package handlers import ( "fmt" "net" "net/url" "os" "path/filepath" "strings" ) // devModeAllowsLoopback reports whether the SSRF defence should permit // http://127.0.0.1: workspace URLs. True only when MOLECULE_ENV is // a dev value — this is the same convention the middleware dev-mode // escape hatch uses (handlers/admin_test_token.go, middleware/devmode.go). // // Why: on a self-hosted Docker setup the provisioner publishes each // container's A2A port on 127.0.0.1: and writes that URL // to workspaces.url. The A2A proxy on the host platform needs to POST // to that same 127.0.0.1: to reach the container — there's no // other reachable address. SaaS never hits this branch because hosted // tenants run MOLECULE_ENV=production (enforced by the crypto strict- // init path) and the workspace URL is the tenant EC2's VPC-private IP. // // The relaxation is narrowly scoped to loopback IPv4 + ::1 — the // metadata, CGNAT, TEST-NET, and link-local guards stay blocked even // in dev mode. func devModeAllowsLoopback() bool { env := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_ENV"))) return env == "development" || env == "dev" } // ssrfCheckEnabled controls whether isSafeURL performs real validation. // Tests disable it via setSSRFCheckForTest so that httptest.NewServer // loopback URLs and fake hostnames (*.example) don't trigger SSRF // rejections. Production code never mutates this. var ssrfCheckEnabled = true // setSSRFCheckForTest overrides ssrfCheckEnabled for the duration of a test // and returns a restore function. Use with defer in *_test.go only. func setSSRFCheckForTest(enabled bool) func() { prev := ssrfCheckEnabled ssrfCheckEnabled = enabled return func() { ssrfCheckEnabled = prev } } // isSafeURL validates that a URL resolves to a publicly-routable address, // 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 { if !ssrfCheckEnabled { return nil } u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme) } host := u.Hostname() if host == "" { return fmt.Errorf("empty hostname") } if ip := net.ParseIP(host); ip != nil { if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || 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 } addrs, err := net.LookupHost(host) if err != nil { return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err) } if len(addrs) == 0 { return fmt.Errorf("DNS returned no addresses for: %s", host) } for _, addr := range addrs { ip := net.ParseIP(addr) if ip == nil { continue } if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || 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 } // testAllowLoopback is a test-only escape hatch. When true, isSafeURL // accepts 127.0.0.0/8 and ::1 so unit tests that stub workspace URLs // with httptest.NewServer (which binds to loopback) can reach their // own mock backends. Flipped via allowLoopbackForTest(t) in tests — // never set in production code paths. // // The 169.254 metadata, RFC-1918, TEST-NET, CGNAT, and link-local // guards are NOT relaxed by this flag — only loopback. var testAllowLoopback = false // 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 { // MOLECULE_ENV=development is the dev-host pattern: platform and // workspace containers share a docker bridge network (172.18.0.0/16, // RFC-1918). Treat that the same as SaaS for private-range relaxation // — both share the "trusted intra-network routing" property. Without // this, every workspace registration via docker-internal hostname // resolves to 172.18.x.x and gets rejected as // "workspace URL is not publicly routable", breaking the entire // docker-compose dev loop. Always-blocked categories (metadata link- // local, TEST-NET, CGNAT) remain blocked regardless. saas := saasMode() || devModeAllowsLoopback() // 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, // unless tests have opted into loopback via testAllowLoopback OR // MOLECULE_ENV is a dev value (mirrors the v4 relaxation above). if ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback() { 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. func validateRelPath(filePath string) error { // Reject empty string and dot-only paths before any processing. if filePath == "" || filePath == "." { return fmt.Errorf("empty or dot-only path not allowed") } clean := filepath.Clean(filePath) // Reject absolute paths (Unix / or Windows C:\). if filepath.IsAbs(clean) { return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath) } // Reject any path containing ".." anywhere — check both raw and cleaned // because filepath.Clean resolves ".." upward (e.g. "foo/../bar" → "bar" // and "foo/.." → ".") which would make the check pass if only clean were checked. // We only want explicitly-named files; ".." implies intent to escape. if strings.Contains(filePath, "..") || strings.Contains(clean, "..") { return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath) } return nil }