diff --git a/internal/runtime/multimodal/vision/image.go b/internal/runtime/multimodal/vision/image.go index 8611af0..e11d538 100644 --- a/internal/runtime/multimodal/vision/image.go +++ b/internal/runtime/multimodal/vision/image.go @@ -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 "" +} + // defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3 // 所述的 HTTP client。这里每次调用返回一个新 client, // 而不是包级全局变量——后者为约束所禁止。 diff --git a/internal/runtime/multimodal/vision/url_cause_test.go b/internal/runtime/multimodal/vision/url_cause_test.go index cbea534..48b413d 100644 --- a/internal/runtime/multimodal/vision/url_cause_test.go +++ b/internal/runtime/multimodal/vision/url_cause_test.go @@ -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、路径、 +// 用户名都会出现);把兜底改回空串 ⇒ 第三条用例失去 ""。 +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: "", + 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) + } + }) + } +}