fix(auth): honor HTTPS sessions in Pi management
This commit is contained in:
@@ -23,11 +23,21 @@ export function requirePermission(
|
||||
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved cookie session is populated only by the central auth boundary, after its
|
||||
* request-snapshot Origin and CSRF checks. Route-specific legacy guards must not reinterpret
|
||||
* the internal transport host/protocol for that already-authorized browser request.
|
||||
*/
|
||||
export function hasCookieBackedAuthSession(request: FastifyRequest): boolean {
|
||||
return request.authSession !== undefined;
|
||||
}
|
||||
|
||||
/** Permit non-browser clients and browsers whose declared origin matches the request host. */
|
||||
export function requireSameOriginOrNonBrowser(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): FastifyReply | undefined {
|
||||
if (hasCookieBackedAuthSession(request)) return undefined;
|
||||
const origin = request.headers.origin;
|
||||
if (origin === undefined) return undefined;
|
||||
if (typeof origin !== "string" || typeof request.headers.host !== "string") {
|
||||
|
||||
@@ -122,7 +122,8 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
});
|
||||
|
||||
app.post("/auth/local/login", async (request, reply) => {
|
||||
const configured = currentLocalConfig(captureAuthConfigSnapshot(request, deps.authentication), deps);
|
||||
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
||||
const configured = currentLocalConfig(snapshot, deps);
|
||||
if (configured.kind === "unavailable") {
|
||||
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
|
||||
}
|
||||
@@ -172,7 +173,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
|
||||
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
|
||||
});
|
||||
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.secure, payload.remember));
|
||||
reply.setCookie(sessionCookieName(), created.token, cookieOptions(snapshot, payload.remember));
|
||||
return reply.send({});
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
@@ -187,7 +188,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
if (!token || !deps.sessionStore) return unavailable(reply);
|
||||
try {
|
||||
await deps.sessionStore.revoke(token);
|
||||
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(request), false));
|
||||
reply.clearCookie(sessionCookieName(), cookieOptions(request.authConfigSnapshot, false));
|
||||
return reply.code(204).send();
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
@@ -237,7 +238,6 @@ function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRout
|
||||
| {
|
||||
revision: string;
|
||||
origin: string;
|
||||
secure: boolean;
|
||||
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
|
||||
registry: LocalUserRegistry;
|
||||
kind: "local";
|
||||
@@ -254,7 +254,6 @@ function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRout
|
||||
kind: "local",
|
||||
revision: loaded.revision,
|
||||
origin: url.origin,
|
||||
secure: url.protocol === "https:",
|
||||
session: loaded.value.session,
|
||||
registry,
|
||||
};
|
||||
@@ -263,11 +262,13 @@ function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRout
|
||||
}
|
||||
}
|
||||
|
||||
function currentSecure(request: FastifyRequest): boolean {
|
||||
return request.authConfigSnapshot?.value.publicUrl.startsWith("https:") ?? false;
|
||||
function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boolean) {
|
||||
let secure = false;
|
||||
try {
|
||||
secure = snapshot !== undefined && new URL(snapshot.value.publicUrl).protocol === "https:";
|
||||
} catch {
|
||||
// Invalid auth configurations are rejected before they can reach this route.
|
||||
}
|
||||
|
||||
function cookieOptions(secure: boolean, remembered: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { PiManagementService } from "../src/pi/management.js";
|
||||
import { createLocalAuthFixture } from "./auth-test-fixtures.js";
|
||||
|
||||
function fakeService(): PiManagementService {
|
||||
return {
|
||||
status: vi.fn(async () => ({ ready: true })),
|
||||
options: vi.fn(async () => ({ providers: [], models: [], reasoning: [], checkedAt: "2026-08-17T00:00:00.000Z" })),
|
||||
configure: vi.fn(async (value) => ({ ...value, updatedAt: "2026-08-17T00:00:00.000Z" })),
|
||||
test: vi.fn(async () => ({ ready: true, checkedAt: "2026-08-17T00:00:00.000Z" })),
|
||||
logs: vi.fn(async () => ({ lines: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
test("a local HTTPS cookie session authorizes Pi writes through an untrusted internal HTTP hop", async () => {
|
||||
const service = fakeService();
|
||||
const fixture = await createLocalAuthFixture(
|
||||
{ piManagement: service },
|
||||
{ publicUrl: "HTTPS://thothii.example.test" },
|
||||
);
|
||||
try {
|
||||
// The fixture performs the real login and /me request through the production hooks.
|
||||
expect(fixture.loginStatus).toBe(200);
|
||||
expect(fixture.meStatus).toBe(200);
|
||||
expect(fixture.publicUrl).toBe("HTTPS://thothii.example.test");
|
||||
|
||||
const proxyHeaders = fixture.sessionHeaders({ host: "127.0.0.1:8080" });
|
||||
const configured = await fixture.app.inject({
|
||||
method: "PUT",
|
||||
url: "/pi-management/config",
|
||||
headers: proxyHeaders,
|
||||
payload: { provider: "zai", model: "glm-5.2", reasoning: "high" },
|
||||
});
|
||||
const smoke = await fixture.app.inject({
|
||||
method: "POST",
|
||||
url: "/pi-management/test",
|
||||
headers: proxyHeaders,
|
||||
});
|
||||
|
||||
expect(configured.statusCode).toBe(200);
|
||||
expect(smoke.statusCode).toBe(200);
|
||||
expect(service.configure).toHaveBeenCalledTimes(1);
|
||||
expect(service.test).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.resetDownstreamHits();
|
||||
vi.mocked(service.configure).mockClear();
|
||||
vi.mocked(service.test).mockClear();
|
||||
const wrongOrigin = await fixture.app.inject({
|
||||
method: "PUT",
|
||||
url: "/pi-management/config",
|
||||
headers: fixture.sessionHeaders({ host: "127.0.0.1:8080", origin: "https://evil.example" }),
|
||||
payload: { provider: "zai", model: "glm-5.2", reasoning: "high" },
|
||||
});
|
||||
const wrongCsrf = await fixture.app.inject({
|
||||
method: "POST",
|
||||
url: "/pi-management/test",
|
||||
headers: fixture.sessionHeaders({ host: "127.0.0.1:8080", "x-thothii-csrf": "wrong" }),
|
||||
});
|
||||
|
||||
for (const response of [wrongOrigin, wrongCsrf]) {
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||
}
|
||||
expect(fixture.downstreamHits()).toBe(0);
|
||||
expect(service.configure).not.toHaveBeenCalled();
|
||||
expect(service.test).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -255,6 +255,42 @@ test("logout revokes the session and clears the cookie with the production attri
|
||||
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"an uppercase HTTPS public URL sets and clears the secure cookie for remembered=%s",
|
||||
async (remember) => {
|
||||
const configuredPublicUrl = "HTTPS://thothii.example.test";
|
||||
const origin = new URL(configuredPublicUrl).origin;
|
||||
const { app } = await createLocalApp({ publicUrl: configuredPublicUrl });
|
||||
|
||||
const signedIn = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/local/login",
|
||||
headers: { origin, "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password, remember },
|
||||
});
|
||||
const setCookie = firstSetCookie(signedIn);
|
||||
expect(signedIn.statusCode).toBe(200);
|
||||
expect(setCookie).toContain("Secure");
|
||||
if (remember) expect(setCookie).toContain("Max-Age=2592000");
|
||||
else expect(setCookie).not.toMatch(/Max-Age=/i);
|
||||
|
||||
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
||||
const loggedOut = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
headers: {
|
||||
cookie: cookiePair(setCookie),
|
||||
origin,
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-thothii-csrf": me.json().csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
expect(loggedOut.statusCode).toBe(204);
|
||||
expect(firstSetCookie(loggedOut)).toContain("Secure");
|
||||
},
|
||||
);
|
||||
|
||||
test("failed logins are limited by normalized username and source address", async () => {
|
||||
const user = {
|
||||
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
||||
|
||||
@@ -13,14 +13,21 @@ export const localPublicUrl = "http://127.0.0.1:8787";
|
||||
|
||||
export interface LocalAuthFixture {
|
||||
app: FastifyInstance;
|
||||
publicUrl: string;
|
||||
cookie: string;
|
||||
csrfToken: string;
|
||||
loginStatus: number;
|
||||
meStatus: number;
|
||||
downstreamHits(): number;
|
||||
resetDownstreamHits(): void;
|
||||
sessionHeaders(overrides?: Record<string, string | undefined>): Record<string, string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalAuthFixtureOptions {
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
const header = response.headers["set-cookie"];
|
||||
return Array.isArray(header) ? header[0] ?? "" : header ?? "";
|
||||
@@ -31,7 +38,12 @@ function cookiePair(setCookie: string): string {
|
||||
}
|
||||
|
||||
/** Creates a production-local app and authenticates through the real login/session boundary. */
|
||||
export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise<LocalAuthFixture> {
|
||||
export async function createLocalAuthFixture(
|
||||
deps?: BuildAppDeps,
|
||||
options: LocalAuthFixtureOptions = {},
|
||||
): Promise<LocalAuthFixture> {
|
||||
const publicUrl = options.publicUrl ?? localPublicUrl;
|
||||
const publicOrigin = new URL(publicUrl).origin;
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-fixture-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authConfigFile = join(directory, "auth.yaml");
|
||||
@@ -39,7 +51,7 @@ export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise<Local
|
||||
writeFileSync(authConfigFile, stringify({
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl: localPublicUrl,
|
||||
publicUrl,
|
||||
local: { usersFile: "users.yaml" },
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersFile, [
|
||||
@@ -70,7 +82,7 @@ export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise<Local
|
||||
const signedIn = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/local/login",
|
||||
headers: { origin: localPublicUrl, "sec-fetch-site": "same-origin" },
|
||||
headers: { origin: publicOrigin, "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password: localPassword },
|
||||
});
|
||||
if (signedIn.statusCode !== 200) throw new Error("local_auth_fixture_login_failed");
|
||||
@@ -82,14 +94,17 @@ export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise<Local
|
||||
|
||||
return {
|
||||
app,
|
||||
publicUrl,
|
||||
cookie,
|
||||
csrfToken,
|
||||
loginStatus: signedIn.statusCode,
|
||||
meStatus: me.statusCode,
|
||||
downstreamHits: () => downstream,
|
||||
resetDownstreamHits: () => { downstream = 0; },
|
||||
sessionHeaders(overrides = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
origin: localPublicUrl,
|
||||
origin: publicOrigin,
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-thothii-csrf": csrfToken,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user