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.
134 lines
4.9 KiB
Go
134 lines
4.9 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|