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
+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);
}
});