diff --git a/.changes/download-host-allowlist-removal.md b/.changes/download-host-allowlist-removal.md index e9d9bb6f..99901e65 100644 --- a/.changes/download-host-allowlist-removal.md +++ b/.changes/download-host-allowlist-removal.md @@ -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. diff --git a/internal/localio/download.go b/internal/localio/download.go index 549ddcf4..12de1f92 100644 --- a/internal/localio/download.go +++ b/internal/localio/download.go @@ -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 diff --git a/internal/localio/download_test.go b/internal/localio/download_test.go index e9a628f9..02c77dc0 100644 --- a/internal/localio/download_test.go +++ b/internal/localio/download_test.go @@ -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) } diff --git a/internal/localio/fortest.go b/internal/localio/fortest.go index f31350b6..94d1cf42 100644 --- a/internal/localio/fortest.go +++ b/internal/localio/fortest.go @@ -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) } } diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go index ce90b3d2..fd1be572 100644 --- a/internal/shortcut/chat/resource_download.go +++ b/internal/shortcut/chat/resource_download.go @@ -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 {