Files
AskaEth a5b3d40a74 fix(vision): report only URL origin when an image source is rejected
fetchURL echoed the whole raw URL (path, query, userinfo) in the
ErrUnsupportedSource message, so a rejected source carrying a signed
query leaked it into logs and tickets. Route the URL exit through
trace.HostOf and fall back to a placeholder instead of the raw string.

Test pins origin-only output and goes red if the fix is reverted.
2026-10-26 21:52:19 +08:00

134 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vision
// 本文件是 M52 的出口回归探针:vision 适配器里「把 net/http 的 *url.Error 直接打进
// 错误串」的两处(建请求 / 发送)一律先过 trace.URLCause。
//
// 病害形状:`client.Do` 失败时 net/http 包出
// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但用户名与
// 整条 query(含凭据/签名)原样在串里;`http.NewRequest` 的解析失败则把整条 base URL
// 带出来。这条串会进日志与工单。
//
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例,且都同时钉住
// 「只少地址、不少原因」(底层原因与 ErrUpstream 哨兵必须还在)。
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
const (
urlCauseQuerySecret = "SECRET-2f8a1c"
urlCauseUser = "svc-user"
urlCauseBadHost = "bad host.example.com"
)
func visionURLCauseGoodBase() string {
return "https://" + urlCauseUser + ":svc-pass@vision.example.com/v1?access_token=" + urlCauseQuerySecret
}
func visionURLCauseBadBase() string {
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret
}
// TestDescribeDropsURLFromTransportError 钉住 ask 里 `a.client.Do` 失败那一处。
func TestDescribeDropsURLFromTransportError(t *testing.T) {
t.Parallel()
ad := newAdapter(visionURLCauseGoodBase())
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
})}
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) {
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
}
if strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
}
if !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err)
}
}
// TestDescribeDropsURLFromBuildRequestError 钉住 ask 里 `http.NewRequest` 失败那一处。
func TestDescribeDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
ad := newAdapter(visionURLCauseBadBase())
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err)
}
if !strings.Contains(err.Error(), "invalid character") {
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
}
if !errors.Is(err, ErrUpstream) {
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)
}
})
}
}