diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index 8478b369..7d70d99d 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -21,6 +21,13 @@ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3} const ROLES = ["user", "admin"] as const; const invalid = (): Error => new Error("local_user_registry_invalid"); +function runtimeOwner(): number { + if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid(); + const owner = process.geteuid(); + if (!Number.isSafeInteger(owner) || owner < 0) throw invalid(); + return owner; +} + export interface LocalUserRecord { id: string; username: string; @@ -53,6 +60,7 @@ export interface LocalUserRegistryOptions { interface FileIdentity { dev: number; ino: number; + uid: number; size: number; mtimeMs: number; } @@ -60,7 +68,8 @@ interface FileIdentity { interface DirectoryIdentity { dev: number; ino: number; - mode?: number; + uid: number; + mode: number; } interface RegistryIdentity { @@ -87,11 +96,12 @@ function normalizeUsername(username: string): string { } function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs; + return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid + && left.size === right.size && left.mtimeMs === right.mtimeMs; } function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { - return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode; + return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; } function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean { @@ -104,36 +114,27 @@ function validateCanonicalPath(path: string): void { if (realpathSync(parent) !== parent) throw invalid(); } -function validateMetadata(info: { isFile(): boolean; nlink: number; mode: number }): void { - if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid(); -} - -function fileMetadata(info: Stats): FileIdentity { - validateMetadata(info); +function fileMetadata(info: Stats, owner: number): FileIdentity { + if (!info.isFile() || info.uid !== owner || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid(); if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid(); - return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs }; + return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs }; } -function directoryMetadata(info: Stats): DirectoryIdentity { - if (!info.isDirectory()) throw invalid(); - if (process.platform !== "win32" && (info.mode & 0o7777) !== 0o700) throw invalid(); - return { - dev: info.dev, - ino: info.ino, - ...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }), - }; +function directoryMetadata(info: Stats, owner: number): DirectoryIdentity { + if (!info.isDirectory() || info.uid !== owner || (info.mode & 0o7777) !== 0o700) throw invalid(); + return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777 }; } -function directoryIdentity(path: string): DirectoryIdentity { +function directoryIdentity(path: string, owner: number): DirectoryIdentity { const parent = dirname(path); if (realpathSync(parent) !== parent) throw invalid(); - return directoryMetadata(lstatSync(parent) as Stats); + return directoryMetadata(lstatSync(parent) as Stats, owner); } -function registryIdentity(path: string): RegistryIdentity { +function registryIdentity(path: string, owner: number): RegistryIdentity { validateCanonicalPath(path); const info = lstatSync(path); - return { file: fileMetadata(info as Stats), directory: directoryIdentity(path) }; + return { file: fileMetadata(info as Stats, owner), directory: directoryIdentity(path, owner) }; } function openDirectoryDescriptor(path: string): number | undefined { @@ -145,21 +146,21 @@ function openDirectoryDescriptor(path: string): number | undefined { return openSync(path, flags); } -function readBounded(path: string): { source: string; identity: RegistryIdentity } { +function readBounded(path: string, owner: number): { source: string; identity: RegistryIdentity } { validateCanonicalPath(path); - const beforeDirectory = directoryIdentity(path); + const beforeDirectory = directoryIdentity(path, owner); const beforePath = lstatSync(path); - const before = fileMetadata(beforePath as Stats); + const before = fileMetadata(beforePath as Stats, owner); let directoryDescriptor: number | undefined; let descriptor: number | undefined; try { directoryDescriptor = openDirectoryDescriptor(dirname(path)); const openedDirectory = directoryDescriptor === undefined ? beforeDirectory - : directoryMetadata(fstatSync(directoryDescriptor) as Stats); + : directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner); if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid(); descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - const opened = fileMetadata(fstatSync(descriptor) as Stats); + const opened = fileMetadata(fstatSync(descriptor) as Stats, owner); if (!sameFileIdentity(before, opened)) throw invalid(); const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1); let offset = 0; @@ -169,12 +170,12 @@ function readBounded(path: string): { source: string; identity: RegistryIdentity offset += bytesRead; } if (offset > MAX_USERS_YAML_BYTES) throw invalid(); - const after = fileMetadata(fstatSync(descriptor) as Stats); - const afterPath = fileMetadata(lstatSync(path) as Stats); - const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats); + const after = fileMetadata(fstatSync(descriptor) as Stats, owner); + const afterPath = fileMetadata(lstatSync(path) as Stats, owner); + const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats, owner); const afterOpenedDirectory = directoryDescriptor === undefined ? afterDirectory - : directoryMetadata(fstatSync(directoryDescriptor) as Stats); + : directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner); if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath) || !sameDirectoryIdentity(beforeDirectory, afterDirectory) || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); @@ -221,8 +222,8 @@ function parseRegistry(source: string): LocalUserRecord[] { } } -function load(path: string): { records: LocalUserRecord[]; identity: RegistryIdentity } { - const read = readBounded(path); +function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } { + const read = readBounded(path, owner); return { records: parseRegistry(read.source), identity: read.identity }; } @@ -234,11 +235,12 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg function currentPosix(): LocalUserRecord[] { try { - const before = registryIdentity(usersPath); + const owner = runtimeOwner(); + const before = registryIdentity(usersPath, owner); if (cached && sameIdentity(cached.identity, before)) return cached.records; for (let attempt = 0; attempt < 2; attempt += 1) { - const loaded = load(usersPath); - if (sameIdentity(loaded.identity, registryIdentity(usersPath))) { + const loaded = load(usersPath, owner); + if (sameIdentity(loaded.identity, registryIdentity(usersPath, owner))) { cached = loaded; return loaded.records; } diff --git a/backend/test/local-registry.test.ts b/backend/test/local-registry.test.ts index 649fc099..6a7718b5 100644 --- a/backend/test/local-registry.test.ts +++ b/backend/test/local-registry.test.ts @@ -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(); + const replaceUid = (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 = ( + 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);