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
+38 -36
View File
@@ -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;
}