Files

354 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test } from "bun:test";
import {
REDACTED,
isSecretKey,
redactEnv,
redactLogText,
redactYaml,
redactYamlFallback,
sanitizeUrlValue,
} from "../src/lib/redaction";
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
const OBFS_PASSWORD = "hy2xs-obfs-password-must-not-leak";
const SERVER_CONFIG = `listen: 0.0.0.0:443
acme:
domains:
- vpn.example.com
email: admin@example.com
ca: letsencrypt
dir: /var/lib/hysteria/acme
listenHost: 0.0.0.0
type: http
auth:
type: http
http:
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}
insecure: false
obfs:
type: gecko
gecko:
password: "${OBFS_PASSWORD}"
minPacketSize: 512
maxPacketSize: 1200
trafficStats:
listen: 127.0.0.1:36712
secret: "${MACHINE_TOKEN}"
`;
afterEach(() => {
// Тесты не должны зависеть друг от друга через глобальное состояние.
});
describe("редакция серверного конфига", () => {
// Регрессия: правило `auth:\\s*(.*)` подставляло маркер в заголовок mapping'а
// и оставляло вложенный auth.http.url с machine token нетронутым.
test("machine token не переживает редакцию", () => {
const redacted = redactYaml(SERVER_CONFIG);
expect(redacted).not.toContain(MACHINE_TOKEN);
});
test("obfs-пароль не переживает редакцию", () => {
expect(redactYaml(SERVER_CONFIG)).not.toContain(OBFS_PASSWORD);
});
test("результат остаётся валидным YAML", () => {
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>;
expect(String(http.url)).toContain("127.0.0.1:8080/internal/hysteria/auth");
// В query-параметре маркер приходит percent-encoded — так же, как это
// делает Go-санитайзер админки: результат обязан остаться валидным URL.
expect(String(http.url)).toContain(encodeURIComponent(REDACTED));
});
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
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");
const obfs = parsed.obfs as Record<string, unknown>;
const gecko = obfs.gecko as Record<string, unknown>;
expect(gecko.minPacketSize).toBe(512);
expect(gecko.maxPacketSize).toBe(1200);
expect(gecko.password).toBe(REDACTED);
});
test("неизвестное upstream-поле с секретоподобным именем вырезается", () => {
const yaml = `future:\n apiKey: super-secret\n privateKey: also-secret\n harmless: keep-me\n`;
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
const future = parsed.future as Record<string, unknown>;
expect(future.apiKey).toBe(REDACTED);
expect(future.privateKey).toBe(REDACTED);
expect(future.harmless).toBe("keep-me");
});
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 cfg = dns.config as Record<string, unknown>;
expect(cfg.api_token).toBe(REDACTED);
// acme.dns.config — карта, где секретны все значения, а не только ключ.
expect(cfg.zone).toBe(REDACTED);
expect(dns.name).toBe("cloudflare");
});
// Паритет с Go-санитайзером админки: URL-значение определяется по самому
// значению, а не по имени ключа. Обе реализации описывают один контракт и
// не имеют права расходиться.
test("URL под произвольным именем ключа теряет секреты, но сохраняет адрес", () => {
const yaml =
"future:\n" +
" endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token\n" +
" mirrors:\n" +
" - https://mirror-user:mirror-p4ss@mirror.example.com/pull\n";
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
const future = parsed.future as Record<string, unknown>;
const endpoint = String(future.endpoint);
expect(endpoint).toContain("relay.example.com");
expect(endpoint).not.toContain("svc-p4ss");
expect(endpoint).not.toContain("endpoint-token");
const mirrors = future.mirrors as string[];
expect(mirrors).toHaveLength(1);
expect(mirrors[0]).toContain("mirror.example.com");
expect(mirrors[0]).not.toContain("mirror-p4ss");
});
test("не-URL скаляры проходят редакцию без изменений", () => {
const yaml =
"bandwidth:\n up: 50 mbps\n down: 50 mbps\n" +
"quic:\n maxIdleTimeout: 30s\n initStreamReceiveWindow: 8388608\n" +
"outbounds:\n - addr: 10.0.0.1:1080\n";
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
const bandwidth = parsed.bandwidth as Record<string, unknown>;
const quic = parsed.quic as Record<string, unknown>;
const outbounds = parsed.outbounds as Record<string, unknown>[];
expect(bandwidth.up).toBe("50 mbps");
expect(quic.maxIdleTimeout).toBe("30s");
expect(quic.initStreamReceiveWindow).toBe(8388608);
expect(outbounds[0].addr).toBe("10.0.0.1:1080");
});
test("невалидный YAML не роняет редакцию и всё равно чистится", () => {
const broken = `auth:\n http:\n url: http://127.0.0.1:8080/x?access_token=${MACHINE_TOKEN}\n\t bad-tab: [`;
const redacted = redactYaml(broken);
expect(redacted).not.toContain(MACHINE_TOKEN);
});
test("построчный резервный проход тоже вырезает секреты", () => {
const redacted = redactYamlFallback(SERVER_CONFIG);
expect(redacted).not.toContain(MACHINE_TOKEN);
expect(redacted).not.toContain(OBFS_PASSWORD);
});
});
describe("редакция env-артефактов", () => {
const POST_INSTALL = [
"PACKAGE_VERSION=1.0.0",
"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",
].join("\n");
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
// machine token в значении.
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"
);
});
test("ключи-секреты вырезаются по имени", () => {
expect(redactEnv(POST_INSTALL)).toContain(`HY2_OBFS_PASSWORD=${REDACTED}`);
expect(redactEnv(POST_INSTALL)).not.toContain(OBFS_PASSWORD);
});
test("несекретные строки не трогаются", () => {
const redacted = redactEnv(POST_INSTALL);
expect(redacted).toContain("PACKAGE_VERSION=1.0.0");
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",
].join("\n");
const redacted = redactEnv(runtime);
expect(redacted).not.toContain(MACHINE_TOKEN);
expect(redacted).not.toContain(OBFS_PASSWORD);
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");
});
test("секретные query-параметры вырезаются, остальные сохраняются", () => {
const out = sanitizeUrlValue(
"https://example.com/x?access_token=abc&page=2"
);
expect(out).not.toContain("abc");
expect(out).toContain("page=2");
});
test("не-URL строка возвращается как есть", () => {
expect(sanitizeUrlValue("просто строка")).toBe("просто строка");
expect(sanitizeUrlValue("")).toBe("");
});
});
/**
* Регрессия diagnostics-бандла: env и YAML в нём редактировались, а
* journal-admin.log копировался как есть. Пока админка логировала RequestURI,
* в этом файле лежал действующий machine token, и бандл уносил его наружу.
*/
describe("редакция журналов в diagnostics-бандле", () => {
const JOURNAL_LINE =
'авг 27 12:00:01 host hy2xs-admin[123]: {"level":"info","reqUri":"/internal/hysteria/auth?access_token=' +
MACHINE_TOKEN +
'"}';
test("machine token из journald-строки вырезается", () => {
const out = redactLogText(JOURNAL_LINE);
expect(out).not.toContain(MACHINE_TOKEN);
expect(out).toContain("/internal/hysteria/auth");
});
test("адрес остаётся читаемым: host, port и path не теряются", () => {
const out = redactLogText(
`auth backend error: Post http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}: EOF`
);
expect(out).not.toContain(MACHINE_TOKEN);
expect(out).toContain("127.0.0.1:8080");
expect(out).toContain("/internal/hysteria/auth");
});
test("секрет вне URL тоже вырезается", () => {
for (const line of [
`starting with access_token=${MACHINE_TOKEN}`,
`trafficStats secret=${MACHINE_TOKEN} rejected`,
`obfs password: ${OBFS_PASSWORD}`,
]) {
const out = redactLogText(line);
expect(out).not.toContain(MACHINE_TOKEN);
expect(out).not.toContain(OBFS_PASSWORD);
expect(out).toContain(REDACTED);
}
});
test("обычные строки журнала не меняются", () => {
const line = "server up and running, listening on 0.0.0.0:443";
expect(redactLogText(line)).toBe(line);
expect(redactLogText("")).toBe("");
});
test("хвостовая пунктуация сохраняется", () => {
const out = redactLogText(
`failed to call http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}.`
);
expect(out).not.toContain(MACHINE_TOKEN);
expect(out.endsWith(".")).toBe(true);
});
test("повторный проход ничего не ломает", () => {
const once = redactLogText(JOURNAL_LINE);
expect(redactLogText(once)).toBe(once);
});
});
describe("маркеры секретных имён", () => {
test("покрывают типовые секретоподобные имена", () => {
for (const key of [
"password",
"obfsPassword",
"secret",
"access_token",
"apiKey",
"api_key",
"privateKey",
"authorization",
"cookie",
"bearer",
"signature",
"passphrase",
]) {
expect(isSecretKey(key)).toBe(true);
}
});
test("не задевают обычные поля", () => {
for (const key of [
"listen",
"type",
"domains",
"minPacketSize",
"email",
"ca",
"dir",
]) {
expect(isSecretKey(key)).toBe(false);
}
});
});