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:
@@ -42,7 +42,8 @@ func GetRawHysteria2Config() (string, error) {
|
|||||||
// Гарантии:
|
// Гарантии:
|
||||||
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
|
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
|
||||||
// обрезаются только потому, что HY2XS ещё не научился их показывать;
|
// обрезаются только потому, что HY2XS ещё не научился их показывать;
|
||||||
// 2. секреты не покидают сервер в открытом виде.
|
// 2. известные секреты и неизвестные поля с секретоподобным именем не
|
||||||
|
// покидают сервер в открытом виде.
|
||||||
func ExportHysteria2ConfigYaml() ([]byte, error) {
|
func ExportHysteria2ConfigYaml() ([]byte, error) {
|
||||||
raw, err := GetRawHysteria2Config()
|
raw, err := GetRawHysteria2Config()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,12 +73,39 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSecretKey — обобщённое правило. Оно важно именно потому, что экспорт
|
// secretKeyMarkers покрывает известные секреты HY2XS/Hysteria и типовые имена,
|
||||||
// сохраняет неизвестные поля: новое upstream-поле с секретом будет вырезано
|
// под которыми секрет может появиться в будущем upstream-поле.
|
||||||
// ещё до того, как HY2XS про него узнает.
|
//
|
||||||
|
// Гарантия формулируется именно так: вырезаются известные секреты и
|
||||||
|
// неизвестные поля с секретоподобным именем. Обещать, что под правило попадёт
|
||||||
|
// ЛЮБОЙ будущий секрет, нельзя — обобщённый sanitizer этого не умеет.
|
||||||
|
//
|
||||||
|
// Список синхронизирован с SECRET_KEY_MARKERS в
|
||||||
|
// orchestrator/src/lib/redaction.ts.
|
||||||
|
var secretKeyMarkers = []string{
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"passphrase",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"credential",
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"privatekey",
|
||||||
|
"private_key",
|
||||||
|
"accesskey",
|
||||||
|
"access_key",
|
||||||
|
"secretkey",
|
||||||
|
"secret_key",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"bearer",
|
||||||
|
"signature",
|
||||||
|
}
|
||||||
|
|
||||||
func isSecretKey(key string) bool {
|
func isSecretKey(key string) bool {
|
||||||
lowered := strings.ToLower(key)
|
lowered := strings.ToLower(key)
|
||||||
for _, marker := range []string{"password", "passwd", "secret", "token", "credential"} {
|
for _, marker := range secretKeyMarkers {
|
||||||
if strings.Contains(lowered, marker) {
|
if strings.Contains(lowered, marker) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -139,7 +167,7 @@ func redactNode(node *yaml.Node, path []string) {
|
|||||||
|
|
||||||
func looksLikeURLKey(key string) bool {
|
func looksLikeURLKey(key string) bool {
|
||||||
lowered := strings.ToLower(key)
|
lowered := strings.ToLower(key)
|
||||||
return lowered == "url" || lowered == "addr"
|
return lowered == "url" || lowered == "addr" || strings.HasSuffix(lowered, "url")
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactSubtree(node *yaml.Node) {
|
func redactSubtree(node *yaml.Node) {
|
||||||
|
|||||||
@@ -1,18 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Вырезание секретов из артефактов, которые покидают сервер
|
||||||
|
* (diagnostics-бандл, `redact-config`).
|
||||||
|
*
|
||||||
|
* Почему структурно, а не regex по строкам: `auth:` в серверном конфиге — это
|
||||||
|
* заголовок mapping'а. Правило вида `auth:\s*(.*)` подставляло маркер в пустое
|
||||||
|
* место и оставляло нетронутым вложенный
|
||||||
|
* `http.url: …/hui/hysteria2/auth?access_token=<секрет>`, то есть бандл уносил
|
||||||
|
* machine token наружу. Значение может быть где угодно в дереве, поэтому
|
||||||
|
* обходить нужно дерево.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REDACTED = "<redacted>";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
|
||||||
|
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
|
||||||
|
* Гарантия формулируется именно так: known secrets + secret-shaped unknown
|
||||||
|
* fields, без обещания «любой будущий секрет».
|
||||||
|
*/
|
||||||
|
export const SECRET_KEY_MARKERS: readonly string[] = [
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"passphrase",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"credential",
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"privatekey",
|
||||||
|
"private_key",
|
||||||
|
"accesskey",
|
||||||
|
"access_key",
|
||||||
|
"secretkey",
|
||||||
|
"secret_key",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"bearer",
|
||||||
|
"signature"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Узлы, где секретом являются все значения карты, а не имя ключа. */
|
||||||
|
const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"];
|
||||||
|
|
||||||
|
export function isSecretKey(key: string): boolean {
|
||||||
|
const lowered = key.toLowerCase();
|
||||||
|
return SECRET_KEY_MARKERS.some((marker) => lowered.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecretMapPath(path: readonly string[]): boolean {
|
||||||
|
return SECRET_MAP_PATHS.includes(path.join("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrlKey(key: string): boolean {
|
||||||
|
const lowered = key.toLowerCase();
|
||||||
|
return lowered === "url" || lowered === "addr" || lowered.endsWith("_url") || lowered.endsWith("url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает из URL встроенные учётные данные и секретные query-параметры,
|
||||||
|
* сохраняя остальную часть адреса читаемой: в диагностике важно видеть
|
||||||
|
* host/port/path, но не токен.
|
||||||
|
*
|
||||||
|
* Маркер внутри query приходит percent-encoded (`%3Credacted%3E`) — результат
|
||||||
|
* обязан остаться валидным URL. Go-санитайзер админки ведёт себя так же.
|
||||||
|
*/
|
||||||
|
export function sanitizeUrlValue(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed || !trimmed.includes("://")) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
parsed.username = REDACTED;
|
||||||
|
parsed.password = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of [...parsed.searchParams.keys()]) {
|
||||||
|
if (isSecretKey(key)) {
|
||||||
|
parsed.searchParams.set(key, REDACTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactValueDeep(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => redactValueDeep(item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[key] = redactValueDeep(child);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return REDACTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactNode(value: unknown, path: readonly string[]): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// Индекс не попадает в путь: правила формулируются по именам полей.
|
||||||
|
return value.map((item) => redactNode(item, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (isSecretMapPath(path)) {
|
||||||
|
return redactValueDeep(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(record)) {
|
||||||
|
if (isSecretKey(key)) {
|
||||||
|
out[key] = redactValueDeep(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof child === "string" && looksLikeUrlKey(key)) {
|
||||||
|
out[key] = sanitizeUrlValue(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = redactNode(child, [...path, key]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// Секрет может лежать в URL и без «говорящего» имени ключа.
|
||||||
|
return sanitizeUrlValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function redactKeyValueSecrets(content: string): string {
|
export function redactKeyValueSecrets(content: string): string {
|
||||||
return content.replace(
|
return content.replace(
|
||||||
/^([A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN)[A-Z0-9_]*=).*$/gm,
|
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
|
||||||
"$1<redacted>"
|
`$1${REDACTED}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помимо ключей-секретов режет секреты внутри URL-значений: в
|
||||||
|
* post-install.env `HY2_AUTH_URL` несёт `access_token=<секрет>`, а его имя
|
||||||
|
* ни под один key-маркер не подходит.
|
||||||
|
*/
|
||||||
export function redactEnv(content: string): string {
|
export function redactEnv(content: string): string {
|
||||||
return redactKeyValueSecrets(content);
|
return redactKeyValueSecrets(content)
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => {
|
||||||
|
const separator = line.indexOf("=");
|
||||||
|
if (line.startsWith("#") || separator < 1) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const key = line.slice(0, separator + 1);
|
||||||
|
const value = line.slice(separator + 1);
|
||||||
|
if (!value.includes("://")) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return `${key}${sanitizeUrlValue(value)}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Резервный построчный проход на случай, если документ не разбирается как
|
||||||
|
* YAML. Диагностика не должна падать, но и утекать не должна, поэтому здесь
|
||||||
|
* правила намеренно грубее: строка с секретным ключом вырезается целиком.
|
||||||
|
*/
|
||||||
|
export function redactYamlFallback(content: string): string {
|
||||||
|
return content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => {
|
||||||
|
const match = /^(\s*)([A-Za-z0-9_.-]+)(\s*:\s*)(.*)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
return line.includes("://") ? sanitizeUrlValue(line) : line;
|
||||||
|
}
|
||||||
|
const [, indent, key, separator, value] = match;
|
||||||
|
if (value.trim() === "") {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (isSecretKey(key)) {
|
||||||
|
return `${indent}${key}${separator}"${REDACTED}"`;
|
||||||
|
}
|
||||||
|
if (value.includes("://")) {
|
||||||
|
return `${indent}${key}${separator}${sanitizeUrlValue(value)}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redactYaml(content: string): string {
|
export function redactYaml(content: string): string {
|
||||||
return content
|
let parsed: unknown;
|
||||||
.replace(/(password:\s*).*/gi, "$1<redacted>")
|
try {
|
||||||
.replace(/(secret:\s*).*/gi, "$1<redacted>")
|
parsed = Bun.YAML.parse(content);
|
||||||
.replace(/(auth:\s*).*/gi, "$1<redacted>");
|
} catch {
|
||||||
|
return redactYamlFallback(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed === null || parsed === undefined) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Bun.YAML.stringify(redactNode(parsed, []));
|
||||||
|
} catch {
|
||||||
|
return redactYamlFallback(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeC
|
|||||||
expectValue(quic, "quic", "maxStreamReceiveWindow", QUIC_BASELINE.maxStreamReceiveWindow);
|
expectValue(quic, "quic", "maxStreamReceiveWindow", QUIC_BASELINE.maxStreamReceiveWindow);
|
||||||
expectValue(quic, "quic", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow);
|
expectValue(quic, "quic", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow);
|
||||||
expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow);
|
expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow);
|
||||||
|
expectValue(quic, "quic", "maxIdleTimeout", QUIC_BASELINE.maxIdleTimeout);
|
||||||
expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams);
|
expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams);
|
||||||
expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery);
|
expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery);
|
||||||
|
|
||||||
@@ -98,15 +99,64 @@ export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeC
|
|||||||
);
|
);
|
||||||
expectNonEmptyString(trafficStats, "trafficStats", "secret");
|
expectNonEmptyString(trafficStats, "trafficStats", "secret");
|
||||||
|
|
||||||
|
assertAuthSection(root, config);
|
||||||
|
assertTlsSection(root, config);
|
||||||
|
assertNoUnexpectedSections(root, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auth проверяется до точного URL, а не по наличию подстроки `access_token=`.
|
||||||
|
* Именно этот URL — единственный канал допуска пиров, и молча уехавший порт
|
||||||
|
* или путь означал бы, что Hysteria спрашивает разрешение не у той службы.
|
||||||
|
*/
|
||||||
|
function assertAuthSection(root: YamlRecord, config: RuntimeConfig): void {
|
||||||
const auth = requireSection(root, "auth");
|
const auth = requireSection(root, "auth");
|
||||||
expectValue(auth, "auth", "type", "http");
|
expectValue(auth, "auth", "type", "http");
|
||||||
|
|
||||||
const authHttp = asRecord(auth.http, "auth.http");
|
const authHttp = asRecord(auth.http, "auth.http");
|
||||||
expectNonEmptyString(authHttp, "auth.http", "url");
|
expectNonEmptyString(authHttp, "auth.http", "url");
|
||||||
if (!String(authHttp.url).includes("access_token=")) {
|
if (!String(authHttp.url).includes("access_token=")) {
|
||||||
throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token");
|
throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertTlsSection(root, config);
|
const expectedUrl =
|
||||||
|
`http://127.0.0.1:${config.uiPort}/hui/hysteria2/auth?access_token=${config.hysteriaTrafficStatsSecret}`;
|
||||||
|
if (authHttp.url !== expectedUrl) {
|
||||||
|
// Секрет в сообщение не попадает: сравнение уже провалилось, а текст
|
||||||
|
// ошибки уходит в логи и диагностику.
|
||||||
|
throw new Error(
|
||||||
|
`hysteria config: auth.http.url must be http://127.0.0.1:${config.uiPort}` +
|
||||||
|
"/hui/hysteria2/auth?access_token=<machine token>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectValue(authHttp, "auth.http", "insecure", config.tlsMode === "self_signed_dev");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelist верхнего уровня: сгенерированный конфиг обязан состоять ровно из
|
||||||
|
* секций production-профиля. Лишняя секция означает, что шаблон или рендер
|
||||||
|
* привнесли то, чего профиль не описывает.
|
||||||
|
*/
|
||||||
|
function assertNoUnexpectedSections(root: YamlRecord, config: RuntimeConfig): void {
|
||||||
|
const allowed = new Set([
|
||||||
|
"listen",
|
||||||
|
"auth",
|
||||||
|
"obfs",
|
||||||
|
"bandwidth",
|
||||||
|
"ignoreClientBandwidth",
|
||||||
|
"congestion",
|
||||||
|
"trafficStats",
|
||||||
|
"quic",
|
||||||
|
config.tlsMode === "acme" ? "acme" : "tls"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unexpected = Object.keys(root).filter((key) => !allowed.has(key));
|
||||||
|
if (unexpected.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`hysteria config: unexpected top-level sections for the HY2XS production profile: ${unexpected.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertObfsSection(root: YamlRecord, config: RuntimeConfig): void {
|
function assertObfsSection(root: YamlRecord, config: RuntimeConfig): void {
|
||||||
@@ -136,6 +186,10 @@ function assertTlsSection(root: YamlRecord, config: RuntimeConfig): void {
|
|||||||
}
|
}
|
||||||
const acme = requireSection(root, "acme");
|
const acme = requireSection(root, "acme");
|
||||||
expectValue(acme, "acme", "type", config.acmeType);
|
expectValue(acme, "acme", "type", config.acmeType);
|
||||||
|
expectValue(acme, "acme", "email", config.acmeEmail);
|
||||||
|
expectValue(acme, "acme", "ca", "letsencrypt");
|
||||||
|
expectValue(acme, "acme", "dir", "/var/lib/hysteria/acme");
|
||||||
|
expectValue(acme, "acme", "listenHost", "0.0.0.0");
|
||||||
if (!Array.isArray(acme.domains) || acme.domains[0] !== config.domain) {
|
if (!Array.isArray(acme.domains) || acme.domains[0] !== config.domain) {
|
||||||
throw new Error(`hysteria config: acme.domains must start with ${config.domain}`);
|
throw new Error(`hysteria config: acme.domains must start with ${config.domain}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,4 +121,80 @@ describe("подмены в конфиге обнаруживаются", () =>
|
|||||||
const { config } = renderFor();
|
const { config } = renderFor();
|
||||||
expect(() => assertHysteriaConfigMatchesProfile("just a string", config)).toThrow();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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