fix(auth): harden runtime projection validation
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user