Files
HY2XS_flamy/apps/service/hysteria2_export_test.go
T
founder b52fac1394 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 и был единственным элементом вне палитры.
2026-08-27 20:49:07 +05:00

319 lines
9.7 KiB
Go

package service
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
const exportSampleConfig = `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
dns:
name: cloudflare
config:
cloudflare_api_token: super-secret-token
zone: example.com
auth:
type: http
http:
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
insecure: false
userpass:
alice: alice-password
bob: bob-password
obfs:
type: gecko
gecko:
password: gecko-obfs-secret
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
trafficStats:
listen: 127.0.0.1:36712
secret: traffic-stats-secret
outbounds:
- name: upstream
type: socks5
socks5:
addr: 10.0.0.1:1080
username: proxyuser
password: proxy-password
quic:
initStreamReceiveWindow: 8388608
disableStatelessReset: false
someFutureUpstreamFeature:
enabled: true
nested:
tuning: 42
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 {
t.Helper()
out, err := SanitizeHysteria2ConfigYaml(raw)
if err != nil {
t.Fatalf("sanitize failed: %v", err)
}
return string(out)
}
func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
leaked := []string{
"gecko-obfs-secret",
"traffic-stats-secret",
"machine-secret",
"alice-password",
"bob-password",
"proxy-password",
"super-secret-token",
"svc-p4ss",
"endpoint-token",
"mirror-p4ss",
}
for _, secret := range leaked {
if strings.Contains(sanitized, secret) {
t.Fatalf("exported config leaks secret %q:\n%s", secret, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_PreservesUnknownUpstreamFields(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)
}
if future["enabled"] != true {
t.Fatalf("unknown upstream scalar was dropped: %+v", future)
}
nested, ok := future["nested"].(map[string]any)
if !ok {
t.Fatalf("nested unknown section was dropped: %+v", future)
}
if nested["tuning"] != 42 {
t.Fatalf("nested unknown value was dropped: %+v", nested)
}
if list, ok := nested["list"].([]any); !ok || len(list) != 2 {
t.Fatalf("nested unknown list was dropped: %+v", nested)
}
}
func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
kept := []string{
"listen: 0.0.0.0:443",
"vpn.example.com",
"type: gecko",
"minPacketSize: 512",
"maxPacketSize: 1200",
"bbrProfile: standard",
"disableLossCompensation: false",
"disableStatelessReset: false",
"127.0.0.1:36712",
}
for _, fragment := range kept {
if !strings.Contains(sanitized, fragment) {
t.Fatalf("exported config lost operational field %q:\n%s", fragment, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
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) &&
!strings.Contains(sanitized, "access_token=%3Credacted%3E") {
t.Fatalf("access_token was not redacted:\n%s", sanitized)
}
}
// Регрессия: санитайзер применял 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
in string
mustKeep []string
mustRemove []string
}{
{
name: "strips access token",
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"},
},
{
name: "strips embedded credentials",
in: "https://user:p4ssw0rd@proxy.example.com:8443/path",
mustKeep: []string{"proxy.example.com:8443", "/path"},
mustRemove: []string{"p4ssw0rd"},
},
{
name: "leaves clean url untouched",
in: "https://example.com/masq",
mustKeep: []string{"https://example.com/masq"},
},
{
name: "leaves plain host:port untouched",
in: "10.0.0.1:1080",
mustKeep: []string{"10.0.0.1:1080"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeURLValue(tc.in)
for _, fragment := range tc.mustKeep {
if !strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to keep %q", tc.in, got, fragment)
}
}
for _, fragment := range tc.mustRemove {
if strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to remove %q", tc.in, got, fragment)
}
}
})
}
}
func TestIsSecretKey(t *testing.T) {
secret := []string{"password", "Password", "obfs_password", "secret", "trafficSecret", "access_token", "apiToken", "credentials"}
for _, key := range secret {
if !isSecretKey(key) {
t.Fatalf("expected %q to be treated as secret", key)
}
}
// Пути к файлам не являются секретами и должны остаться читаемыми.
notSecret := []string{"key", "keyPath", "cert", "clientCA", "listen", "url", "type", "dir"}
for _, key := range notSecret {
if isSecretKey(key) {
t.Fatalf("expected %q to stay visible in export", key)
}
}
}
func TestSanitizeHysteria2ConfigYaml_RejectsEmptyInput(t *testing.T) {
if _, err := SanitizeHysteria2ConfigYaml(" \n"); err == nil {
t.Fatal("expected an error for empty config")
}
}
func TestSanitizeHysteria2ConfigYaml_RedactsUnknownFutureSecretField(t *testing.T) {
// Обратная сторона сохранения неизвестных полей: новое upstream-поле с
// секретом должно вырезаться до того, как HY2XS про него узнает.
raw := "listen: 0.0.0.0:443\nfutureFeature:\n apiSecret: leak-me\n nested:\n userPassword: leak-me-too\n"
sanitized := sanitizeForTest(t, raw)
if strings.Contains(sanitized, "leak-me") {
t.Fatalf("unknown future secret field leaked:\n%s", sanitized)
}
if !strings.Contains(sanitized, "futureFeature") {
t.Fatalf("unknown future section was dropped:\n%s", sanitized)
}
}