diff --git a/backend/src/auth/runtime-projection.ts b/backend/src/auth/runtime-projection.ts index 782d9a32..851ad797 100644 --- a/backend/src/auth/runtime-projection.ts +++ b/backend/src/auth/runtime-projection.ts @@ -4,9 +4,9 @@ import { constants, fstatSync, lstatSync, + opendirSync, openSync, readSync, - readdirSync, } from "node:fs"; import type { Stats } from "node:fs"; import { isAbsolute, join, normalize } from "node:path"; @@ -23,6 +23,7 @@ const MAX_AUTH_BYTES = 1 << 20; const MAX_USERS_BYTES = 1 << 20; const MAX_SELECTOR_BYTES = 4096; const MAX_MANIFEST_BYTES = 4096; +const MAX_DIRECTORY_ENTRIES = 16; const DIR_MODE = 0o700; const FILE_MODE = 0o600; const GENERATION = /^[0-9a-f]{64}$/; @@ -69,6 +70,13 @@ function runtimeOwner(): number { if (!Number.isSafeInteger(uid) || uid < 0) throw invalid(); return uid; } +function runtimeGroup(): number { + if (process.platform === "win32" || typeof process.getegid !== "function") + throw invalid(); + const gid = process.getegid(); + if (!Number.isSafeInteger(gid) || gid < 0) throw invalid(); + return gid; +} function meta(info: Stats): Identity { return { dev: info.dev, @@ -97,7 +105,12 @@ function same(a: Identity, b: Identity): boolean { } function directory(info: Stats, uid: number): Identity { const value = meta(info); - if (!info.isDirectory() || value.uid !== uid || value.mode !== DIR_MODE) + if ( + !info.isDirectory() || + value.uid !== uid || + value.gid !== runtimeGroup() || + value.mode !== DIR_MODE + ) throw invalid(); return value; } @@ -106,6 +119,7 @@ function regular(info: Stats, uid: number, maximum: number): Identity { if ( !info.isFile() || value.uid !== uid || + value.gid !== runtimeGroup() || value.mode !== FILE_MODE || value.nlink !== 1 || value.size < 0 || @@ -120,7 +134,8 @@ function checkRoot(root: string): void { root.length === 0 || root.includes("\0") || !isAbsolute(root) || - normalize(root) !== root + normalize(root) !== root || + (root.length > 1 && root.endsWith("/")) ) throw invalid(); } @@ -163,7 +178,22 @@ function stableDirectory( function entries(path: string, uid: number, expected: readonly string[]): void { const opened = openDirectory(path, uid); try { - const names = readdirSync(path); + const directory = opendirSync(`/proc/self/fd/${opened.fd}`, { + bufferSize: 1, + }); + const names: string[] = []; + try { + for (;;) { + const entry = directory.readSync(); + if (entry === null) break; + if (names.length === MAX_DIRECTORY_ENTRIES) throw invalid(); + names.push(entry.name); + } + } finally { + try { + directory.closeSync(); + } catch {} + } if ( names.length !== expected.length || new Set(names).size !== names.length || @@ -180,6 +210,55 @@ function entries(path: string, uid: number, expected: readonly string[]): void { function replaced(before: Identity, after: Identity): boolean { return before.dev !== after.dev || before.ino !== after.ino; } +function rootIdentityAtPathAndDescriptor( + root: string, + openedRoot: { fd: number; identity: Identity }, + uid: number, +): Identity { + const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid); + const pathAfter = directory(lstatSync(root) as Stats, uid); + if (!same(openedAfter, pathAfter)) throw invalid(); + return openedAfter; +} +function validateRootAndCurrentAfterLoad( + root: string, + openedRoot: { fd: number; identity: Identity }, + currentPath: string, + selectedCurrent: Identity, + uid: number, +): void { + const rootBeforeCurrent = rootIdentityAtPathAndDescriptor( + root, + openedRoot, + uid, + ); + const currentAfter = regular( + lstatSync(currentPath) as Stats, + uid, + MAX_SELECTOR_BYTES, + ); + const rootAfterCurrent = rootIdentityAtPathAndDescriptor( + root, + openedRoot, + uid, + ); + if ( + !same(openedRoot.identity, rootBeforeCurrent) || + !same(rootBeforeCurrent, rootAfterCurrent) + ) { + const latestCurrent = regular( + lstatSync(currentPath) as Stats, + uid, + MAX_SELECTOR_BYTES, + ); + if (replaced(selectedCurrent, latestCurrent)) throw new CurrentReplaced(); + throw invalid(); + } + if (!same(selectedCurrent, currentAfter)) { + if (replaced(selectedCurrent, currentAfter)) throw new CurrentReplaced(); + throw invalid(); + } +} function readRegular( path: string, parentPath: string, @@ -484,17 +563,13 @@ function load(root: string): LoadedAuthConfig { for (const predecessor of selected.previousGenerations ?? []) validateGeneration(generationsPath, predecessor, uid); stableDirectory(generationsPath, openedGenerations, uid); - const afterCurrent = regular( - lstatSync(currentPath) as Stats, + validateRootAndCurrentAfterLoad( + root, + openedRoot, + currentPath, + selectedCurrent.identity, uid, - MAX_SELECTOR_BYTES, ); - if (!same(selectedCurrent.identity, afterCurrent)) { - if (replaced(selectedCurrent.identity, afterCurrent)) - throw new CurrentReplaced(); - throw invalid(); - } - stableDirectory(root, openedRoot, uid); return { value: selectedGeneration.value, revision: `sha256:${selected.generation}`, diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts index 0fa6e38b..b76181e0 100644 --- a/backend/test/auth-runtime-projection.test.ts +++ b/backend/test/auth-runtime-projection.test.ts @@ -21,20 +21,40 @@ import { afterEach, expect, test, vi } from "vitest"; import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js"; import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js"; -const lstatHook = vi.hoisted(() => ({ +const fsHook = vi.hoisted(() => ({ path: undefined as string | undefined, callback: undefined as (() => void) | undefined, + rejectPathReaddir: false, + foreignGid: undefined as number | undefined, })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); + const observed = (value: T): T => + fsHook.foreignGid === undefined + ? value + : new Proxy(value, { + get(target, property) { + if (property === "gid") return fsHook.foreignGid; + const member = Reflect.get(target, property, target); + return typeof member === "function" ? member.bind(target) : member; + }, + }); return { ...actual, lstatSync(path: import("node:fs").PathLike) { - const result = actual.lstatSync(path); - if (lstatHook.path === String(path)) lstatHook.callback?.(); + const result = observed(actual.lstatSync(path)); + if (fsHook.path === String(path)) fsHook.callback?.(); return result; }, + fstatSync(fd: number) { + return observed(actual.fstatSync(fd)); + }, + readdirSync(path: import("node:fs").PathLike) { + if (fsHook.rejectPathReaddir) + throw new Error("path readdir is forbidden"); + return actual.readdirSync(path); + }, }; }); @@ -46,8 +66,10 @@ const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const roots: string[] = []; afterEach(() => { - lstatHook.path = undefined; - lstatHook.callback = undefined; + fsHook.path = undefined; + fsHook.callback = undefined; + fsHook.rejectPathReaddir = false; + fsHook.foreignGid = undefined; for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -175,6 +197,66 @@ function writeReadyProjection( return generation; } +function writeReadyOidcProjection(root: string): string { + mkdirSync(join(root, "generations"), { mode: 0o700 }); + chmodSync(join(root, "generations"), 0o700); + const auth = stringify({ + version: 1, + mode: "oidc", + publicUrl: "https://thothii.example.org", + oidc: { + issuer: "https://authentik.example.org/application/o/thothii/", + clientId: "thothii", + clientSecretRef: "THT_OIDC_CLIENT_SECRET", + scopes: ["openid", "profile", "email"], + groupsClaim: "groups", + }, + groupCatalog: { + driver: "authentik", + baseUrl: "https://authentik.example.org", + apiTokenRef: "THT_AUTHENTIK_API_TOKEN", + }, + authorization: { + groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] }, + }, + }); + const generation = sha256( + `thothii-auth-projection-v1\nmode=oidc\nauth=${sha256(auth)}\nusers=-\n`, + ); + const directory = join(root, "generations", generation); + mkdirSync(directory, { mode: 0o700 }); + chmodSync(directory, 0o700); + const manifest = `${JSON.stringify({ + version: 1, + generation, + mode: "oidc", + canonicalRevision: `sha256:${generation}`, + files: [ + { + name: "auth.yaml", + size: Buffer.byteLength(auth), + sha256: sha256(auth), + }, + ], + })}\n`; + writeFileSync(join(directory, "manifest.json"), manifest, { + encoding: "utf8", + mode: 0o600, + }); + writeFileSync(join(directory, "auth.yaml"), auth, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(directory, "manifest.json"), 0o600); + chmodSync(join(directory, "auth.yaml"), 0o600); + writeFileSync(join(root, "CURRENT"), currentDocument(generation), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(root, "CURRENT"), 0o600); + return generation; +} + function expectDenied(operation: () => unknown): void { try { operation(); @@ -209,6 +291,28 @@ test("loads ready projection as one immutable auth and local-users snapshot", () ).toBe(true); }); +test("loads a complete OIDC projection without a users snapshot", () => { + const root = projectionRoot(); + const generation = writeReadyOidcProjection(root); + const loaded = createProjectedAuthenticationConfigProvider(root).current(); + expect(loaded.value.mode).toBe("oidc"); + expect(loaded.runtimeProjection).toEqual({ + generation, + canonicalRevision: `sha256:${generation}`, + }); +}); + +test("rejects a trailing-slash runtime root", () => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + expectDenied(() => + createProjectedAuthenticationConfigProvider(`${root}/`).current(), + ); +}); + test.each([ ["missing", undefined], [ @@ -296,6 +400,32 @@ test.runIf(process.geteuid?.() === 0)( }, ); +test("rejects a foreign group with the correct owner", () => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + const gid = process.getegid?.() ?? 0; + fsHook.foreignGid = gid === 1 ? 2 : 1; + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test("enumerates closed namespaces without path-based readdirSync", () => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + fsHook.rejectPathReaddir = true; + expect( + createProjectedAuthenticationConfigProvider(root).current() + .runtimeProjection?.generation, + ).toBe(generation); +}); + test("rejects a symlinked runtime root", () => { const root = projectionRoot(); writeReadyProjection( @@ -409,9 +539,9 @@ test("retries once when CURRENT is atomically replaced between lstat and open", localProjectionFixture("second", passwordHash), ); const temporary = join(root, ".current-replacement.tmp"); - lstatHook.path = join(root, "CURRENT"); - lstatHook.callback = () => { - lstatHook.callback = undefined; + fsHook.path = join(root, "CURRENT"); + fsHook.callback = () => { + fsHook.callback = undefined; writeFileSync(temporary, currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, @@ -425,6 +555,43 @@ test("retries once when CURRENT is atomically replaced between lstat and open", ).toBe(second); }); +test("retries once when CURRENT is replaced after the final identity read", () => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + const stagedParent = mkdtempSync( + join(tmpdir(), "tht-auth-projection-late-generation-"), + ); + roots.push(stagedParent); + renameSync(join(root, "generations", second), join(stagedParent, second)); + let observations = 0; + fsHook.path = join(root, "CURRENT"); + fsHook.callback = () => { + observations += 1; + if (observations !== 3) return; + fsHook.callback = undefined; + renameSync(join(stagedParent, second), join(root, "generations", second)); + const temporary = join(root, ".current-late-replacement.tmp"); + writeFileSync(temporary, currentDocument(second, [first]), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(temporary, 0o600); + renameSync(temporary, join(root, "CURRENT")); + }; + expect( + createProjectedAuthenticationConfigProvider(root).current() + .runtimeProjection?.generation, + ).toBe(second); + expect(observations).toBe(3); +}); + test("rejects a second CURRENT replacement after the one permitted retry", () => { const root = projectionRoot(); const first = writeReadyProjection( @@ -443,8 +610,8 @@ test("rejects a second CURRENT replacement after the one permitted retry", () => { generation: second, previous: [first] }, { generation: third, previous: [second, first] }, ]; - lstatHook.path = join(root, "CURRENT"); - lstatHook.callback = () => { + fsHook.path = join(root, "CURRENT"); + fsHook.callback = () => { const replacement = replacements.shift(); if (!replacement) return; const temporary = join( @@ -484,9 +651,9 @@ test("fails deterministically when generations is replaced during a load", () => }); chmodSync(join(replacement, generation, name), 0o600); } - lstatHook.path = join(root, "generations"); - lstatHook.callback = () => { - lstatHook.callback = undefined; + fsHook.path = join(root, "generations"); + fsHook.callback = () => { + fsHook.callback = undefined; renameSync(join(root, "generations"), join(root, "generations-retired")); renameSync(replacement, join(root, "generations")); }; @@ -513,9 +680,9 @@ test("fails deterministically when the selected generation directory is replaced }); chmodSync(join(replacement, name), 0o600); } - lstatHook.path = join(root, "generations", generation); - lstatHook.callback = () => { - lstatHook.callback = undefined; + fsHook.path = join(root, "generations", generation); + fsHook.callback = () => { + fsHook.callback = undefined; renameSync( join(root, "generations", generation), join(root, "generation-retired"),