fix(security): структурная редакция секретов и строгая проверка конфига
Diagnostics-бандл уносил machine token наружу. Построчное правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенный auth.http.url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=<секрет> Это тот же trafficStats secret, который открывает и traffic API, и auth-endpoint. Бандл собирается автоматически при любом падении install/reconfigure и предназначен для передачи наружу. Редакция YAML переписана структурно: документ разбирается и обходится как дерево. Значение секрета может лежать где угодно, поэтому обходить нужно дерево, а не строки. Для неразбираемого документа остаётся консервативный построчный fallback. В env-артефактах секрет теперь вырезается и из URL-значений: HY2_AUTH_URL в post-install.env не подходит ни под один маркер имени ключа, но несёт access_token в значении. Семантическая проверка сгенерированного конфига: - добавлен quic.maxIdleTimeout - он был в production-профиле, но не проверялся, и конфиг с уехавшим idle timeout проходил проверку; - auth.http.url сверяется целиком (host/port/path/token), а не по наличию подстроки access_token=. Это единственный канал допуска пиров, уехавший порт или путь остались бы незамеченными; - сообщение об ошибке auth.http.url не печатает сам токен: текст уходит в логи и в diagnostics-бандл; - добавлены auth.http.insecure, поля ACME и запрет посторонних секций верхнего уровня. Маркеры секретных имён в Go-санитайзере расширены и синхронизированы с оркестратором. Формулировка гарантии сужена до честной: известные секреты и неизвестные поля с секретоподобным именем.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
REDACTED,
|
||||
isSecretKey,
|
||||
redactEnv,
|
||||
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/hui/hysteria2/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/hui/hysteria2/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");
|
||||
});
|
||||
|
||||
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/hui/hysteria2/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/hui/hysteria2/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("runtime-конфиг теряет все секреты", () => {
|
||||
const runtime = [
|
||||
"HY2XS_CONFIG_SCHEMA_VERSION=2",
|
||||
`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");
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user