Compare commits
4
Commits
b626b3f5c0
...
e8bc8727da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8bc8727da | ||
|
|
a1c9da6e8a | ||
|
|
28a6cd7e53 | ||
|
|
a5b3d40a74 |
No files matched your search
@@ -119,7 +119,7 @@ func decodeBase64(src string) (string, []byte, error) {
|
||||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, sourceOrigin(rawURL))
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -242,6 +242,19 @@ func fileExists(path string) bool {
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// sourceOrigin 给出可进错误串的「来源」形态:只有 scheme://host(见
|
||||
// trace.HostOf),路径、查询串与 userinfo 一律不出 —— 一个被拒的来源地址同样
|
||||
// 可能带签名 query,原样回显等于把它写进日志与工单。
|
||||
//
|
||||
// 解析不出主机(`file:///…`、相对路径、畸形串)时回占位符而**绝不回落原串**:
|
||||
// 回落就是泄漏,而空串会让错误文案失去「这次拒绝的是哪一类地址」的信息。
|
||||
func sourceOrigin(rawURL string) string {
|
||||
if origin := trace.HostOf(rawURL); origin != "" {
|
||||
return origin
|
||||
}
|
||||
return "<unknown source>"
|
||||
}
|
||||
|
||||
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
|
||||
// 所述的 HTTP client。这里每次调用返回一个新 client,
|
||||
// 而不是包级全局变量——后者为约束所禁止。
|
||||
|
||||
@@ -78,3 +78,56 @@ func TestDescribeDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchURLUnsupportedSourceReportsOriginOnly 钉住 M55 的那处 URL 出口:
|
||||
// fetchURL 拒绝非 http(s) 来源时,错误串里只许有**来源**(scheme://host,
|
||||
// 见 trace.HostOf),不得带路径、查询串与 userinfo —— 被拒的地址同样可能带
|
||||
// 签名 query,而这条错误会进日志与工单。
|
||||
//
|
||||
// 变异:把 `sourceOrigin(rawURL)` 改回 `rawURL` ⇒ 本用例必红(secret、路径、
|
||||
// 用户名都会出现);把兜底改回空串 ⇒ 第三条用例失去 "<unknown source>"。
|
||||
func TestFetchURLUnsupportedSourceReportsOriginOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantOrigin string
|
||||
leaks []string
|
||||
}{
|
||||
{
|
||||
name: "带 query 的 ftp",
|
||||
in: "ftp://files.example.com/a/b.png?access_token=" + urlCauseQuerySecret,
|
||||
wantOrigin: "ftp://files.example.com",
|
||||
leaks: []string{urlCauseQuerySecret, "access_token", "/a/b.png"},
|
||||
},
|
||||
{
|
||||
name: "带 userinfo 的 ftp",
|
||||
in: "ftp://" + urlCauseUser + ":svc-pass@files.example.com/p.png",
|
||||
wantOrigin: "ftp://[redacted]@files.example.com",
|
||||
leaks: []string{urlCauseUser, "svc-pass", "/p.png"},
|
||||
},
|
||||
{
|
||||
name: "没有主机的 file",
|
||||
in: "file:///etc/passwd?x=1",
|
||||
wantOrigin: "<unknown source>",
|
||||
leaks: []string{"/etc/passwd", "x=1"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, err := fetchURL(context.Background(), c.in, nil)
|
||||
if !errors.Is(err, ErrUnsupportedSource) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedSource", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range c.leaks {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Fatalf("错误串带出了地址细节 %q:%v", leak, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, c.wantOrigin) {
|
||||
t.Fatalf("错误串没给出可读来源 %q:%v", c.wantOrigin, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 "<redacted>"
|
||||
}
|
||||
return raw
|
||||
if err == nil && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
if strings.Contains(raw, "@") {
|
||||
return "<redacted>"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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,42 @@ func TestRedactURLCredentials(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactURLNoSchemeDropsQuery 钉住 M55 的缺口:**漏了 scheme** 的地址
|
||||
// (`127.0.0.1:9880/v1?token=…`)在 `url.Parse` 里是错误 —— 首段带 `:` ——
|
||||
// 于是 host 为空、走兜底。旧兜底把原串连 query 一起返回,签名/token 就这么进了
|
||||
// 日志与工单。现在兜底只按 `?`/`#` 切掉查询串与片段。
|
||||
//
|
||||
// 变异(两种,各自被不同的子用例杀灭):
|
||||
// - 兜底改回 `raw` ⇒ 带 query 的三条用例全红(secret / `token=` 会出现在结果里);
|
||||
// - `stripURLQuery` 退化成**只切 `?`** ⇒ 只有「片段先于查询」那条红
|
||||
// (`#access_token=…` 原样留在结果里)。「query 与片段」那条**打不红** ——
|
||||
// `?` 在 `#` 之前,只切 `?` 会顺带把片段带走 —— 所以 `#` 先行这条分支必须由
|
||||
// 「片段先于查询」独立钉住(review M55 r1 的 P2-01)。
|
||||
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 片段先于查询", "127.0.0.1:9880/v1#access_token=" + secret, "127.0.0.1:9880/v1"},
|
||||
{"无 scheme 无 query 原样", "127.0.0.1:9880", "127.0.0.1:9880"},
|
||||
{"无 scheme 带 userinfo ⇒ 整体打码", "user:pass@host/v1?token=" + secret, "<redacted>"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user