fix(admin): убрать каналы утечки секретов и остатки H UI из runtime
Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export, и файл там оставался навсегда. При includeSecrets=true это означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки формируются в памяти, каталога export/ больше нет. Generic export/import таблицы config удалён целиком. Он исключал только сырой Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export выгружала их открытым текстом, импорт позволял подменить. Для PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже существующих пиров. Production-сценария у этой пары не было. Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира: в базу попадало имя любой длины и с любыми символами, disabled с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список наполовину изменённым, а импорт мог перезаписать bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret. Партия проверяется целиком до первой записи, неизвестные поля отклоняются. Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT, H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт аргументом, админка писала его в SQLite и тут же читала обратно, а UI показывал поля disabled — второй источник истины, из которого ничего нельзя было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR, база переименована в hy2xs-admin.db, reference-схема — в schema.sql. API namespace разделён по природе маршрутов: операторский API на /api, machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth — runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому объявлен одной константой на компонент. Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr: будущее upstream-поле с другим именем уносило учётные данные и access_token целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора. Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен: NProgress приходил со своим #29d и был единственным элементом вне палитры.
This commit is contained in:
@@ -26,7 +26,7 @@ acme:
|
||||
auth:
|
||||
type: http
|
||||
http:
|
||||
url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=machine-secret
|
||||
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
|
||||
insecure: false
|
||||
userpass:
|
||||
alice: alice-password
|
||||
@@ -71,6 +71,11 @@ someFutureUpstreamFeature:
|
||||
list:
|
||||
- a
|
||||
- b
|
||||
# Ни одно из имён ниже не является ни secret-shaped, ни url/addr-подобным:
|
||||
# именно так выглядит поле, которое upstream добавит завтра.
|
||||
endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||
mirrors:
|
||||
- https://mirror-user:mirror-p4ss@mirror.example.com/pull
|
||||
`
|
||||
|
||||
func sanitizeForTest(t *testing.T, raw string) string {
|
||||
@@ -93,6 +98,9 @@ func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
|
||||
"bob-password",
|
||||
"proxy-password",
|
||||
"super-secret-token",
|
||||
"svc-p4ss",
|
||||
"endpoint-token",
|
||||
"mirror-p4ss",
|
||||
}
|
||||
for _, secret := range leaked {
|
||||
if strings.Contains(sanitized, secret) {
|
||||
@@ -152,7 +160,7 @@ func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.
|
||||
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/hui/hysteria2/auth") {
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||
t.Fatalf("auth url shape was lost:\n%s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
|
||||
@@ -161,6 +169,69 @@ func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: санитайзер применял sanitizeURLValue только к ключам url/addr/*url.
|
||||
// Любое будущее upstream-поле с другим именем уносило встроенные учётные данные
|
||||
// и секретные query-параметры наружу целиком. TS-санитайзер оркестратора такой
|
||||
// границы никогда не имел, и расхождение между двумя реализациями одного
|
||||
// контракта — само по себе дефект.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsURLsUnderArbitraryKeys(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("sanitized output is not valid yaml: %v", err)
|
||||
}
|
||||
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
|
||||
}
|
||||
|
||||
endpoint, _ := future["endpoint"].(string)
|
||||
if !strings.Contains(endpoint, "relay.example.com") {
|
||||
t.Fatalf("endpoint потерял адрес, диагностика станет бесполезной: %q", endpoint)
|
||||
}
|
||||
if strings.Contains(endpoint, "svc-p4ss") || strings.Contains(endpoint, "endpoint-token") {
|
||||
t.Fatalf("endpoint унёс секреты наружу: %q", endpoint)
|
||||
}
|
||||
|
||||
// Скаляры внутри последовательностей раньше вообще не доходили до
|
||||
// санитайзера: у redactNode не было ветки ScalarNode.
|
||||
mirrors, ok := future["mirrors"].([]any)
|
||||
if !ok || len(mirrors) != 1 {
|
||||
t.Fatalf("список mirrors потерян: %+v", future["mirrors"])
|
||||
}
|
||||
mirror, _ := mirrors[0].(string)
|
||||
if !strings.Contains(mirror, "mirror.example.com") {
|
||||
t.Fatalf("mirror потерял адрес: %q", mirror)
|
||||
}
|
||||
if strings.Contains(mirror, "mirror-p4ss") {
|
||||
t.Fatalf("mirror унёс встроенные учётные данные: %q", mirror)
|
||||
}
|
||||
}
|
||||
|
||||
// Санитайзер обязан оставаться безвредным для значений, которые URL не
|
||||
// являются: он проходит по каждому скаляру документа.
|
||||
func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
intact := []string{
|
||||
"up: 50 mbps",
|
||||
"down: 50 mbps",
|
||||
"listen: 0.0.0.0:443",
|
||||
"listen: 127.0.0.1:36712",
|
||||
"addr: 10.0.0.1:1080",
|
||||
"email: admin@example.com",
|
||||
"dir: /var/lib/hysteria/acme",
|
||||
"initStreamReceiveWindow: 8388608",
|
||||
"tuning: 42",
|
||||
}
|
||||
for _, fragment := range intact {
|
||||
if !strings.Contains(sanitized, fragment) {
|
||||
t.Fatalf("санитайзер изменил не-URL значение %q:\n%s", fragment, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURLValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -170,8 +241,8 @@ func TestSanitizeURLValue(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "strips access token",
|
||||
in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123",
|
||||
mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"},
|
||||
in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123",
|
||||
mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"},
|
||||
mustRemove: []string{"abc123"},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user