From 28a6cd7e5388a524ee8cc955e78f0714b2f95a63 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 21:54:21 +0800 Subject: [PATCH] fix(voice/tts): strip query from scheme-less URLs in redactURL redactURL's fallback returned the raw string when url.Parse found no host. A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the signed query reached the WARN log and the error chain verbatim. Strip at ? and # in that path, keeping the '@' full-redaction rule first. Test pins the scheme-less query/fragment cases and goes red if the fix is reverted. --- internal/runtime/voice/tts/client.go | 31 +++++++++++++------ internal/runtime/voice/tts/redact_test.go | 37 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/internal/runtime/voice/tts/client.go b/internal/runtime/voice/tts/client.go index 1089758..e5d8af5 100644 --- a/internal/runtime/voice/tts/client.go +++ b/internal/runtime/voice/tts/client.go @@ -306,16 +306,29 @@ type Audio struct { // redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host, // 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。 // -// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会 -// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的 -// 分隔符,出现在地址里只可能是凭据)。 +// 解析不出 host 时**不能**原样返回,两条兜底各挡一类泄漏: +// - 含 `@`(userinfo 的分隔符,出现在地址里只可能是凭据)⇒ 整体打码。 +// `http://user:pass@` 这种不合法地址因此不会把凭据整个写进日志。 +// - 其余多半是**漏了 scheme** 的地址(`127.0.0.1:9880/v1?token=…`:`url.Parse` +// 在首段的 `:` 上报错,host 为空)。漏 scheme 不是"原样返回"的理由 —— +// 签名与 token 就在 query 里,因此按 `?`/`#` 切掉查询串与片段再返回。 func redactURL(raw string) string { u, err := url.Parse(raw) - if err != nil || u.Host == "" { - if strings.Contains(raw, "@") { - return "" - } - return raw + if err == nil && u.Host != "" { + return u.Scheme + "://" + u.Host } - return u.Scheme + "://" + u.Host + if strings.Contains(raw, "@") { + return "" + } + return stripURLQuery(raw) +} + +// stripURLQuery 切掉 raw 里 `?` 或 `#` 起的部分。它是 redactURL 在「解析不出 +// host」那条路径上的兜底:那里拿不到 url.URL 的 RawQuery/Fragment,只能按 +// 分隔符切。没有分隔符时原样返回(不重新编码、不规范化,便于肉眼比对)。 +func stripURLQuery(raw string) string { + if i := strings.IndexAny(raw, "?#"); i >= 0 { + return raw[:i] + } + return raw } diff --git a/internal/runtime/voice/tts/redact_test.go b/internal/runtime/voice/tts/redact_test.go index 3d38af5..a66c5d9 100644 --- a/internal/runtime/voice/tts/redact_test.go +++ b/internal/runtime/voice/tts/redact_test.go @@ -6,7 +6,8 @@ package tts // 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进 // 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。 // 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL -// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。 +// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志;而**漏了 scheme** 的 +// 地址(`127.0.0.1:9880/v1?token=…`)走的是同一条兜底,query 必须被切掉。 import ( "bytes" @@ -121,3 +122,37 @@ func TestRedactURLCredentials(t *testing.T) { }) } } + +// TestRedactURLNoSchemeDropsQuery 钉住 M55 的缺口:**漏了 scheme** 的地址 +// (`127.0.0.1:9880/v1?token=…`)在 `url.Parse` 里是错误 —— 首段带 `:` —— +// 于是 host 为空、走兜底。旧兜底把原串连 query 一起返回,签名/token 就这么进了 +// 日志与工单。现在兜底只按 `?`/`#` 切掉查询串与片段。 +// +// 变异:把兜底的 `stripURLQuery(raw)` 改回 `raw` ⇒ 前两条用例必红(secret 与 +// `token=` 都会出现);把 stripURLQuery 改成只切 `?` ⇒ 第二条用例留下 `#frag`。 +func TestRedactURLNoSchemeDropsQuery(t *testing.T) { + const secret = "TTS-SECRET-7b3e" + cases := []struct { + name string + in string + want string + }{ + {"无 scheme 带 query", "127.0.0.1:9880/v1/audio/speech?token=" + secret, "127.0.0.1:9880/v1/audio/speech"}, + {"无 scheme 带 query 与片段", "tts.internal:9880/v1?token=" + secret + "#frag", "tts.internal:9880/v1"}, + {"无 scheme 无 query 原样", "127.0.0.1:9880", "127.0.0.1:9880"}, + {"无 scheme 带 userinfo ⇒ 整体打码", "user:pass@host/v1?token=" + secret, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := redactURL(c.in) + if got != c.want { + t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want) + } + for _, leak := range []string{secret, "token=", "pass"} { + if strings.Contains(got, leak) { + t.Fatalf("可打日志的地址里出现了 %q: %q", leak, got) + } + } + }) + } +}