fix(security): harden diagnostics storage boundary

This commit is contained in:
2026-09-07 19:52:11 +05:00
parent af9f476658
commit a3445f79c5
12 changed files with 562 additions and 37 deletions
+253
View File
@@ -0,0 +1,253 @@
import { chmod, chown, lstat, mkdir, mkdtemp, open, rm, unlink } from "node:fs/promises";
import type { Stats } from "node:fs";
import { basename, dirname, join } from "node:path";
import { assertMutationAllowed } from "./guard";
import { INSTALL_STATE_DIR } from "./installState";
export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`;
const ROOT_UID = 0;
const ROOT_GID = 0;
const DIAGNOSTICS_MODE = 0o700;
const ARCHIVE_MODE = 0o600;
const HAS_POSIX_MODE_SEMANTICS = process.platform !== "win32";
type DirectoryPolicy = {
uid: number;
gid: number;
exactMode?: number;
rejectGroupOrOtherWrite?: boolean;
};
export type DiagnosticsStorageOptions = {
stateRoot?: string;
diagnosticsRoot?: string;
uid?: number;
gid?: number;
};
export type DiagnosticsWorkspace = {
operationId: string;
outDir: string;
archive: string;
};
function errnoCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
async function lstatOptional(path: string): Promise<Stats | null> {
try {
return await lstat(path);
} catch (error) {
if (errnoCode(error) === "ENOENT") {
return null;
}
throw error;
}
}
function octal(mode: number): string {
return (mode & 0o777).toString(8).padStart(4, "0");
}
/**
* Проверяет каталог именно по lstat-снимку: stat здесь запрещён, поскольку он
* скрыл бы symlink и тем самым снова перенёс root-запись в чужой namespace.
*/
export function assertTrustedDirectory(path: string, stats: Stats, policy: DirectoryPolicy): void {
if (stats.isSymbolicLink()) {
throw new Error(`небезопасный каталог ${path}: symbolic link запрещён`);
}
if (!stats.isDirectory()) {
throw new Error(`небезопасный каталог ${path}: ожидается обычный каталог`);
}
if (stats.uid !== policy.uid || stats.gid !== policy.gid) {
throw new Error(
`небезопасный каталог ${path}: ожидается владелец ${policy.uid}:${policy.gid}, ` +
`получен ${stats.uid}:${stats.gid}`
);
}
const mode = stats.mode & 0o777;
if (policy.exactMode !== undefined && mode !== policy.exactMode) {
throw new Error(
`небезопасный каталог ${path}: ожидается режим ${octal(policy.exactMode)}, получен ${octal(mode)}`
);
}
if (policy.rejectGroupOrOtherWrite && (mode & 0o022) !== 0) {
throw new Error(
`небезопасный каталог ${path}: группа или остальные могут изменять root namespace (режим ${octal(mode)})`
);
}
}
function resolveOptions(options: DiagnosticsStorageOptions = {}): Required<DiagnosticsStorageOptions> {
const stateRoot = options.stateRoot ?? INSTALL_STATE_DIR;
const diagnosticsRoot = options.diagnosticsRoot ?? join(stateRoot, "diagnostics");
const uid = options.uid ?? ROOT_UID;
const gid = options.gid ?? ROOT_GID;
if (dirname(diagnosticsRoot) !== stateRoot) {
throw new Error(`diagnostics root обязан быть непосредственным дочерним каталогом ${stateRoot}`);
}
return { stateRoot, diagnosticsRoot, uid, gid };
}
function assertWorkspacePaths(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): void {
const resolved = resolveOptions(options);
const directoryName = basename(workspace.outDir);
if (
dirname(workspace.outDir) !== resolved.diagnosticsRoot ||
!/^diag-[A-Za-z0-9]{6}$/.test(directoryName) ||
workspace.operationId !== directoryName ||
workspace.archive !== `${workspace.outDir}.tar.gz`
) {
throw new Error(`небезопасные пути diagnostics workspace: ${workspace.outDir}`);
}
}
/**
* Создаёт и затем повторно проверяет root-only namespace диагностики.
*
* Родитель уже принадлежит install-state и недоступен сервисному пользователю.
* Неверные существующие права не «исправляются» автоматически: это может быть
* следом подмены, поэтому граница привилегий обязана отказать fail closed.
*/
export async function ensureDiagnosticsStorageRoot(
options: DiagnosticsStorageOptions = {}
): Promise<string> {
assertMutationAllowed("ensureDiagnosticsStorageRoot");
const resolved = resolveOptions(options);
const stateStats = await lstatOptional(resolved.stateRoot);
if (!stateStats) {
throw new Error(`доверенный каталог состояния отсутствует: ${resolved.stateRoot}`);
}
assertTrustedDirectory(resolved.stateRoot, stateStats, {
uid: resolved.uid,
gid: resolved.gid,
rejectGroupOrOtherWrite: HAS_POSIX_MODE_SEMANTICS
});
let created = false;
try {
await mkdir(resolved.diagnosticsRoot, { mode: DIAGNOSTICS_MODE });
created = true;
} catch (error) {
// Параллельные read-only diagnostics не берут operation lock и законно
// могут одновременно увидеть отсутствующий каталог. Победитель создаёт
// его, проигравший всё равно обязан проверить итоговый inode через lstat.
if (errnoCode(error) !== "EEXIST") {
throw error;
}
}
if (created) {
// mkdir применяет umask. Под доверенным root-only родителем pathname нельзя
// подменить непривилегированным процессом, поэтому режим можно довести до
// точного публичного контракта и затем проверить ещё раз.
if (HAS_POSIX_MODE_SEMANTICS) {
await chown(resolved.diagnosticsRoot, resolved.uid, resolved.gid);
}
await chmod(resolved.diagnosticsRoot, DIAGNOSTICS_MODE);
}
const diagnosticsStats = await lstat(resolved.diagnosticsRoot);
assertTrustedDirectory(resolved.diagnosticsRoot, diagnosticsStats, {
uid: resolved.uid,
gid: resolved.gid,
exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined
});
return resolved.diagnosticsRoot;
}
/** Резервирует непредсказуемые рабочий каталог и archive path эксклюзивно. */
export async function createDiagnosticsWorkspace(
options: DiagnosticsStorageOptions = {}
): Promise<DiagnosticsWorkspace> {
assertMutationAllowed("createDiagnosticsWorkspace");
const root = await ensureDiagnosticsStorageRoot(options);
const outDir = await mkdtemp(join(root, "diag-"));
try {
const resolved = resolveOptions(options);
if (HAS_POSIX_MODE_SEMANTICS) {
await chown(outDir, resolved.uid, resolved.gid);
}
await chmod(outDir, DIAGNOSTICS_MODE);
assertTrustedDirectory(outDir, await lstat(outDir), {
uid: resolved.uid,
gid: resolved.gid,
exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined
});
const archive = `${outDir}.tar.gz`;
const archiveHandle = await open(archive, "wx", ARCHIVE_MODE);
try {
if (HAS_POSIX_MODE_SEMANTICS) {
await archiveHandle.chown(resolved.uid, resolved.gid);
}
await archiveHandle.chmod(ARCHIVE_MODE);
} finally {
await archiveHandle.close();
}
return { operationId: basename(outDir), outDir, archive };
} catch (error) {
await rm(outDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export async function assertDiagnosticsArchive(path: string, options: DiagnosticsStorageOptions = {}): Promise<void> {
const resolved = resolveOptions(options);
if (dirname(path) !== resolved.diagnosticsRoot || !/^diag-[A-Za-z0-9]{6}\.tar\.gz$/.test(basename(path))) {
throw new Error(`небезопасный путь diagnostics archive: ${path}`);
}
const stats = await lstat(path);
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`небезопасный diagnostics archive ${path}: ожидается обычный файл`);
}
if (stats.uid !== resolved.uid || stats.gid !== resolved.gid) {
throw new Error(`небезопасный diagnostics archive ${path}: неверный владелец`);
}
if (HAS_POSIX_MODE_SEMANTICS && (stats.mode & 0o777) !== ARCHIVE_MODE) {
throw new Error(`небезопасный diagnostics archive ${path}: ожидается режим 0600`);
}
}
export async function finalizeDiagnosticsArchive(
path: string,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("finalizeDiagnosticsArchive");
await chmod(path, ARCHIVE_MODE);
await assertDiagnosticsArchive(path, options);
}
export async function cleanupDiagnosticsWorkspace(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("cleanupDiagnosticsWorkspace");
assertWorkspacePaths(workspace, options);
await rm(workspace.outDir, { recursive: true, force: true });
}
export async function discardDiagnosticsArchive(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("discardDiagnosticsArchive");
assertWorkspacePaths(workspace, options);
await unlink(workspace.archive).catch((error) => {
if (errnoCode(error) !== "ENOENT") {
throw error;
}
});
}