fix(security): drop IP-literal refusal from download URL validation

Confirmed with the product team that the official GUI client applies no
client-side SSRF interception to downloads, so the IP-literal refusal
was the last remaining client-side interception beyond transport
hygiene. Dedicated deployments make every host dimension (domain,
port, network location) unenumerable, and an IP-literal host is just
another network location.

- ValidateDownloadURL accepts any HTTPS host, IP literals included;
  HTTPS scheme, userinfo refusal, and per-hop redirect re-validation
  stay as the transport baseline
- this retires the third and last interception layer after the host
  allowlist removal and the dial-time public-IP refusal (be52ca5d)
- upload targets stay unaffected: trustedUploadHost keeps rejecting
  non-DingTalk/OSS hosts, so local file bytes cannot be PUT to an IP
- regressions: IP-literal URLs pass validation on the shared and chat
  download paths; userinfo and plain-HTTP URLs stay rejected
This commit is contained in:
liyuan committed 2026-08-25 21:07:18 +08:00
1 parent be52ca5d2a
commit 7f353b73a5
6 files changed
+55 -57

No files matched your search

+12 -12
View File
@@ -3,20 +3,20 @@ category: Changed
--- ---
- **Download host trust policy** — retires the static DingTalk/OSS download - **Download host trust policy** — retires the static DingTalk/OSS download
host allowlist and the dial-time public-IP refusal from both the shared host allowlist, the dial-time public-IP refusal, and the IP-literal
local download path (`drive +download`, `drive +version-download`, refusal from both the shared local download path (`drive +download`,
doc/minutes artifact downloads) and the chat message-resource path `drive +version-download`, doc/minutes artifact downloads) and the chat
(`chat +messages-resource-download`, `--download-resources`). Download message-resource path (`chat +messages-resource-download`,
URLs still require an HTTPS domain (IP literals and userinfo URLs stay `--download-resources`). Download URLs only require HTTPS without userinfo
rejected) and now accept non-default HTTPS ports, because every dimension and accept non-default HTTPS ports, because every dimension of a
of a dedicated-deployment storage endpoint — custom domain, port, and dedicated-deployment storage endpoint — custom domain, port, and network
network location — is decided by the customer deployment and cannot be location — is decided by the customer deployment and cannot be enumerated
enumerated or configured client-side. Verified on a dedicated deployment or configured client-side. Verified on a dedicated deployment whose
whose storage domain resolves to a customer-intranet address. Downloads storage domain resolves to a customer-intranet address. Downloads align
align with the official GUI client, which applies no client-side SSRF with the official GUI client, which applies no client-side SSRF
interception: download URLs only ever come from authenticated service interception: download URLs only ever come from authenticated service
responses (no command accepts a user-supplied URL), TLS hostname responses (no command accepts a user-supplied URL), TLS hostname
verification pins the connection to the requested domain, redirects are verification pins the connection to the requested host, redirects are
re-validated per hop, and service credential headers are stripped once a re-validated per hop, and service credential headers are stripped once a
redirect leaves the original origin. redirect leaves the original origin.
- **Upload host trust unchanged** — upload target URLs (`drive +upload`, - **Upload host trust unchanged** — upload target URLs (`drive +upload`,
+7 -12
View File
@@ -290,20 +290,15 @@ func SafeFilename(preferredName, rawURL string) string {
return "download" return "download"
} }
// ValidateDownloadURL accepts HTTPS download URLs on any port. Hosts must be // ValidateDownloadURL accepts HTTPS download URLs on any host and port, IP
// domain names, never IP literals or userinfo URLs; TLS hostname // literals included — mirroring the official GUI client, which applies no
// verification in secureHTTPClient then pins the connection to the requested // client-side SSRF interception to downloads. Only userinfo URLs stay
// domain. Dedicated-deployment storage domains legitimately use custom // rejected; TLS hostname verification in secureHTTPClient pins the
// domains, non-default ports, and customer-intranet addresses, so the port // connection to the requested host and redirects are re-validated per hop.
// and the resolved network location are not client-side trust signals.
func ValidateDownloadURL(rawURL string) (*url.URL, error) { func ValidateDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL)) parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL") return nil, fmt.Errorf("下载地址必须是合法的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if host == "" || net.ParseIP(host) != nil {
return nil, fmt.Errorf("下载地址不允许使用 IP 直连,必须是 HTTPS 域名")
} }
return parsed, nil return parsed, nil
} }
+6 -5
View File
@@ -64,14 +64,16 @@ func TestCrossPlatformCoverageDownloadURLPolicy(t *testing.T) {
valid := []string{ valid := []string{
"https://alidocs.dingtalk.com/file.docx", "https://alidocs.dingtalk.com/file.docx",
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
// 域名白名单已移除:任意 HTTPS 域名在 URL 校验层放行, // 域名白名单、IP 直连拦截与拨号层公网 IP 校验均已移除:
// SSRF 防护由拨号层的公网 IP 强制校验承担。 // 任意 HTTPS 主机(含 IP 字面量)在 URL 校验层放行,对齐
// GUI 客户端(无客户端侧 SSRF 拦截)。
"https://ddoss.ijingbo.chambroad.com/file.doc", "https://ddoss.ijingbo.chambroad.com/file.doc",
// 专属部署存储域可服务在非默认端口;端口不是信任信号, // 专属部署存储域可服务在非默认端口;端口不是信任信号。
// 拨号层公网 IP 校验与端口号无关。
"https://ddoss.ijingbo.chambroad.com:8443/file.doc", "https://ddoss.ijingbo.chambroad.com:8443/file.doc",
"https://evil.example/file.docx", "https://evil.example/file.docx",
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx", "https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
"https://127.0.0.1/file.docx",
"https://[::1]/file.docx",
} }
for _, raw := range valid { for _, raw := range valid {
if _, err := ValidateDownloadURL(raw); err != nil { if _, err := ValidateDownloadURL(raw); err != nil {
@@ -80,7 +82,6 @@ func TestCrossPlatformCoverageDownloadURLPolicy(t *testing.T) {
} }
invalid := []string{ invalid := []string{
"http://alidocs.dingtalk.com/file.docx", "http://alidocs.dingtalk.com/file.docx",
"https://127.0.0.1/file.docx",
"https://user@alidocs.dingtalk.com/file.docx", "https://user@alidocs.dingtalk.com/file.docx",
"http://alidocs.dingtalk.com:8443/file.docx", "http://alidocs.dingtalk.com:8443/file.docx",
"https://alidocs.dingtalk.com:NOTAPORT/file.docx", "https://alidocs.dingtalk.com:NOTAPORT/file.docx",
+6 -5
View File
@@ -317,11 +317,12 @@ func isAliyunOSSHost(host string) bool {
} }
// validateResourceDownloadURL enforces the shared localio download URL // validateResourceDownloadURL enforces the shared localio download URL
// policy: HTTPS domain names only, never IP literals or userinfo URLs. // policy: HTTPS URLs without userinfo, on any host or port, IP literals
// Resource URLs are always resolved from an authenticated MCP response // included. Resource URLs are always resolved from an authenticated MCP
// (never user-supplied), and localio.SecureHTTPClient keeps TLS hostname // response (never user-supplied), and localio.SecureHTTPClient keeps TLS
// verification plus redirect credential hygiene, mirroring the official GUI // hostname verification plus redirect credential hygiene, mirroring the
// client which applies no client-side SSRF interception to downloads. // official GUI client which applies no client-side SSRF interception to
// downloads.
func validateResourceDownloadURL(rawURL string) (*url.URL, error) { func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := localio.ValidateDownloadURL(rawURL) parsed, err := localio.ValidateDownloadURL(rawURL)
if err != nil { if err != nil {
@@ -188,19 +188,19 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want) t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
} }
} }
// Host trust is no longer a static allowlist: any HTTPS domain (including // Host trust is no longer a static allowlist: any HTTPS host (including
// dedicated-deployment download hosts) passes URL validation, while IP // dedicated-deployment download hosts and IP literals) passes URL
// literals, userinfo URLs, and plain HTTP stay rejected. Non-default HTTPS // validation, while userinfo URLs and plain HTTP stay rejected.
// ports are accepted: dedicated storage domains legitimately serve on them // Non-default HTTPS ports are accepted: dedicated storage domains
// and SSRF protection is enforced at dial time by localio.SecureHTTPClient. // legitimately serve on them.
for rawURL, wantOK := range map[string]bool{ for rawURL, wantOK := range map[string]bool{
"https://download.dingtalk.com/file": true, "https://download.dingtalk.com/file": true,
"https://bucket.oss-cn-hangzhou.aliyuncs.com/file": true, "https://bucket.oss-cn-hangzhou.aliyuncs.com/file": true,
"https://ddoss.tenant.example.com/file": true, "https://ddoss.tenant.example.com/file": true,
"https://ddoss.tenant.example.com:8443/file": true, "https://ddoss.tenant.example.com:8443/file": true,
"https://203.0.113.5/file": true,
"http://download.dingtalk.com/file": false, "http://download.dingtalk.com/file": false,
"http://download.dingtalk.com:8443/file": false, "http://download.dingtalk.com:8443/file": false,
"https://203.0.113.5/file": false,
"https://user:secret@download.dingtalk.com/file": false, "https://user:secret@download.dingtalk.com/file": false,
} { } {
if _, err := validateResourceDownloadURL(rawURL); (err == nil) != wantOK { if _, err := validateResourceDownloadURL(rawURL); (err == nil) != wantOK {
@@ -426,13 +426,6 @@ func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil { if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("content-length mismatch was accepted") t.Fatal("content-length mismatch was accepted")
} }
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
if _, err := downloadResourceAtomically(
context.Background(), nil, "https://203.0.113.5/file",
map[string]string{"X-Test": "ok"}, nilClientDest, true,
); err == nil {
t.Fatal("nil-client path accepted an IP-literal URL")
}
} }
func TestCrossPlatformCoverageDownloadResourceDedicatedHostWithHeaders(t *testing.T) { func TestCrossPlatformCoverageDownloadResourceDedicatedHostWithHeaders(t *testing.T) {
@@ -467,11 +460,18 @@ func TestCrossPlatformCoverageDownloadResourceNilClientUsesSecureDefault(t *test
}, nil }, nil
})} })}
} }
if _, err := downloadResourceAtomically( // IP-literal download hosts pass the same host-agnostic HTTPS policy as
context.Background(), nil, "https://download.dingtalk.com/file", nil, // domain hosts: the GUI client applies no client-side SSRF interception.
filepath.Join(t.TempDir(), "nil-secure"), false, for _, resourceURL := range []string{
); err != nil { "https://download.dingtalk.com/file",
t.Fatal(err) "https://203.0.113.5/file",
} {
if _, err := downloadResourceAtomically(
context.Background(), nil, resourceURL, nil,
filepath.Join(t.TempDir(), "nil-secure"), false,
); err != nil {
t.Fatal(err)
}
} }
if !served { if !served {
t.Fatal("nil client did not route through the secure default client") t.Fatal("nil client did not route through the secure default client")
@@ -81,7 +81,6 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
t.Fatal("plain HTTP URL unexpectedly accepted") t.Fatal("plain HTTP URL unexpectedly accepted")
} }
for _, resourceURL := range []string{ for _, resourceURL := range []string{
"https://127.0.0.1/file",
"https://user:secret@download.dingtalk.com/file", "https://user:secret@download.dingtalk.com/file",
"http://download.dingtalk.com:8443/file", "http://download.dingtalk.com:8443/file",
} { } {
@@ -91,15 +90,17 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL) t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL)
} }
} }
// The static host allowlist is retired: dedicated-deployment download // The static host allowlist and the IP-literal refusal are both retired:
// hosts pass the same host-agnostic HTTPS policy as DingTalk/OSS hosts; // dedicated-deployment download hosts and IP literals pass the same
// SSRF protection moved to dial time (localio.SecureHTTPClient). // host-agnostic HTTPS policy as DingTalk/OSS hosts, mirroring the GUI
// client which applies no client-side SSRF interception.
// Non-default HTTPS ports are accepted too: dedicated storage domains // Non-default HTTPS ports are accepted too: dedicated storage domains
// legitimately serve on them and the dial-time policy is port-agnostic. // legitimately serve on them.
for _, resourceURL := range []string{ for _, resourceURL := range []string{
"https://download.dingtalk.com/file", "https://download.dingtalk.com/file",
"https://ddoss.tenant.example.com/file", "https://ddoss.tenant.example.com/file",
"https://ddoss.tenant.example.com:8443/file", "https://ddoss.tenant.example.com:8443/file",
"https://127.0.0.1/file",
} { } {
if _, _, err := resourceDownloadInfo( if _, _, err := resourceDownloadInfo(
map[string]any{"resourceUrl": resourceURL}, map[string]any{"resourceUrl": resourceURL},