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/hui/hysteria2/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 ` 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", } 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/hui/hysteria2/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) } } 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/hui/hysteria2/auth?access_token=abc123", mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/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) } }