fix(security): remove dial-time public-IP refusal from download client

Customer round-2 testing on the dedicated deployment (Jingbo) found the
storage domain resolving to a customer-intranet address (10.254.87.52),
which the dial-time public-IP policy refused: dedicated storage can be
deployed inside the customer network, so its resolved network location
is as unenumerable as its domain and port.

- delete the public-IP policy, the IANA special-purpose denylist, and
  the NAT64 embedded-IPv4 re-validation introduced in 617b780a;
  downloads now dial the service-issued host directly, still ignoring
  environment proxies
- align with the official GUI client, which applies no client-side
  SSRF interception to downloads: no command accepts a user-supplied
  download URL, TLS hostname verification pins the connection to the
  requested domain, redirects are re-validated per hop, and credential
  headers are stripped once a redirect leaves the original origin
- uploads keep the static DingTalk/OSS default-port trust boundary
- simplify SetSecureDownloadDialTargetForTest to a single dial seam
This commit is contained in:
liyuan
2026-08-25 20:40:47 +08:00
parent 6134d413f5
commit be52ca5d2a
5 changed files with 54 additions and 196 deletions
+17 -20
View File
@@ -3,18 +3,22 @@ category: Changed
--- ---
- **Download host trust policy** — retires the static DingTalk/OSS download - **Download host trust policy** — retires the static DingTalk/OSS download
host allowlist from both the shared local download path (`drive +download`, host allowlist and the dial-time public-IP refusal from both the shared
`drive +version-download`, doc/minutes artifact downloads) and the chat local download path (`drive +download`, `drive +version-download`,
message-resource path (`chat +messages-resource-download`, doc/minutes artifact downloads) and the chat message-resource path
`--download-resources`). Download URLs still require an HTTPS domain (IP (`chat +messages-resource-download`, `--download-resources`). Download
literals and userinfo URLs stay rejected), and non-default HTTPS ports are URLs still require an HTTPS domain (IP literals and userinfo URLs stay
now accepted because dedicated-deployment storage domains legitimately rejected) and now accept non-default HTTPS ports, because every dimension
serve on them; the port is not a trust signal and the dial-time SSRF policy of a dedicated-deployment storage endpoint — custom domain, port, and
is port-agnostic. SSRF network location — is decided by the customer deployment and cannot be
protection now happens at dial time through a shared public-IP policy that enumerated or configured client-side. Verified on a dedicated deployment
refuses hosts resolving to private, loopback, link-local, or otherwise whose storage domain resolves to a customer-intranet address. Downloads
non-public addresses. Dedicated-deployment storage domains now download align with the official GUI client, which applies no client-side SSRF
through the same code path as public DingTalk/OSS hosts. interception: download URLs only ever come from authenticated service
responses (no command accepts a user-supplied URL), TLS hostname
verification pins the connection to the requested domain, redirects are
re-validated per hop, and service credential headers are stripped once a
redirect leaves the original origin.
- **Upload host trust unchanged** — upload target URLs (`drive +upload`, - **Upload host trust unchanged** — upload target URLs (`drive +upload`,
minutes audio upload) keep the pre-existing public DingTalk/OSS trusted minutes audio upload) keep the pre-existing public DingTalk/OSS trusted
host requirement through a dedicated upload validator, so removing the host requirement through a dedicated upload validator, so removing the
@@ -24,11 +28,4 @@ category: Changed
accepted for dedicated-deployment downloads stay anomalous for uploads). accepted for dedicated-deployment downloads stay anomalous for uploads).
Download credential headers are issued together with the download URL by Download credential headers are issued together with the download URL by
the same authenticated service response and follow it as-is on the first the same authenticated service response and follow it as-is on the first
request; redirects leaving the original host still strip them. The request; redirects leaving the original host still strip them.
dial-time non-public IP policy also covers additional IANA
special-purpose ranges (`0.0.0.0/8`, `192.88.99.0/24`, `100::/64`,
`2002::/16`, `3fff::/20`, `5f00::/16`) and IPv4-embedded IPv6 transition
ranges: NAT64 well-known prefix answers (`64:ff9b::/96`) are re-validated
against their embedded IPv4 address so DNS64 keeps working while embedded
internal addresses are refused, and NAT64 local-use (`64:ff9b:1::/48`) and
Teredo (`2001::/32`) answers are refused outright.
+18 -89
View File
@@ -12,7 +12,6 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"os" "os"
pathpkg "path" pathpkg "path"
@@ -35,8 +34,7 @@ type downloadTempFile interface {
var ( var (
createDownloadTemp = createDownloadTempInRoot createDownloadTemp = createDownloadTempInRoot
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr secureDialContext = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
localGetwd = os.Getwd localGetwd = os.Getwd
localAbs = filepath.Abs localAbs = filepath.Abs
localEvalSymlinks = filepath.EvalSymlinks localEvalSymlinks = filepath.EvalSymlinks
@@ -293,11 +291,11 @@ func SafeFilename(preferredName, rawURL string) string {
} }
// ValidateDownloadURL accepts HTTPS download URLs on any port. Hosts must be // ValidateDownloadURL accepts HTTPS download URLs on any port. Hosts must be
// domain names, never IP literals or userinfo URLs; SSRF protection is // domain names, never IP literals or userinfo URLs; TLS hostname
// enforced at dial time by the public-IP policy in secureHTTPClient, which is // verification in secureHTTPClient then pins the connection to the requested
// port-agnostic because the destination IP is what determines reachability. // domain. Dedicated-deployment storage domains legitimately use custom
// Dedicated-deployment storage domains legitimately serve HTTPS on // domains, non-default ports, and customer-intranet addresses, so the port
// non-default ports, so the port is not a trust signal. // 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.Host == "" || parsed.User != nil {
@@ -310,45 +308,26 @@ func ValidateDownloadURL(rawURL string) (*url.URL, error) {
return parsed, nil return parsed, nil
} }
// SecureHTTPClient returns a download client enforcing the same dial-time // SecureHTTPClient returns a download client enforcing the same URL policy
// public-IP policy and redirect hygiene as Download. Product shortcuts that // and redirect hygiene as Download. Product shortcuts that own their
// own their local-file workflow (e.g. chat message resources) share it so // local-file workflow (e.g. chat message resources) share it so download URL
// download URL trust decisions stay in one place. // trust decisions stay in one place.
func SecureHTTPClient() *http.Client { func SecureHTTPClient() *http.Client {
return secureHTTPClient() return secureHTTPClient()
} }
func secureHTTPClient() *http.Client { func secureHTTPClient() *http.Client {
transport := &http.Transport{ transport := &http.Transport{
// Do not use environment proxies here. DialContext must resolve and dial // Dial the service-issued host directly, ignoring environment proxies:
// the validated download host itself; with a proxy it would receive the // dedicated-deployment storage may live on customer intranets that are
// proxy address and could not enforce the target host's public-IP policy. // only reachable without a proxy, and TLS hostname verification always
// runs against the requested host. Download URLs never come from
// user input — every command resolves them through an authenticated
// MCP response first, mirroring the official GUI client which applies
// no client-side SSRF interception to downloads.
Proxy: nil, Proxy: nil,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address) return secureDialContext(ctx, network, address)
if err != nil {
return nil, err
}
ips, err := lookupDownloadIPs(ctx, host)
if err != nil {
return nil, err
}
for _, resolved := range ips {
if !publicIP(resolved.IP) {
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
}
}
// Dial the already validated address, not the hostname, to avoid a
// second DNS lookup opening a rebinding window.
var lastErr error
for _, resolved := range ips {
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}, },
} }
client := &http.Client{Transport: transport, Timeout: downloadTimeout} client := &http.Client{Transport: transport, Timeout: downloadTimeout}
@@ -383,56 +362,6 @@ func downloadOrigin(parsed *url.URL) string {
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port) return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
} }
func publicIP(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
// NAT64 (RFC 6052 well-known prefix): the gateway forwards traffic to the
// IPv4 address embedded in the last 32 bits, so that address is the real
// destination. Validate it instead of rejecting the whole prefix, which
// would break legitimate DNS64 answers for IPv4-only download hosts on
// IPv6-only networks.
if nat64WellKnownPrefix.Contains(addr) {
embedded := addr.As16()
return publicIP(net.IP(embedded[12:16]))
}
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(addr) {
return false
}
}
return true
}
// nat64WellKnownPrefix is checked separately in publicIP: the embedded IPv4
// address is extracted and re-validated so DNS64 answers for public hosts
// keep working while embedded internal addresses are rejected.
var nat64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96")
var nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "this network"; reaches localhost on Linux
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
netip.MustParsePrefix("192.88.99.0/24"), // deprecated 6to4 relay anycast
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
netip.MustParsePrefix("240.0.0.0/4"), // reserved
netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 local-use (RFC 8215); IPv4 embedding is deployment-specific
netip.MustParsePrefix("100::/64"), // IPv6 discard-only
netip.MustParsePrefix("2001::/32"), // Teredo; embeds a tunneled IPv4 destination
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
netip.MustParsePrefix("2002::/16"), // 6to4
netip.MustParsePrefix("3fff::/20"), // IPv6 documentation (RFC 9637)
netip.MustParsePrefix("5f00::/16"), // SRv6 SIDs
}
func ensureSafeParent(root *os.Root, parent string) error { func ensureSafeParent(root *os.Root, parent string) error {
if parent == "." { if parent == "." {
return nil return nil
+9 -73
View File
@@ -60,7 +60,7 @@ func (f *coverageTempFile) Close() error {
return err return err
} }
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) { 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",
@@ -90,24 +90,6 @@ func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw) t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
} }
} }
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1",
// IANA 特殊用途段:0.0.0.0/8 在 Linux 上可达本机,其余均非全球可达。
"0.0.0.1", "0.255.255.255", "192.88.99.1", "100::1", "2002::1", "3fff::1", "5f00::1",
// IPv4 嵌入式 IPv6 转换段:NAT64 well-known 前缀按嵌入 IPv4 复检,
// 内嵌回环/私网/特殊段必须拒绝;local-use 前缀与 Teredo 整段拒绝。
"64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b::c0a8:101", "64:ff9b::a9fe:a9fe", "64:ff9b:1::1", "2001::1"} {
if publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = true", raw)
}
}
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111",
// DNS64 对公网 IPv4-only 域名合成的 NAT64 地址必须放行。
"64:ff9b::808:808"} {
if !publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = false", raw)
}
}
} }
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) { func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
@@ -387,63 +369,22 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
t.Fatal("bad address dial succeeded") t.Fatal("bad address dial succeeded")
} }
t.Run("lookup error", func(t *testing.T) { t.Run("dial seam failure propagates", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") }) testseam.Swap(t, &secureDialContext, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil { if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("lookup error ignored") t.Fatal("dial failure ignored")
} }
}) })
t.Run("private answer", func(t *testing.T) { t.Run("dial seam success", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
})
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("private DNS answer accepted")
}
})
t.Run("nat64 answer embedding loopback", func(t *testing.T) {
// 恶意域名把 AAAA 解析到内嵌 127.0.0.1 的 NAT64 well-known 地址:
// 必须在拨号前失败,且完全不发起 dial。
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("64:ff9b::7f00:1")}}, nil
})
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
t.Error("dial attempted for a NAT64 answer embedding a loopback IPv4")
return nil, errors.New("blocked")
})
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("NAT64 answer embedding a loopback IPv4 accepted")
}
})
t.Run("public dial fallback and success", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
})
left, right := net.Pipe() left, right := net.Pipe()
t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
calls := 0 testseam.Swap(t, &secureDialContext, func(context.Context, string, string) (net.Conn, error) { return left, nil })
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, errors.New("first")
}
return left, nil
})
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil { if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
t.Fatal(err) t.Fatal(err)
} else { } else {
_ = conn.Close() _ = conn.Close()
} }
}) })
t.Run("all public dials fail", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
})
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("dial failure ignored")
}
})
base := t.TempDir() base := t.TempDir()
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil { if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
@@ -532,7 +473,6 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt") _ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
_ = SafeFilename("", "https://download.dingtalk.com/%zz") _ = SafeFilename("", "https://download.dingtalk.com/%zz")
_ = SafeFilename("", "://bad") _ = SafeFilename("", "://bad")
_ = publicIP(net.IP{1, 2, 3})
} }
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) { func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
@@ -544,8 +484,8 @@ func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testin
} }
func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) { func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) {
prevLookup, prevDial := lookupDownloadIPs, dialDownloadIP prevDial := secureDialContext
t.Cleanup(func() { lookupDownloadIPs, dialDownloadIP = prevLookup, prevDial }) t.Cleanup(func() { secureDialContext = prevDial })
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -562,11 +502,7 @@ func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) {
}() }()
SetSecureDownloadDialTargetForTest(listener.Addr().String()) SetSecureDownloadDialTargetForTest(listener.Addr().String())
ips, err := lookupDownloadIPs(context.Background(), "download.dingtalk.com") conn, err := secureDialContext(context.Background(), "tcp", "download.dingtalk.com:443")
if err != nil || len(ips) != 1 || !publicIP(ips[0].IP) {
t.Fatalf("overridden lookup = %v, %v; want one public answer", ips, err)
}
conn, err := dialDownloadIP(context.Background(), "tcp", "8.8.8.8:443")
if err != nil { if err != nil {
t.Fatalf("overridden dial did not reach the fixture listener: %v", err) t.Fatalf("overridden dial did not reach the fixture listener: %v", err)
} }
+5 -9
View File
@@ -9,16 +9,12 @@ import (
) )
// SetSecureDownloadDialTargetForTest reroutes every secure download dial to // SetSecureDownloadDialTargetForTest reroutes every secure download dial to
// fixtureAddr while reporting a public DNS answer for any host. Subprocess // fixtureAddr. Subprocess e2e tests use it to serve platform download hosts
// e2e tests use it to serve platform download hosts from a loopback TLS // from a loopback TLS fixture: TLS SNI and certificate verification still run
// fixture: TLS SNI and certificate verification still run against the real // against the real host name, and the production client keeps Proxy disabled.
// host name, and the production client keeps Proxy disabled and the // Production code must not call this.
// dial-time public-IP policy intact. Production code must not call this.
func SetSecureDownloadDialTargetForTest(fixtureAddr string) { func SetSecureDownloadDialTargetForTest(fixtureAddr string) {
lookupDownloadIPs = func(context.Context, string) ([]net.IPAddr, error) { secureDialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
}
dialDownloadIP = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, fixtureAddr) return (&net.Dialer{}).DialContext(ctx, network, fixtureAddr)
} }
} }
+5 -5
View File
@@ -317,11 +317,11 @@ func isAliyunOSSHost(host string) bool {
} }
// validateResourceDownloadURL enforces the shared localio download URL // validateResourceDownloadURL enforces the shared localio download URL
// policy: HTTPS domain names on the default port, never IP literals or // policy: HTTPS domain names only, never IP literals or userinfo URLs.
// userinfo URLs. Host trust is no longer a static allowlist — SSRF protection // Resource URLs are always resolved from an authenticated MCP response
// is enforced at dial time by the public-IP policy in // (never user-supplied), and localio.SecureHTTPClient keeps TLS hostname
// localio.SecureHTTPClient, so dedicated-deployment download hosts follow the // verification plus redirect credential hygiene, mirroring the official GUI
// same code path as public DingTalk/OSS hosts. // 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 {