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
host allowlist from both the shared local download path (`drive +download`,
`drive +version-download`, doc/minutes artifact downloads) and the chat
message-resource path (`chat +messages-resource-download`,
`--download-resources`). Download URLs still require an HTTPS domain (IP
literals and userinfo URLs stay rejected), and non-default HTTPS ports are
now accepted because dedicated-deployment storage domains legitimately
serve on them; the port is not a trust signal and the dial-time SSRF policy
is port-agnostic. SSRF
protection now happens at dial time through a shared public-IP policy that
refuses hosts resolving to private, loopback, link-local, or otherwise
non-public addresses. Dedicated-deployment storage domains now download
through the same code path as public DingTalk/OSS hosts.
host allowlist and the dial-time public-IP refusal from both the shared
local download path (`drive +download`, `drive +version-download`,
doc/minutes artifact downloads) and the chat message-resource path
(`chat +messages-resource-download`, `--download-resources`). Download
URLs still require an HTTPS domain (IP literals and userinfo URLs stay
rejected) and now accept non-default HTTPS ports, because every dimension
of a dedicated-deployment storage endpoint — custom domain, port, and
network location — is decided by the customer deployment and cannot be
enumerated or configured client-side. Verified on a dedicated deployment
whose storage domain resolves to a customer-intranet address. Downloads
align with the official GUI client, which applies no client-side SSRF
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`,
minutes audio upload) keep the pre-existing public DingTalk/OSS trusted
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).
Download credential headers are issued together with the download URL by
the same authenticated service response and follow it as-is on the first
request; redirects leaving the original host still strip them. The
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.
request; redirects leaving the original host still strip them.
+18 -89
View File
@@ -12,7 +12,6 @@ import (
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
pathpkg "path"
@@ -35,8 +34,7 @@ type downloadTempFile interface {
var (
createDownloadTemp = createDownloadTempInRoot
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
secureDialContext = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
localGetwd = os.Getwd
localAbs = filepath.Abs
localEvalSymlinks = filepath.EvalSymlinks
@@ -293,11 +291,11 @@ func SafeFilename(preferredName, rawURL string) string {
}
// ValidateDownloadURL accepts HTTPS download URLs on any port. Hosts must be
// domain names, never IP literals or userinfo URLs; SSRF protection is
// enforced at dial time by the public-IP policy in secureHTTPClient, which is
// port-agnostic because the destination IP is what determines reachability.
// Dedicated-deployment storage domains legitimately serve HTTPS on
// non-default ports, so the port is not a trust signal.
// domain names, never IP literals or userinfo URLs; TLS hostname
// verification in secureHTTPClient then pins the connection to the requested
// domain. Dedicated-deployment storage domains legitimately use custom
// domains, non-default ports, and customer-intranet addresses, so the port
// and the resolved network location are not client-side trust signals.
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
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
}
// SecureHTTPClient returns a download client enforcing the same dial-time
// public-IP policy and redirect hygiene as Download. Product shortcuts that
// own their local-file workflow (e.g. chat message resources) share it so
// download URL trust decisions stay in one place.
// SecureHTTPClient returns a download client enforcing the same URL policy
// and redirect hygiene as Download. Product shortcuts that own their
// local-file workflow (e.g. chat message resources) share it so download URL
// trust decisions stay in one place.
func SecureHTTPClient() *http.Client {
return secureHTTPClient()
}
func secureHTTPClient() *http.Client {
transport := &http.Transport{
// Do not use environment proxies here. DialContext must resolve and dial
// the validated download host itself; with a proxy it would receive the
// proxy address and could not enforce the target host's public-IP policy.
// Dial the service-issued host directly, ignoring environment proxies:
// dedicated-deployment storage may live on customer intranets that are
// 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,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(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
return secureDialContext(ctx, network, address)
},
}
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)
}
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 {
if parent == "." {
return nil
+9 -73
View File
@@ -60,7 +60,7 @@ func (f *coverageTempFile) Close() error {
return err
}
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
func TestCrossPlatformCoverageDownloadURLPolicy(t *testing.T) {
valid := []string{
"https://alidocs.dingtalk.com/file.docx",
"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)
}
}
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) {
@@ -387,63 +369,22 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
t.Fatal("bad address dial succeeded")
}
t.Run("lookup error", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
t.Run("dial seam failure propagates", func(t *testing.T) {
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 {
t.Fatal("lookup error ignored")
t.Fatal("dial failure ignored")
}
})
t.Run("private answer", 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
})
t.Run("dial seam success", func(t *testing.T) {
left, right := net.Pipe()
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
calls := 0
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, errors.New("first")
}
return left, nil
})
testseam.Swap(t, &secureDialContext, func(context.Context, string, string) (net.Conn, error) { return left, nil })
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
t.Fatal(err)
} else {
_ = 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()
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/%zz")
_ = SafeFilename("", "://bad")
_ = publicIP(net.IP{1, 2, 3})
}
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
@@ -544,8 +484,8 @@ func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testin
}
func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) {
prevLookup, prevDial := lookupDownloadIPs, dialDownloadIP
t.Cleanup(func() { lookupDownloadIPs, dialDownloadIP = prevLookup, prevDial })
prevDial := secureDialContext
t.Cleanup(func() { secureDialContext = prevDial })
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -562,11 +502,7 @@ func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) {
}()
SetSecureDownloadDialTargetForTest(listener.Addr().String())
ips, err := lookupDownloadIPs(context.Background(), "download.dingtalk.com")
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")
conn, err := secureDialContext(context.Background(), "tcp", "download.dingtalk.com:443")
if err != nil {
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
// fixtureAddr while reporting a public DNS answer for any host. Subprocess
// e2e tests use it to serve platform download hosts from a loopback TLS
// fixture: TLS SNI and certificate verification still run against the real
// host name, and the production client keeps Proxy disabled and the
// dial-time public-IP policy intact. Production code must not call this.
// fixtureAddr. Subprocess e2e tests use it to serve platform download hosts
// from a loopback TLS fixture: TLS SNI and certificate verification still run
// against the real host name, and the production client keeps Proxy disabled.
// Production code must not call this.
func SetSecureDownloadDialTargetForTest(fixtureAddr string) {
lookupDownloadIPs = func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
}
dialDownloadIP = func(ctx context.Context, network, _ string) (net.Conn, error) {
secureDialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
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
// policy: HTTPS domain names on the default port, never IP literals or
// userinfo URLs. Host trust is no longer a static allowlist — SSRF protection
// is enforced at dial time by the public-IP policy in
// localio.SecureHTTPClient, so dedicated-deployment download hosts follow the
// same code path as public DingTalk/OSS hosts.
// policy: HTTPS domain names only, never IP literals or userinfo URLs.
// Resource URLs are always resolved from an authenticated MCP response
// (never user-supplied), and localio.SecureHTTPClient keeps TLS hostname
// verification plus redirect credential hygiene, mirroring the official GUI
// client which applies no client-side SSRF interception to downloads.
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := localio.ValidateDownloadURL(rawURL)
if err != nil {