fix(auth): make diagnostics match runtime safety

This commit is contained in:
2026-08-17 11:23:52 +02:00
parent f0ae680671
commit 7cfbee36fa
14 changed files with 619 additions and 99 deletions
+131 -31
View File
@@ -1,5 +1,15 @@
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs";
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
realpathSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { dirname, isAbsolute, normalize } from "node:path";
import { parseDocument } from "yaml";
import { z } from "zod";
import type {
@@ -60,25 +70,126 @@ const oidcSchema = z.strictObject({
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
});
interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number }
interface FileIdentity {
dev: number;
ino: number;
uid: number;
size: number;
mtimeMs: number;
ctimeMs: number;
mode: number;
nlink: number;
}
function readBoundedConfig(path: string): { source: string; identity: FileIdentity } {
interface DirectoryIdentity {
dev: number;
ino: number;
uid: number;
mode: number;
ctimeMs: number;
}
interface StorageIdentity {
file: FileIdentity;
directory: DirectoryIdentity;
}
function validateCanonicalPath(path: string): void {
if (typeof path !== "string" || path.length === 0 || path.trim() !== path
|| path.includes("\0") || !isAbsolute(path) || normalize(path) !== path
|| realpathSync(path) !== path || realpathSync(dirname(path)) !== dirname(path)) throw 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;
}
function fileMetadata(info: Stats): FileIdentity {
const mode = info.mode & 0o7777;
if (!info.isFile() || info.uid !== runtimeOwner() || info.nlink !== 1 || mode !== 0o600
|| info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
return {
dev: info.dev, ino: info.ino, uid: info.uid, size: info.size,
mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, mode, nlink: info.nlink,
};
}
function directoryMetadata(info: Stats): DirectoryIdentity {
const mode = info.mode & 0o7777;
if (!info.isDirectory() || info.uid !== runtimeOwner() || mode !== 0o700) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, mode, ctimeMs: info.ctimeMs };
}
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
&& left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
&& left.mode === right.mode && left.nlink === right.nlink;
}
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
&& left.mode === right.mode && left.ctimeMs === right.ctimeMs;
}
function sameIdentity(left: StorageIdentity, right: StorageIdentity): boolean {
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
}
function storageIdentity(path: string): StorageIdentity {
try {
validateCanonicalPath(path);
return {
file: fileMetadata(lstatSync(path) as Stats),
directory: directoryMetadata(lstatSync(dirname(path)) as Stats),
};
} catch {
throw invalid();
}
}
function openDirectoryDescriptor(path: string): number {
return openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
}
function readBoundedConfig(path: string): { source: string; identity: StorageIdentity } {
let directoryDescriptor: number | undefined;
let fd: number | undefined;
try {
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const info = fstatSync(fd);
if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
const before = storageIdentity(path);
directoryDescriptor = openDirectoryDescriptor(dirname(path));
const openedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
if (!sameDirectoryIdentity(before.directory, openedDirectory)) throw invalid();
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const opened = fileMetadata(fstatSync(fd) as Stats);
if (!sameFileIdentity(before.file, opened)) throw invalid();
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
let offset = 0;
while (offset < buffer.length) {
const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset > MAX_AUTH_CONFIG_BYTES) throw invalid();
const afterFile = fileMetadata(fstatSync(fd) as Stats);
const afterPath = storageIdentity(path);
const afterOpenedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
if (!sameFileIdentity(opened, afterFile) || !sameFileIdentity(afterFile, afterPath.file)
|| !sameDirectoryIdentity(before.directory, afterPath.directory)
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
validateCanonicalPath(path);
return {
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)),
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs },
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)),
identity: afterPath,
};
} catch {
throw invalid();
} finally {
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ }
if (directoryDescriptor !== undefined) try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
}
}
@@ -132,8 +243,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig {
} catch { throw invalid(); }
}
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } {
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
const read = readBoundedConfig(path);
const value = parseAuthenticationConfig(read.source);
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
@@ -143,29 +253,19 @@ export function loadAuthenticationConfig(path: string): LoadedAuthConfig {
return loadAuthenticationConfigWithIdentity(path).loaded;
}
function fileIdentity(path: string): FileIdentity {
try {
const info = statSync(path);
if (!info.isFile()) throw invalid();
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
} catch { throw invalid(); }
}
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
}
export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider {
let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined;
let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined;
return { current(): LoadedAuthConfig {
const before = fileIdentity(path);
const before = storageIdentity(path);
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
for (let attempt = 0; attempt < 2; attempt += 1) {
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
if (sameIdentity(identity, fileIdentity(path))) {
cached = { identity, loaded };
return loaded;
}
try {
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
if (sameIdentity(identity, storageIdentity(path))) {
cached = { identity, loaded };
return loaded;
}
} catch { /* retry one concurrent atomic replacement, then fail closed */ }
}
throw invalid();
} };