fix(rc5): harden frontend runtime and diagnostics

This commit is contained in:
2026-09-08 05:22:06 +05:00
parent 079094591b
commit b7e478f014
26 changed files with 707 additions and 169 deletions
+32 -6
View File
@@ -12,6 +12,17 @@
export const REDACTED = "<redacted>";
/**
* ENV-флаги, чьи имена похожи на секрет, но значения являются диагностически
* полезным публичным состоянием. Исключения здесь только точные: общий
* детектор секретов нельзя ослаблять совпадением по префиксу или суффиксу.
*/
const NON_SECRET_ENV_KEYS = new Set(["HY2XS_FORCE_PASSWORD_CHANGE"]);
/** Сохранённый контракт прежнего ENV-детектора, но применённый после разбора key. */
const ENV_SECRET_KEY_PATTERN =
/(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)/;
/**
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
@@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [
"authorization",
"cookie",
"bearer",
"signature"
"signature",
];
/** Узлы, где секретом являются все значения карты, а не имя ключа. */
const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"];
const SECRET_MAP_PATHS: readonly string[] = [
"auth.userpass",
"acme.dns.config",
];
export function isSecretKey(key: string): boolean {
const lowered = key.toLowerCase();
@@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown {
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
for (const [key, child] of Object.entries(
value as Record<string, unknown>
)) {
out[key] = redactValueDeep(child);
}
return out;
@@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown {
export function redactKeyValueSecrets(content: string): string {
return content.replace(
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
`$1${REDACTED}`
/^([A-Z0-9_]+)=(.*?)(\r?)$/gm,
(line, key: string, value: string, eol: string) => {
const isKnownBoolean =
NON_SECRET_ENV_KEYS.has(key) && /^(?:true|false)$/.test(value);
if (isKnownBoolean || !ENV_SECRET_KEY_PATTERN.test(key)) {
return line;
}
return `${key}=${REDACTED}${eol}`;
}
);
}
@@ -207,7 +230,10 @@ export function redactLogText(content: string): string {
LOG_SECRET_ASSIGNMENT_PATTERN,
(match, key: string, separator: string, rawValue: string) => {
const [value, trailing] = splitTrailingPunctuation(rawValue);
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
if (
value === REDACTED ||
value.toLowerCase() === ENCODED_REDACTED.toLowerCase()
) {
return match;
}
const rendered = separator === ":" ? ": " : "=";
+24 -3
View File
@@ -9,7 +9,11 @@ import { redactEnv } from "../src/lib/redaction";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
await Promise.all(
directories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
async function source(bytes: Uint8Array): Promise<string> {
@@ -22,14 +26,31 @@ async function source(bytes: Uint8Array): Promise<string> {
describe("безопасная подготовка diagnostics", () => {
test("секрет редактируется до выдачи текста на запись", async () => {
const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n"));
const path = await source(
new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n")
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("<redacted>");
expect(prepared).not.toContain("top-secret");
});
test("диагностический boolean виден, а соседний пароль скрыт", async () => {
const path = await source(
new TextEncoder().encode(
"HY2XS_FORCE_PASSWORD_CHANGE=false\nHY2XS_ADMIN_INITIAL_PASSWORD=top-secret\n"
)
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
expect(prepared).toContain("HY2XS_ADMIN_INITIAL_PASSWORD=<redacted>");
expect(prepared).not.toContain("top-secret");
});
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff]));
const path = await source(
new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff])
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("некорректный UTF-8");
expect(prepared).not.toContain("SECRET=");
+63 -12
View File
@@ -6,7 +6,7 @@ import {
redactLogText,
redactYaml,
redactYamlFallback,
sanitizeUrlValue
sanitizeUrlValue,
} from "../src/lib/redaction";
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
@@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => {
});
test("результат остаётся валидным YAML", () => {
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
string,
unknown
>;
expect(parsed.listen).toBe("0.0.0.0:443");
const auth = parsed.auth as Record<string, unknown>;
const http = auth.http as Record<string, unknown>;
@@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => {
});
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
string,
unknown
>;
const acme = parsed.acme as Record<string, unknown>;
expect(acme.email).toBe("admin@example.com");
expect(acme.ca).toBe("letsencrypt");
@@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => {
test("вложенное дерево под секретным ключом вырезается целиком", () => {
const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`;
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
const dns = (parsed.acme as Record<string, unknown>).dns as Record<string, unknown>;
const dns = (parsed.acme as Record<string, unknown>).dns as Record<
string,
unknown
>;
const cfg = dns.config as Record<string, unknown>;
expect(cfg.api_token).toBe(REDACTED);
// acme.dns.config — карта, где секретны все значения, а не только ключ.
@@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => {
"HY2_AUTH_MODE=http",
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2_PORT=443"
"HY2_PORT=443",
].join("\n");
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
@@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => {
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
const redacted = redactEnv(POST_INSTALL);
expect(redacted).not.toContain(MACHINE_TOKEN);
expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth");
expect(redacted).toContain(
"HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth"
);
});
test("ключи-секреты вырезаются по имени", () => {
@@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => {
expect(redacted).toContain("HY2_PORT=443");
});
test("boolean-флаг принудительной смены пароля остаётся диагностируемым", () => {
const flags = [
"HY2XS_FORCE_PASSWORD_CHANGE=false",
"HY2XS_FORCE_PASSWORD_CHANGE=true",
].join("\n");
expect(redactEnv(flags)).toBe(flags);
});
test("исключение для boolean-флага точное и не ослабляет защиту", () => {
const input = [
"HY2XS_FORCE_PASSWORD_CHANGE_SUFFIX=must-hide",
"HY2XS_FORCE_PASSWORD_CHANGE=malformed-secret",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
"HY2XS_ADMIN_CON_PASS=connection-password",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
].join("\n");
const redacted = redactEnv(input);
expect(redacted).not.toContain("must-hide");
expect(redacted).not.toContain("initial-password");
expect(redacted).not.toContain("connection-password");
expect(redacted).not.toContain(OBFS_PASSWORD);
expect(redacted.match(/<redacted>/g)).toHaveLength(5);
});
test("runtime-конфиг теряет все секреты", () => {
const runtime = [
"HY2XS_CONFIG_SCHEMA_VERSION=2",
"HY2XS_FORCE_PASSWORD_CHANGE=false",
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2XS_ADMIN_CON_PASS=connection-password",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password"
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
].join("\n");
const redacted = redactEnv(runtime);
@@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => {
expect(redacted).not.toContain("connection-password");
expect(redacted).not.toContain("initial-password");
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
});
});
describe("санитизация URL", () => {
test("креденшелы в URL вырезаются", () => {
expect(sanitizeUrlValue("https://user:hunter2@example.com/x")).not.toContain("hunter2");
expect(
sanitizeUrlValue("https://user:hunter2@example.com/x")
).not.toContain("hunter2");
});
test("секретные query-параметры вырезаются, остальные сохраняются", () => {
const out = sanitizeUrlValue("https://example.com/x?access_token=abc&page=2");
const out = sanitizeUrlValue(
"https://example.com/x?access_token=abc&page=2"
);
expect(out).not.toContain("abc");
expect(out).toContain("page=2");
});
@@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () =>
for (const line of [
`starting with access_token=${MACHINE_TOKEN}`,
`trafficStats secret=${MACHINE_TOKEN} rejected`,
`obfs password: ${OBFS_PASSWORD}`
`obfs password: ${OBFS_PASSWORD}`,
]) {
const out = redactLogText(line);
expect(out).not.toContain(MACHINE_TOKEN);
@@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => {
"cookie",
"bearer",
"signature",
"passphrase"
"passphrase",
]) {
expect(isSecretKey(key)).toBe(true);
}
});
test("не задевают обычные поля", () => {
for (const key of ["listen", "type", "domains", "minPacketSize", "email", "ca", "dir"]) {
for (const key of [
"listen",
"type",
"domains",
"minPacketSize",
"email",
"ca",
"dir",
]) {
expect(isSecretKey(key)).toBe(false);
}
});