fix(security): harden diagnostics storage boundary
This commit is contained in:
@@ -2,12 +2,14 @@ import { join } from "node:path";
|
||||
import type { CommonOptions } from "../types/context";
|
||||
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
|
||||
import { info, setOperationContext } from "../lib/log";
|
||||
import { runMutating, runReadOnlyArgvStatus } from "../lib/process";
|
||||
import { runMutatingArgv, runReadOnlyArgvStatus } from "../lib/process";
|
||||
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||
|
||||
function shellEscapeSingleQuotes(value: string): string {
|
||||
return value.replaceAll("'", "'\\''");
|
||||
}
|
||||
import {
|
||||
cleanupDiagnosticsWorkspace,
|
||||
createDiagnosticsWorkspace,
|
||||
discardDiagnosticsArchive,
|
||||
finalizeDiagnosticsArchive
|
||||
} from "../lib/diagnosticsStorage";
|
||||
|
||||
const unavailable = (reason: string): string =>
|
||||
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
|
||||
@@ -68,29 +70,61 @@ export async function prepareDiagnosticFile(
|
||||
}
|
||||
|
||||
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
|
||||
const opId = `diag-${Date.now().toString(36)}`;
|
||||
setOperationContext(opId);
|
||||
const workspace = await createDiagnosticsWorkspace();
|
||||
setOperationContext(workspace.operationId);
|
||||
let archiveComplete = false;
|
||||
let collectionFailed = false;
|
||||
|
||||
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
|
||||
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`;
|
||||
await runMutating`install -d -m 0700 ${outDir}`;
|
||||
try {
|
||||
await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||
await collectCommand(workspace.outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
||||
await collectCommand(workspace.outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(workspace.outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(workspace.outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
|
||||
await collectCommand(workspace.outDir, "uname.txt", ["uname", "-a"]);
|
||||
await collectCommand(workspace.outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
|
||||
|
||||
await collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||
await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
||||
await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
|
||||
await collectCommand(outDir, "uname.txt", ["uname", "-a"]);
|
||||
await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
|
||||
await collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||
await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||
await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||
await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||
await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||
|
||||
await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||
await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||
await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||
await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||
await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||
// Archive path уже создан через open("wx") внутри доверенного root-only
|
||||
// namespace. argv-форма не запускает shell и сохраняет границы аргументов.
|
||||
await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]);
|
||||
await finalizeDiagnosticsArchive(workspace.archive);
|
||||
archiveComplete = true;
|
||||
} catch (error) {
|
||||
collectionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
try {
|
||||
await cleanupDiagnosticsWorkspace(workspace);
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
if (!archiveComplete) {
|
||||
try {
|
||||
await discardDiagnosticsArchive(workspace);
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
}
|
||||
if (cleanupErrors.length > 0) {
|
||||
if (!collectionFailed) {
|
||||
throw new AggregateError(cleanupErrors, "не удалось полностью очистить временные diagnostics-артефакты");
|
||||
}
|
||||
for (const cleanupError of cleanupErrors) {
|
||||
info(
|
||||
`diagnostics cleanup failed after the primary error: ${
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
||||
await runMutating`chmod 0600 ${archive}`;
|
||||
|
||||
info(`diagnostics bundle collected: ${archive}`);
|
||||
info(`diagnostics bundle collected: ${workspace.archive}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import { InvalidUtf8Error, readText } from "./fs";
|
||||
import { runReadOnly } from "./process";
|
||||
|
||||
export function isMissingPackageMetadataError(error: unknown): boolean {
|
||||
return (
|
||||
!(error instanceof InvalidUtf8Error) &&
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||
try {
|
||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||
} catch (error) {
|
||||
// Отсутствующая metadata допускает fallback, повреждённая — нет: иначе
|
||||
// битый пакет выдаёт себя за пакет с корректным запасным значением.
|
||||
if (error instanceof InvalidUtf8Error) {
|
||||
throw error;
|
||||
// Только отсутствие необязательного файла означает fallback. Ошибки прав,
|
||||
// ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо
|
||||
// недоступный пакет и не имеют права маскироваться запасным значением.
|
||||
if (isMissingPackageMetadataError(error)) {
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InstallContext } from "../types/context";
|
||||
import { runMutating } from "../lib/process";
|
||||
import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage";
|
||||
|
||||
async function userExists(user: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
|
||||
await ensureDir(context.config.installDir, "0755", "root:root");
|
||||
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
||||
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
||||
await ensureDiagnosticsStorageRoot();
|
||||
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user