fix(auth): require local registry ownership
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user