fix(auth): harden runtime projection validation

This commit is contained in:
User
2026-08-21 22:46:42 +02:00
parent 05f8615887
commit da2f4a6681
2 changed files with 271 additions and 29 deletions
+88 -13
View File
@@ -4,9 +4,9 @@ import {
constants,
fstatSync,
lstatSync,
opendirSync,
openSync,
readSync,
readdirSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
@@ -23,6 +23,7 @@ const MAX_AUTH_BYTES = 1 << 20;
const MAX_USERS_BYTES = 1 << 20;
const MAX_SELECTOR_BYTES = 4096;
const MAX_MANIFEST_BYTES = 4096;
const MAX_DIRECTORY_ENTRIES = 16;
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
const GENERATION = /^[0-9a-f]{64}$/;
@@ -69,6 +70,13 @@ function runtimeOwner(): number {
if (!Number.isSafeInteger(uid) || uid < 0) throw invalid();
return uid;
}
function runtimeGroup(): number {
if (process.platform === "win32" || typeof process.getegid !== "function")
throw invalid();
const gid = process.getegid();
if (!Number.isSafeInteger(gid) || gid < 0) throw invalid();
return gid;
}
function meta(info: Stats): Identity {
return {
dev: info.dev,
@@ -97,7 +105,12 @@ function same(a: Identity, b: Identity): boolean {
}
function directory(info: Stats, uid: number): Identity {
const value = meta(info);
if (!info.isDirectory() || value.uid !== uid || value.mode !== DIR_MODE)
if (
!info.isDirectory() ||
value.uid !== uid ||
value.gid !== runtimeGroup() ||
value.mode !== DIR_MODE
)
throw invalid();
return value;
}
@@ -106,6 +119,7 @@ function regular(info: Stats, uid: number, maximum: number): Identity {
if (
!info.isFile() ||
value.uid !== uid ||
value.gid !== runtimeGroup() ||
value.mode !== FILE_MODE ||
value.nlink !== 1 ||
value.size < 0 ||
@@ -120,7 +134,8 @@ function checkRoot(root: string): void {
root.length === 0 ||
root.includes("\0") ||
!isAbsolute(root) ||
normalize(root) !== root
normalize(root) !== root ||
(root.length > 1 && root.endsWith("/"))
)
throw invalid();
}
@@ -163,7 +178,22 @@ function stableDirectory(
function entries(path: string, uid: number, expected: readonly string[]): void {
const opened = openDirectory(path, uid);
try {
const names = readdirSync(path);
const directory = opendirSync(`/proc/self/fd/${opened.fd}`, {
bufferSize: 1,
});
const names: string[] = [];
try {
for (;;) {
const entry = directory.readSync();
if (entry === null) break;
if (names.length === MAX_DIRECTORY_ENTRIES) throw invalid();
names.push(entry.name);
}
} finally {
try {
directory.closeSync();
} catch {}
}
if (
names.length !== expected.length ||
new Set(names).size !== names.length ||
@@ -180,6 +210,55 @@ function entries(path: string, uid: number, expected: readonly string[]): void {
function replaced(before: Identity, after: Identity): boolean {
return before.dev !== after.dev || before.ino !== after.ino;
}
function rootIdentityAtPathAndDescriptor(
root: string,
openedRoot: { fd: number; identity: Identity },
uid: number,
): Identity {
const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid);
const pathAfter = directory(lstatSync(root) as Stats, uid);
if (!same(openedAfter, pathAfter)) throw invalid();
return openedAfter;
}
function validateRootAndCurrentAfterLoad(
root: string,
openedRoot: { fd: number; identity: Identity },
currentPath: string,
selectedCurrent: Identity,
uid: number,
): void {
const rootBeforeCurrent = rootIdentityAtPathAndDescriptor(
root,
openedRoot,
uid,
);
const currentAfter = regular(
lstatSync(currentPath) as Stats,
uid,
MAX_SELECTOR_BYTES,
);
const rootAfterCurrent = rootIdentityAtPathAndDescriptor(
root,
openedRoot,
uid,
);
if (
!same(openedRoot.identity, rootBeforeCurrent) ||
!same(rootBeforeCurrent, rootAfterCurrent)
) {
const latestCurrent = regular(
lstatSync(currentPath) as Stats,
uid,
MAX_SELECTOR_BYTES,
);
if (replaced(selectedCurrent, latestCurrent)) throw new CurrentReplaced();
throw invalid();
}
if (!same(selectedCurrent, currentAfter)) {
if (replaced(selectedCurrent, currentAfter)) throw new CurrentReplaced();
throw invalid();
}
}
function readRegular(
path: string,
parentPath: string,
@@ -484,17 +563,13 @@ function load(root: string): LoadedAuthConfig {
for (const predecessor of selected.previousGenerations ?? [])
validateGeneration(generationsPath, predecessor, uid);
stableDirectory(generationsPath, openedGenerations, uid);
const afterCurrent = regular(
lstatSync(currentPath) as Stats,
validateRootAndCurrentAfterLoad(
root,
openedRoot,
currentPath,
selectedCurrent.identity,
uid,
MAX_SELECTOR_BYTES,
);
if (!same(selectedCurrent.identity, afterCurrent)) {
if (replaced(selectedCurrent.identity, afterCurrent))
throw new CurrentReplaced();
throw invalid();
}
stableDirectory(root, openedRoot, uid);
return {
value: selectedGeneration.value,
revision: `sha256:${selected.generation}`,