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:
@@ -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 {
|
||||
return content.replace(
|
||||
/^([A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN)[A-Z0-9_]*=).*$/gm,
|
||||
"$1<redacted>"
|
||||
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
|
||||
`$1${REDACTED}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Помимо ключей-секретов режет секреты внутри URL-значений: в
|
||||
* post-install.env `HY2_AUTH_URL` несёт `access_token=<секрет>`, а его имя
|
||||
* ни под один key-маркер не подходит.
|
||||
*/
|
||||
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 {
|
||||
return content
|
||||
.replace(/(password:\s*).*/gi, "$1<redacted>")
|
||||
.replace(/(secret:\s*).*/gi, "$1<redacted>")
|
||||
.replace(/(auth:\s*).*/gi, "$1<redacted>");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = Bun.YAML.parse(content);
|
||||
} 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", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow);
|
||||
expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow);
|
||||
expectValue(quic, "quic", "maxIdleTimeout", QUIC_BASELINE.maxIdleTimeout);
|
||||
expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams);
|
||||
expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery);
|
||||
|
||||
@@ -98,15 +99,64 @@ export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeC
|
||||
);
|
||||
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");
|
||||
expectValue(auth, "auth", "type", "http");
|
||||
|
||||
const authHttp = asRecord(auth.http, "auth.http");
|
||||
expectNonEmptyString(authHttp, "auth.http", "url");
|
||||
if (!String(authHttp.url).includes("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 {
|
||||
@@ -136,6 +186,10 @@ function assertTlsSection(root: YamlRecord, config: RuntimeConfig): void {
|
||||
}
|
||||
const acme = requireSection(root, "acme");
|
||||
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) {
|
||||
throw new Error(`hysteria config: acme.domains must start with ${config.domain}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user