fix(auth): require local registry ownership

This commit is contained in:
2026-08-18 10:31:44 +02:00
parent 3c6ddfaef1
commit d43738eeae
2 changed files with 111 additions and 36 deletions
+73
View File
@@ -15,6 +15,47 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
type OwnershipObservation =
| "file-lstat"
| "file-fstat"
| "directory-lstat"
| "directory-fstat";
const ownershipOverride = vi.hoisted(() => ({
observation: undefined as OwnershipObservation | undefined,
uid: undefined as number | undefined,
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const replaceUid = <T extends object>(value: T, uid: number): T => new Proxy(value, {
get(target, property) {
if (property === "uid") return uid;
const member = Reflect.get(target, property, target);
return typeof member === "function" ? member.bind(target) : member;
},
});
const maybeReplace = <T extends import("node:fs").Stats>(
value: T,
source: "lstat" | "fstat",
): T => {
const kind = value.isDirectory() ? "directory" : "file";
return ownershipOverride.observation === `${kind}-${source}` && ownershipOverride.uid !== undefined
? replaceUid(value, ownershipOverride.uid)
: value;
};
return {
...actual,
lstatSync(path: import("node:fs").PathLike) {
return maybeReplace(actual.lstatSync(path), "lstat");
},
fstatSync(fd: number) {
return maybeReplace(actual.fstatSync(fd), "fstat");
},
};
});
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
const password = "correct horse battery staple";
@@ -25,6 +66,8 @@ const userId = "7ba7b810-9dad-4ed1-80b4-00c04fd430c8";
const createdRoots: string[] = [];
afterEach(() => {
ownershipOverride.observation = undefined;
ownershipOverride.uid = undefined;
for (const root of createdRoots.splice(0)) {
for (const name of ["users.yaml", "users-link.yaml", "users-target.yaml", "replacement.yaml"]) {
const path = join(root, name);
@@ -190,6 +233,36 @@ describe("local user registry", () => {
},
);
test.runIf(process.platform !== "win32").each([
"file-lstat",
"file-fstat",
"directory-lstat",
"directory-fstat",
] as const)("rejects foreign ownership at the %s boundary", async (observation) => {
const fixture = writeRegistry(registryYaml(userYaml()));
const owner = process.geteuid();
ownershipOverride.observation = observation;
ownershipOverride.uid = owner === 0 ? 1 : owner - 1;
await expectInvalid(
createLocalUserRegistry(fixture.path).findByUsername("admin"),
["admin", passwordHash, fixture.path],
);
});
test.runIf(process.platform !== "win32")("fails closed when the effective UID is invalid", async () => {
const fixture = writeRegistry(registryYaml(userYaml()));
const getuid = vi.spyOn(process, "geteuid").mockReturnValue(-1);
try {
await expectInvalid(
createLocalUserRegistry(fixture.path).findByUsername("admin"),
["admin", passwordHash, fixture.path],
);
} finally {
getuid.mockRestore();
}
});
test("reloads a same-size atomic replacement with changed metadata", async () => {
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Admin" })));
const registry = createLocalUserRegistry(fixture.path);