diff --git a/backend/src/auth/authorization.ts b/backend/src/auth/authorization.ts index bec563e4..0fbf152e 100644 --- a/backend/src/auth/authorization.ts +++ b/backend/src/auth/authorization.ts @@ -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") { diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts index 85c3bdb2..6278a828 100644 --- a/backend/src/auth/routes.ts +++ b/backend/src/auth/routes.ts @@ -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(secure: boolean, remembered: boolean) { +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. + } return { httpOnly: true, sameSite: "lax" as const, diff --git a/backend/test/auth-pi-management-local.test.ts b/backend/test/auth-pi-management-local.test.ts new file mode 100644 index 00000000..606513c6 --- /dev/null +++ b/backend/test/auth-pi-management-local.test.ts @@ -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(); + } +}); diff --git a/backend/test/auth-routes-local.test.ts b/backend/test/auth-routes-local.test.ts index 8edf2d74..7f30eaf0 100644 --- a/backend/test/auth-routes-local.test.ts +++ b/backend/test/auth-routes-local.test.ts @@ -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", diff --git a/backend/test/auth-test-fixtures.ts b/backend/test/auth-test-fixtures.ts index 39506c97..863620a7 100644 --- a/backend/test/auth-test-fixtures.ts +++ b/backend/test/auth-test-fixtures.ts @@ -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): Record; close(): Promise; } +export interface LocalAuthFixtureOptions { + publicUrl?: string; +} + function firstSetCookie(response: { headers: Record }): 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 { +export async function createLocalAuthFixture( + deps?: BuildAppDeps, + options: LocalAuthFixtureOptions = {}, +): Promise { + 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 downstream, resetDownstreamHits: () => { downstream = 0; }, sessionHeaders(overrides = {}) { const headers: Record = { cookie, - origin: localPublicUrl, + origin: publicOrigin, "sec-fetch-site": "same-origin", "x-thothii-csrf": csrfToken, };