cb20d8d28f
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Якоря и ссылки YAML ------------------------------------------------------
|
|
|
|
// Секрет, спрятанный за якорем, покидал сервер ДВАЖДЫ.
|
|
//
|
|
// redactNode и redactSubtree разбирали документ, последовательность,
|
|
// отображение и скаляр, но не yaml.AliasNode. Значение под ключом `password`
|
|
// было ссылкой — и redactSubtree на ней не делал ничего; сам якорь объявлен под
|
|
// ключом `shared`, секретоподобным не выглядящим, — и его не трогал никто.
|
|
//
|
|
// Канонический конфиг HY2XS якорей не содержит, но выгрузка существует ещё и
|
|
// для диагностики правленых руками файлов, а обещание «секреты не покидают
|
|
// сервер» либо абсолютно, либо его нет.
|
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredSecrets(t *testing.T) {
|
|
raw := `shared: &credential VERY_SECRET_VALUE
|
|
|
|
listen: 0.0.0.0:443
|
|
|
|
obfs:
|
|
type: salamander
|
|
salamander:
|
|
password: *credential
|
|
`
|
|
|
|
sanitized := sanitizeForTest(t, raw)
|
|
|
|
if strings.Contains(sanitized, "VERY_SECRET_VALUE") {
|
|
t.Fatalf("секрет за якорем уехал в выгрузку:\n%s", sanitized)
|
|
}
|
|
// Документ обязан остаться валидным YAML: ссылка без якоря его сломала бы.
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
|
t.Fatalf("выгрузка перестала быть валидным YAML: %v\n%s", err, sanitized)
|
|
}
|
|
obfs, ok := parsed["obfs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("секция obfs потеряна:\n%s", sanitized)
|
|
}
|
|
salamander, ok := obfs["salamander"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("подсекция salamander потеряна: %+v", obfs)
|
|
}
|
|
if salamander["password"] != RedactedPlaceholder {
|
|
t.Fatalf("значение по ссылке не вырезано: %+v", salamander)
|
|
}
|
|
if parsed["shared"] != RedactedPlaceholder {
|
|
t.Fatalf("объявление якоря осталось нетронутым: %+v", parsed["shared"])
|
|
}
|
|
}
|
|
|
|
// URL с учётными данными, спрятанный за якорем, — тот же случай.
|
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredURLCredentials(t *testing.T) {
|
|
raw := `shared: &endpoint https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
|
|
|
someFutureUpstreamFeature:
|
|
endpoint: *endpoint
|
|
`
|
|
|
|
sanitized := sanitizeForTest(t, raw)
|
|
|
|
for _, secret := range []string{"svc-p4ss", "endpoint-token"} {
|
|
if strings.Contains(sanitized, secret) {
|
|
t.Fatalf("секрет %q уехал в выгрузку через якорь:\n%s", secret, sanitized)
|
|
}
|
|
}
|
|
if !strings.Contains(sanitized, "relay.example.com") {
|
|
t.Fatalf("адрес вырезан целиком вместо учётных данных:\n%s", sanitized)
|
|
}
|
|
}
|
|
|
|
// Ссылка на составной узел: редактируется всё поддерево цели.
|
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredMapping(t *testing.T) {
|
|
raw := `shared: &creds
|
|
username: proxyuser
|
|
password: proxy-password
|
|
|
|
outbounds:
|
|
- name: upstream
|
|
type: socks5
|
|
socks5: *creds
|
|
`
|
|
|
|
sanitized := sanitizeForTest(t, raw)
|
|
|
|
if strings.Contains(sanitized, "proxy-password") {
|
|
t.Fatalf("пароль в составном якоре уехал в выгрузку:\n%s", sanitized)
|
|
}
|
|
}
|
|
|
|
// Ссылка на предка образует цикл. Без защиты обход не завершился бы вовсе —
|
|
// это отказ обслуживания на выгрузке конфига, а не утечка.
|
|
func TestSanitizeHysteria2ConfigYaml_SurvivesRecursiveAlias(t *testing.T) {
|
|
raw := `root: &root
|
|
password: cycle-secret
|
|
self: *root
|
|
`
|
|
|
|
type outcome struct {
|
|
sanitized string
|
|
err error
|
|
}
|
|
done := make(chan outcome, 1)
|
|
go func() {
|
|
out, err := SanitizeHysteria2ConfigYaml(raw)
|
|
done <- outcome{sanitized: string(out), err: err}
|
|
}()
|
|
|
|
select {
|
|
case got := <-done:
|
|
// yaml.v3 строит на такой записи ДЕЙСТВИТЕЛЬНО циклический граф узлов
|
|
// (alias указывает на объемлющее отображение) и умеет его же обратно
|
|
// сериализовать, поэтому отказ здесь был бы регрессией, а не
|
|
// допустимым исходом.
|
|
if got.err != nil {
|
|
t.Fatalf("выгрузка отказала на рекурсивной ссылке: %v", got.err)
|
|
}
|
|
if strings.Contains(got.sanitized, "cycle-secret") {
|
|
t.Fatalf("секрет в рекурсивной структуре уехал в выгрузку:\n%s", got.sanitized)
|
|
}
|
|
if !strings.Contains(got.sanitized, RedactedPlaceholder) {
|
|
t.Fatalf("секрет исчез вместе со структурой вместо редакции:\n%s", got.sanitized)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("санитайзер зациклился на ссылке, указывающей на предка")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|