fix(auth): harden runtime projection validation

This commit is contained in:
User
2026-08-21 22:46:42 +02:00
parent 05f8615887
commit da2f4a6681
2 changed files with 271 additions and 29 deletions
+183 -16
View File
@@ -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<typeof import("node:fs")>();
const observed = <T extends import("node:fs").Stats>(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"),