fix(auth): close diagnostic filesystem races

This commit is contained in:
2026-08-17 13:04:15 +02:00
parent be1724890a
commit 6d438f4c7e
16 changed files with 545 additions and 90 deletions
+91 -4
View File
@@ -7,6 +7,7 @@ import { createAuthenticationConfigProvider } from "../src/auth/config.js";
import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js";
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
import { createOidcProtocol, OidcJwksUnavailableError } from "../src/auth/oidc-client.js";
import { validateAuthSessionRoot } from "../src/auth/session-store.js";
import type { LoadedAuthConfig } from "../src/auth/types.js";
const sentinels = [
@@ -151,6 +152,7 @@ test("distinguishes a valid registry without an enabled admin from a malformed r
chmodSync(validUsers, 0o600);
const validReport = await createAuthDiagnoser({
authMode: "local", authStateRoot: validRoot,
sessionRootValidator: validateAuthSessionRoot,
authentication: { current: () => localConfig(join(validRoot, "auth.yaml")) },
localUserRegistry: createLocalUserRegistry(validUsers),
}).inspect({ live: false });
@@ -162,6 +164,7 @@ test("distinguishes a valid registry without an enabled admin from a malformed r
chmodSync(malformedUsers, 0o600);
const malformedReport = await createAuthDiagnoser({
authMode: "local", authStateRoot: malformedRoot,
sessionRootValidator: validateAuthSessionRoot,
authentication: { current: () => localConfig(join(malformedRoot, "auth.yaml")) },
localUserRegistry: createLocalUserRegistry(malformedUsers),
}).inspect({ live: false });
@@ -179,6 +182,7 @@ test("maps unsafe auth.yaml storage from the real provider to a redacted config
chmodSync(unsafePath, 0o640);
const report = await createAuthDiagnoser({
authMode: "local", authStateRoot: root,
sessionRootValidator: validateAuthSessionRoot,
authentication: createAuthenticationConfigProvider(unsafePath),
}).inspect({ live: false });
@@ -367,6 +371,42 @@ test.each([
id_token_signing_alg_values_supported: ["RS256"],
}],
["schema-invalid metadata", 200, { issuer: "https://different-issuer.example.test" }],
["an unsafe foreign issuer", 200, {
issuer: "http://different-issuer.example.test",
authorization_endpoint: "https://issuer.example.test/authorize",
token_endpoint: "https://issuer.example.test/token",
jwks_uri: "https://issuer.example.test/jwks",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
}],
["an unsafe authorization endpoint", 200, {
issuer: "https://different-issuer.example.test",
authorization_endpoint: "http://127.0.0.1/authorize",
token_endpoint: "https://issuer.example.test/token",
jwks_uri: "https://issuer.example.test/jwks",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
}],
["an unsafe token endpoint", 200, {
issuer: "https://different-issuer.example.test",
authorization_endpoint: "https://issuer.example.test/authorize",
token_endpoint: "https://operator:secret@issuer.example.test/token",
jwks_uri: "https://issuer.example.test/jwks",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
}],
["an unsafe JWKS endpoint", 200, {
issuer: "https://different-issuer.example.test",
authorization_endpoint: "https://issuer.example.test/authorize",
token_endpoint: "https://issuer.example.test/token",
jwks_uri: "https://issuer.example.test/jwks#fragment",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
}],
])("does not classify %s containing an issuer as an issuer mismatch", async (_label, status, body) => {
const loaded = oidcConfig();
const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json(body, { status }));
@@ -403,8 +443,13 @@ test("redacts exceptional configuration, registry, protocol, and catalog errors"
});
test.skipIf(process.platform === "win32")("uses the runtime validator for canonical, private session roots", async () => {
const dependencies = (authStateRoot: string) => ({
authMode: "none" as const,
authStateRoot,
sessionRootValidator: validateAuthSessionRoot,
});
const valid = privateRoot();
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
await expect(createAuthDiagnoser(dependencies(valid)).inspect({ live: false }))
.resolves.toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
const realRoot = join(privateRoot(), "real-auth");
@@ -419,22 +464,64 @@ test.skipIf(process.platform === "win32")("uses the runtime validator for canoni
writeFileSync(blockedParent, "blocked", { mode: 0o600 });
const traversal = `${valid}/../${basename(valid)}`;
const missingReport = await createAuthDiagnoser({ authMode: "none", authStateRoot: absent }).inspect({ live: false });
const missingReport = await createAuthDiagnoser(dependencies(absent)).inspect({ live: false });
expect(missingReport).toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
expect(existsSync(absent)).toBe(false);
for (const unsafe of [traversal, linkedRoot, absentNested, join(blockedParent, "auth")]) {
const report = await createAuthDiagnoser({ authMode: "none", authStateRoot: unsafe }).inspect({ live: false });
const report = await createAuthDiagnoser(dependencies(unsafe)).inspect({ live: false });
expect(report).toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
expect(JSON.stringify(report)).not.toContain(unsafe);
}
expect(existsSync(absentParent)).toBe(false);
chmodSync(valid, 0o750);
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
await expect(createAuthDiagnoser(dependencies(valid)).inspect({ live: false }))
.resolves.toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
});
test.skipIf(process.platform === "win32")("diagnoses unsafe existing session-store children without creating missing children", async () => {
const root = privateRoot();
const outside = privateRoot();
symlinkSync(outside, join(root, "sessions"));
const linked = await createAuthDiagnoser({
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
}).inspect({ live: false });
expect(linked).toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
});
expect(existsSync(join(root, "oidc"))).toBe(false);
expect(JSON.stringify(linked)).not.toContain(root);
rmSync(join(root, "sessions"));
mkdirSync(join(root, "sessions"), { mode: 0o700 });
chmodSync(join(root, "sessions"), 0o700);
mkdirSync(join(root, "oidc"), { mode: 0o700 });
chmodSync(join(root, "oidc"), 0o750);
const nonPrivate = await createAuthDiagnoser({
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
}).inspect({ live: false });
expect(nonPrivate).toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
});
});
test.skipIf(process.platform === "win32")("routes production POSIX static validation through the native auth-storage bridge", async () => {
const validateRoot = vi.fn(async () => undefined);
const report = await createAuthDiagnoser({
authMode: "none",
authStateRoot: "/var/lib/thothii/auth",
posixStorageBridge: { validateRoot },
}).inspect({ live: false });
expect(report).toMatchObject({ ready: true });
expect(validateRoot).toHaveBeenCalledOnce();
expect(validateRoot).toHaveBeenCalledWith("/var/lib/thothii/auth");
});
test("routes native Windows static session-root validation through the auth-storage bridge", async () => {
const originalPlatform = process.platform;
const validateRoot = vi.fn(async () => undefined);
+4 -1
View File
@@ -6,6 +6,7 @@ import { stringify } from "yaml";
import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
import { loadAuthenticationConfig } from "../src/auth/config.js";
import { loadConfig } from "../src/config.js";
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
@@ -47,9 +48,11 @@ test("each login and session resolve uses the current config snapshot users file
chmodSync(authFile, 0o600);
chmodSync(usersAFile, 0o600);
chmodSync(usersBFile, 0o600);
const authStateRoot = join(directory, "auth-state");
prepareAuthStateRoot(authStateRoot);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authFile,
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
THT_AUTH_STATE_ROOT: authStateRoot,
THT_HARNESS_DIR: "/tmp/h",
}));
cleanups.push(async () => {
+4 -1
View File
@@ -7,6 +7,7 @@ import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
import { loadAuthenticationConfig } from "../src/auth/config.js";
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "../src/auth/types.js";
import { loadConfig } from "../src/config.js";
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
@@ -78,9 +79,11 @@ async function createFixture(first: "A" | "B", later: "A" | "B" | "oidc") {
return calls === 1 ? snapshots[first] : snapshots[later === "oidc" ? "B" : later];
},
};
const authStateRoot = join(directory, "auth-state");
prepareAuthStateRoot(authStateRoot);
const config = loadConfig({
THT_AUTH_CONFIG_FILE: authA,
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
THT_AUTH_STATE_ROOT: authStateRoot,
THT_HARNESS_DIR: "/tmp/h",
});
config.authentication = provider;
+3
View File
@@ -6,6 +6,7 @@ import { stringify } from "yaml";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { LoginFailureLimiter } from "../src/auth/routes.js";
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
@@ -82,6 +83,7 @@ async function createLocalApp(options: {
writeFileSync(usersFile, usersYaml({ enabled: options.enabled }), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
prepareAuthStateRoot(authStateRoot);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authConfigFile,
THT_AUTH_STATE_ROOT: authStateRoot,
@@ -151,6 +153,7 @@ test("remembered login uses a persistent secure cookie under an HTTPS public URL
writeFileSync(usersFile, usersYaml(), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
prepareAuthStateRoot(authStateRoot);
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
const first = buildApp(config());
try {
+69
View File
@@ -107,6 +107,10 @@ afterEach(() => {
function root(): string {
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-session-"));
chmodSync(path, 0o700);
for (const child of ["sessions", "oidc"]) {
mkdirSync(join(path, child), { mode: 0o700 });
chmodSync(join(path, child), 0o700);
}
roots.push(path);
return path;
}
@@ -265,10 +269,75 @@ describe("file-backed auth session store", () => {
}
expect(existsSync(join(outer, "absent-parent"))).toBe(false);
for (const child of ["sessions", "oidc"] as const) {
const childRoot = join(outer, `child-${child}`);
mkdirSync(childRoot, { mode: 0o700 });
chmodSync(childRoot, 0o700);
const childPath = join(childRoot, child);
const outside = root();
symlinkSync(outside, childPath);
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
expect(readdirSync(outside).sort()).toEqual(["oidc", "sessions"]);
unlinkSync(childPath);
mkdirSync(childPath, { mode: 0o700 });
chmodSync(childPath, 0o750);
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
}
const missingChildren = join(outer, "missing-children");
mkdirSync(missingChildren, { mode: 0o700 });
chmodSync(missingChildren, 0o700);
expect(() => validateAuthSessionRoot(missingChildren)).not.toThrow();
expect(existsSync(join(missingChildren, "sessions"))).toBe(false);
expect(existsSync(join(missingChildren, "oidc"))).toBe(false);
chmodSync(valid, 0o750);
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
});
test.skipIf(process.platform === "win32")("delegates missing layout creation and then enforces static/runtime parity", async () => {
const storageRoot = join(root(), "auth");
const ensureLayout = vi.fn(async (requestedRoot: string) => {
expect(requestedRoot).toBe(storageRoot);
mkdirSync(requestedRoot, { mode: 0o700 });
chmodSync(requestedRoot, 0o700);
for (const child of ["sessions", "oidc"]) {
mkdirSync(join(requestedRoot, child), { mode: 0o700 });
chmodSync(join(requestedRoot, child), 0o700);
}
});
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
await expect(create(store)).resolves.toMatchObject({ record: { method: "local" } });
expect(ensureLayout).toHaveBeenCalledOnce();
expect(() => validateAuthSessionRoot(storageRoot)).not.toThrow();
chmodSync(join(storageRoot, "oidc"), 0o750);
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
await expectStoreInvalid(store.createOidcState(oidcInput("n".repeat(16), "v".repeat(43)), base));
});
test.skipIf(process.platform === "win32")("does not perform a path-based fallback when bridge layout creation loses an ancestor race", async () => {
const outer = root();
const outside = root();
const parent = join(outer, "parent");
const movedParent = join(outer, "parent-original");
mkdirSync(parent, { mode: 0o700 });
chmodSync(parent, 0o700);
const storageRoot = join(parent, "auth");
const ensureLayout = vi.fn(async () => {
renameSync(parent, movedParent);
symlinkSync(outside, parent);
throw new Error(`${storageRoot} rejected`);
});
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
await expectStoreInvalid(create(store));
expect(ensureLayout).toHaveBeenCalledOnce();
expect(existsSync(join(outside, "auth"))).toBe(false);
expect(existsSync(join(movedParent, "auth"))).toBe(false);
});
test.skipIf(process.platform === "win32")("never follows a symlinked ancestor while creating a missing session root", async () => {
const outer = root();
const outside = root();
+14 -2
View File
@@ -1,4 +1,4 @@
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
@@ -37,6 +37,16 @@ function cookiePair(setCookie: string): string {
return setCookie.split(";", 1)[0] ?? "";
}
/** Unit fixtures which bypass the installed tht binary start from an already-safe native layout. */
export function prepareAuthStateRoot(root: string): void {
mkdirSync(root, { mode: 0o700 });
chmodSync(root, 0o700);
for (const child of ["sessions", "oidc"]) {
mkdirSync(join(root, child), { mode: 0o700 });
chmodSync(join(root, child), 0o700);
}
}
/** Creates a production-local app and authenticates through the real login/session boundary. */
export async function createLocalAuthFixture(
deps?: BuildAppDeps,
@@ -70,9 +80,11 @@ export async function createLocalAuthFixture(
chmodSync(authConfigFile, 0o600);
chmodSync(usersFile, 0o600);
const authStateRoot = join(directory, "auth-state");
prepareAuthStateRoot(authStateRoot);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authConfigFile,
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
THT_AUTH_STATE_ROOT: authStateRoot,
THT_HARNESS_DIR: "/tmp/h",
}), deps);
let downstream = 0;
+35 -1
View File
@@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
import {
createPosixAuthStorageBridge,
createWindowsAuthStorageBridge,
} from "../src/auth/windows-auth-storage.js";
const root = "C:\\ProgramData\\ThothII\\auth";
const filename = "a".repeat(64) + ".json";
@@ -80,6 +83,37 @@ function bridgeForChild(child: FakeBridgeChild) {
}
describe("Windows auth-storage bridge", () => {
test("uses the same bounded hidden bridge to ensure a POSIX session layout", async () => {
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
const bridge = createPosixAuthStorageBridge({
thtExecutable: "/opt/thothii/bin/tht",
invoke: async (call) => {
calls.push(call);
return {
code: 0,
stdout: Buffer.from('{"version":1,"ok":true,"prepared":true}\n'),
stderr: Buffer.alloc(0),
};
},
});
await expect(bridge.ensureLayout("/var/lib/thothii/auth")).resolves.toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
executable: "/opt/thothii/bin/tht",
args: ["_auth-storage"],
timeoutMs: 5_000,
});
expect(JSON.parse(calls[0]!.input.toString("utf8"))).toEqual({
version: 1,
operation: "ensure-layout",
root: "/var/lib/thothii/auth",
});
expect(JSON.stringify(calls[0]!.args)).not.toContain("/var/lib/thothii/auth");
await expect(bridge.ensureLayout("/var/lib/thothii/../auth"))
.rejects.toThrow("auth_session_store_invalid");
});
test("permits reservation slots only for OIDC record operations", async () => {
const requests: Array<Record<string, unknown>> = [];
const bridge = createWindowsAuthStorageBridge({