diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index bda47d34..575889d8 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -44,6 +44,17 @@ interface FileIdentity { mtimeMs: number; } +interface DirectoryIdentity { + dev: number; + ino: number; + mode?: number; +} + +interface RegistryIdentity { + file: FileIdentity; + directory: DirectoryIdentity; +} + const roleSchema = z.enum(ROLES); const userSchema = z.strictObject({ id: z.string().regex(UUID_V4_PATTERN), @@ -62,10 +73,18 @@ function normalizeUsername(username: string): string { return username.replace(/[A-Z]/g, (character) => character.toLowerCase()); } -function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { +function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && 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; +} + +function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean { + return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory); +} + function validateCanonicalPath(path: string): void { if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid(); const parent = dirname(path); @@ -76,27 +95,59 @@ function validateMetadata(info: { isFile(): boolean; nlink: number; mode: number if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid(); } -function metadata(info: Stats): FileIdentity { +function fileMetadata(info: Stats): FileIdentity { validateMetadata(info); 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 }; } -function fileIdentity(path: string): FileIdentity { - validateCanonicalPath(path); - const info = lstatSync(path); - return metadata(info as Stats); +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 readBounded(path: string): { source: string; identity: FileIdentity } { +function directoryIdentity(path: string): DirectoryIdentity { + const parent = dirname(path); + if (realpathSync(parent) !== parent) throw invalid(); + return directoryMetadata(lstatSync(parent) as Stats); +} + +function registryIdentity(path: string): RegistryIdentity { validateCanonicalPath(path); + const info = lstatSync(path); + return { file: fileMetadata(info as Stats), directory: directoryIdentity(path) }; +} + +function openDirectoryDescriptor(path: string): number | undefined { + if (process.platform === "win32") return undefined; + const flags = constants.O_RDONLY + | (constants.O_DIRECTORY ?? 0) + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_NONBLOCK ?? 0); + return openSync(path, flags); +} + +function readBounded(path: string): { source: string; identity: RegistryIdentity } { + validateCanonicalPath(path); + const beforeDirectory = directoryIdentity(path); const beforePath = lstatSync(path); - const before = metadata(beforePath); + const before = fileMetadata(beforePath as Stats); + let directoryDescriptor: number | undefined; let descriptor: number | undefined; try { + directoryDescriptor = openDirectoryDescriptor(dirname(path)); + const openedDirectory = directoryDescriptor === undefined + ? beforeDirectory + : directoryMetadata(fstatSync(directoryDescriptor) as Stats); + if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid(); descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - const opened = metadata(fstatSync(descriptor) as Stats); - if (!sameIdentity(before, opened)) throw invalid(); + const opened = fileMetadata(fstatSync(descriptor) as Stats); + if (!sameFileIdentity(before, opened)) throw invalid(); const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1); let offset = 0; while (offset < buffer.length) { @@ -105,17 +156,26 @@ function readBounded(path: string): { source: string; identity: FileIdentity } { offset += bytesRead; } if (offset > MAX_USERS_YAML_BYTES) throw invalid(); - const after = metadata(fstatSync(descriptor) as Stats); - const afterPath = metadata(lstatSync(path) as Stats); - if (!sameIdentity(opened, after) || !sameIdentity(after, afterPath)) 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 afterOpenedDirectory = directoryDescriptor === undefined + ? afterDirectory + : directoryMetadata(fstatSync(directoryDescriptor) as Stats); + if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath) + || !sameDirectoryIdentity(beforeDirectory, afterDirectory) + || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); - return { source, identity: after }; + return { source, identity: { file: after, directory: afterDirectory } }; } catch { throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* sanitized by design */ } } + if (directoryDescriptor !== undefined) { + try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ } + } } } @@ -151,21 +211,21 @@ function parseRegistry(source: string): LocalUserRecord[] { } } -function load(path: string): { records: LocalUserRecord[]; identity: FileIdentity } { +function load(path: string): { records: LocalUserRecord[]; identity: RegistryIdentity } { const read = readBounded(path); return { records: parseRegistry(read.source), identity: read.identity }; } export function createLocalUserRegistry(usersPath: string): LocalUserRegistry { - let cached: { records: LocalUserRecord[]; identity: FileIdentity } | undefined; + let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined; function current(): LocalUserRecord[] { try { - const before = fileIdentity(usersPath); + const before = registryIdentity(usersPath); 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, fileIdentity(usersPath))) { + if (sameIdentity(loaded.identity, registryIdentity(usersPath))) { cached = loaded; return loaded.records; } diff --git a/backend/test/local-registry.test.ts b/backend/test/local-registry.test.ts index 9f2717d8..004edcb2 100644 --- a/backend/test/local-registry.test.ts +++ b/backend/test/local-registry.test.ts @@ -2,6 +2,7 @@ import { chmodSync, existsSync, lstatSync, + mkdirSync, renameSync, realpathSync, symlinkSync, @@ -44,6 +45,7 @@ function lstatMaybe(path: string): boolean { function root(): string { const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-")); chmodSync(path, 0o700); + if ((lstatSync(path).mode & 0o7777) !== 0o700) throw new Error("test root is not private"); createdRoots.push(path); return path; } @@ -123,6 +125,28 @@ describe("local user registry", () => { await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false); }); + test("rejects a valid registry under a non-private authentication directory", async () => { + const fixture = writeRegistry(registryYaml(userYaml())); + chmodSync(fixture.root, 0o750); + expect(lstatSync(fixture.root).mode & 0o7777).toBe(0o750); + + await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]); + }); + + test("rejects a valid registry under a symlinked authentication directory", async () => { + const outer = root(); + const realDirectory = join(outer, "real-auth"); + const linkedDirectory = join(outer, "linked-auth"); + mkdirSync(realDirectory, { mode: 0o700 }); + chmodSync(realDirectory, 0o700); + const path = join(realDirectory, "users.yaml"); + writeFileSync(path, registryYaml(userYaml()), { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); + symlinkSync(realDirectory, linkedDirectory); + + await expectInvalid(createLocalUserRegistry(join(linkedDirectory, "users.yaml")).findByUsername("admin"), ["admin", passwordHash]); + }); + test.each([ ["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))], ["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],