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:
2026-08-27 12:15:07 +05:00
parent 2b4a2cb2d5
commit abdcb881f3
5 changed files with 579 additions and 15 deletions
@@ -121,4 +121,80 @@ describe("подмены в конфиге обнаруживаются", () =>
const { config } = renderFor();
expect(() => assertHysteriaConfigMatchesProfile("just a string", config)).toThrow();
});
// Инвариант присутствовал в профиле, но не проверялся: конфиг с уехавшим
// idle timeout проходил семантическую проверку.
test("подменён quic.maxIdleTimeout", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("maxIdleTimeout: 30s", "maxIdleTimeout: 5s");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxIdleTimeout/);
});
test("исчез quic.maxIdleTimeout", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace(/ {2}maxIdleTimeout: 30s\n/, "");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxIdleTimeout/);
});
test("auth url указывает на другой порт", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/);
});
test("auth url указывает на другой путь", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("/hui/hysteria2/auth", "/hui/hysteria2/authorize");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/);
});
test("auth url несёт чужой токен", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace(/access_token=[^\s]*/, "access_token=someone-elses-token");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/);
});
test("сообщение об ошибке auth url не печатает сам токен", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui");
try {
assertHysteriaConfigMatchesProfile(tampered, config);
throw new Error("expected assertion to fail");
} catch (error) {
expect(String(error)).not.toContain(config.hysteriaTrafficStatsSecret);
}
});
test("auth.http.insecure включён вне self_signed_dev", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("insecure: false", "insecure: true");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.insecure/);
});
test("подменён acme.email", () => {
const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" });
const tampered = yaml.replace("email: admin@example.com", "email: attacker@example.com");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/acme\.email/);
});
test("подменён acme.ca", () => {
const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" });
const tampered = yaml.replace("ca: letsencrypt", "ca: zerossl");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/acme\.ca/);
});
test("посторонняя секция верхнего уровня отвергается", () => {
const { config, yaml } = renderFor();
const tampered = `${yaml}\nresolver:\n type: system\n`;
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(
/unexpected top-level sections/
);
});
test("сгенерированный конфиг не содержит посторонних секций", () => {
for (const tlsMode of ["acme", "file"]) {
const { config, yaml } = renderFor({ HY2XS_TLS_MODE: tlsMode });
expect(() => assertHysteriaConfigMatchesProfile(yaml, config)).not.toThrow();
}
});
});