From 6d87408f775bda82b1b10a2d8889a85764defc8f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 17:00:30 -0700 Subject: [PATCH 1/2] fix(ssrf): honour saasMode for RFC-1918 private IPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspaces on SaaS register with their VPC-private IP (172.31.x.x on AWS default VPCs). The SSRF guard in ssrf.go blocked them unconditionally as "forbidden private/metadata IP", returning 502 on every /workspaces/:id/a2a call — chat, delegation fanout, webhooks all failed. The saasMode()-aware test assertions existed (TestIsPrivateOrMetadataIP_SaaSMode) but the implementation never called saasMode(). Wire it up. In SaaS: - RFC-1918 (10/8, 172.16/12, 192.168/16) and IPv6 ULA fd00::/8 are allowed - 169.254/16 metadata, TEST-NET, 100.64/10 CGNAT, loopback, link-local stay blocked in every mode Also hardens IPv6: link-local multicast and interface-local multicast are now rejected; DNS-resolved v6 addrs are checked too. Symptom log (prod tenant hongmingwang): ProxyA2A: unsafe URL for workspace a8af9d79-...: forbidden private/metadata IP: 172.31.47.119 Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace-server/internal/handlers/ssrf.go | 127 ++++++++++++++++----- 1 file changed, 101 insertions(+), 26 deletions(-) diff --git a/workspace-server/internal/handlers/ssrf.go b/workspace-server/internal/handlers/ssrf.go index 09bb2774..67af118d 100644 --- a/workspace-server/internal/handlers/ssrf.go +++ b/workspace-server/internal/handlers/ssrf.go @@ -12,12 +12,16 @@ import ( // preventing A2A requests from being redirected to internal/cloud-metadata // infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches // so we validate before making any outbound HTTP call. +// +// SaaS relaxation: when saasMode() is true, RFC-1918 private ranges and +// IPv6 ULA are considered safe because workspaces live on sibling EC2s in +// the same VPC and register by their VPC-private IP. Metadata endpoints, +// loopback, link-local, and TEST-NET stay blocked in every mode. func isSafeURL(rawURL string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } - // Reject non-HTTP(S) schemes. if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme) } @@ -25,20 +29,17 @@ func isSafeURL(rawURL string) error { if host == "" { return fmt.Errorf("empty hostname") } - // Block direct IP addresses. if ip := net.ParseIP(host); ip != nil { - if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() { - return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip) + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() { + return fmt.Errorf("forbidden loopback/unspecified/link-local IP: %s", ip) } if isPrivateOrMetadataIP(ip) { return fmt.Errorf("forbidden private/metadata IP: %s", ip) } return nil } - // For hostnames, resolve and validate each returned IP. addrs, err := net.LookupHost(host) if err != nil { - // DNS resolution failure — block it. Could be an internal hostname. return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err) } if len(addrs) == 0 { @@ -46,38 +47,112 @@ func isSafeURL(rawURL string) error { } for _, addr := range addrs { ip := net.ParseIP(addr) - if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) { + if ip == nil { + continue + } + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() { + return fmt.Errorf("hostname %s resolves to forbidden link-local/loopback IP: %s", host, ip) + } + if isPrivateOrMetadataIP(ip) { return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip) } } return nil } -// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT, -// link-local, and cloud metadata ranges. +// isPrivateOrMetadataIP returns true for IPs that must not be reached via A2A. +// +// Always blocked (both modes): +// - 169.254.0.0/16 link-local (cloud metadata endpoints) +// - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (TEST-NET RFC-5737) +// - 100.64.0.0/10 (carrier-grade NAT) +// - IPv6 loopback ::1, link-local fe80::/10, and ULA fc00::/7 in strict mode +// +// Allowed in SaaS mode only (saasMode() == true): +// - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC-1918) +// - fd00::/8 (IPv6 ULA subset of fc00::/7) +// +// Rationale: SaaS tenants run workspaces on sibling EC2s in the same VPC +// and register them by VPC-private IP. The control plane provisions these +// instances, so intra-VPC routing is trusted. On self-hosted / single- +// container deployments the relaxation is off and every private range +// stays blocked. func isPrivateOrMetadataIP(ip net.IP) bool { - var privateRanges = []net.IPNet{ - {IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)}, - {IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)}, - {IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)}, - {IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)}, - {IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, - {IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)}, - {IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)}, - {IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)}, - } - ip = ip.To4() - if ip == nil { - return false - } - for _, r := range privateRanges { - if r.Contains(ip) { + saas := saasMode() + + // IPv4 path. + if ip4 := ip.To4(); ip4 != nil { + // Metadata link-local — always blocked. + if metadataV4.Contains(ip4) { return true } + // TEST-NET / documentation — always blocked. + for _, r := range docRangesV4 { + if r.Contains(ip4) { + return true + } + } + // Carrier-grade NAT — always blocked. + if cgnatV4.Contains(ip4) { + return true + } + // RFC-1918 private — blocked strict, allowed in SaaS. + for _, r := range privateV4 { + if r.Contains(ip4) { + return !saas + } + } + return false + } + + // IPv6 path — .To4() was nil so this is a real v6 address. + // ::1 (loopback) — treat as blocked here too for defense-in-depth. + if ip.IsLoopback() { + return true + } + // Link-local fe80::/10 — always blocked. + if ip.IsLinkLocalUnicast() { + return true + } + // ULA fc00::/7. fd00::/8 is the "locally assigned" half AWS hands out; + // fc00::/8 is reserved. We treat the whole fc00::/7 as private, then + // let SaaS relax fd00::/8 (matches the tests). + if ulaV6.Contains(ip) { + if saas && fd00V6.Contains(ip) { + return false + } + return true } return false } +var ( + metadataV4 = mustCIDR("169.254.0.0/16") + cgnatV4 = mustCIDR("100.64.0.0/10") + + privateV4 = []net.IPNet{ + mustCIDR("10.0.0.0/8"), + mustCIDR("172.16.0.0/12"), + mustCIDR("192.168.0.0/16"), + } + docRangesV4 = []net.IPNet{ + mustCIDR("192.0.2.0/24"), + mustCIDR("198.51.100.0/24"), + mustCIDR("203.0.113.0/24"), + } + + ulaV6 = mustCIDR("fc00::/7") + fd00V6 = mustCIDR("fd00::/8") +) + +func mustCIDR(s string) net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic("ssrf: bad CIDR " + s + ": " + err.Error()) + } + return *n +} + // validateRelPath checks that a file path is relative and does not escape // the destination via absolute paths or ".." traversal. Used by // copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure. @@ -87,4 +162,4 @@ func validateRelPath(filePath string) error { return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath) } return nil -} \ No newline at end of file +} From 0baa6abe18d09f0ecc5d4e60752f53ec47fdec38 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 17:25:11 -0700 Subject: [PATCH 2/2] ci: retrigger after retarget to main