fix(rc5): harden frontend runtime and diagnostics
This commit is contained in:
@@ -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 === ":" ? ": " : "=";
|
||||
|
||||
@@ -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=");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user