fix(auth): honor HTTPS sessions in Pi management

This commit is contained in:
2026-08-17 01:46:34 +02:00
parent 09e546c1ef
commit 8c67cb75dc
5 changed files with 146 additions and 14 deletions
@@ -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();
}
});
+36
View File
@@ -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",
+19 -4
View File
@@ -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,
};