fix(auth): close diagnostic filesystem races
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user