// Phase C Step 9 — `http` host capability. // // Outbound HTTP requests proxied through the server. Each request is matched // against PluginsConfig.HTTPAllowlist (host suffix match) before being sent. // The wazero-tagged build invokes this from the plugin's `host_http_request` // import; the default build exposes it for testing. package plugin import ( "bytes" "context" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "time" ) // HTTPRequest is the plugin → host request envelope. type HTTPRequest struct { Method string URL string Body []byte Header map[string]string } // HTTPResponse is the host → plugin response envelope. type HTTPResponse struct { StatusCode int Body []byte Header map[string]string } const ( httpTimeout = 10 * time.Second maxResponseBytes = 5 * 1024 * 1024 // 5 MiB ) // ErrHTTPHostDenied is returned when a plugin HTTP request targets a host that // is not in the allowlist or resolves to a private/loopback/link-local address. var ErrHTTPHostDenied = errors.New("plugin http: host denied") // HTTPDo executes a plugin-initiated HTTP request after enforcing the host // allowlist declared in PluginsConfig and rejecting requests that resolve to // private, loopback, or link-local IP ranges (SSRF defense). func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) (*HTTPResponse, error) { if !inst.Manifest.HasCapability(CapHTTP) { return nil, ErrCapabilityNotGranted } parsed, err := url.Parse(req.URL) if err != nil { return nil, fmt.Errorf("plugin http: invalid URL: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("plugin http: scheme %q not allowed", parsed.Scheme) } host := parsed.Hostname() if host == "" { return nil, fmt.Errorf("plugin http: empty host") } if !r.hostAllowed(host) { return nil, fmt.Errorf("%w: %s", ErrHTTPHostDenied, host) } // No pre-resolve here: the transport's guarded dial resolves once, // validates every address, and dials only vetted IPs — it is the // authoritative SSRF check, and a second lookup would just cost an extra // DNS round trip while re-opening the rebinding TOCTOU it exists to close. httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewReader(req.Body)) if err != nil { return nil, fmt.Errorf("plugin http: build request: %w", err) } for k, v := range req.Header { httpReq.Header.Set(k, v) } // Custom transport with a guarded DialContext: the host is resolved once, // every candidate IP is validated against the blocklist, and the actual // connection is made to a specific vetted IP — never re-resolved by // hostname. This closes the DNS-rebinding TOCTOU window where a second // lookup (the one net.Dialer would perform on a hostname) could return an // internal IP after an earlier check had approved the name. transport := &http.Transport{DialContext: GuardedDialContext()} client := &http.Client{ Timeout: httpTimeout, Transport: transport, // Refuse to follow redirects across hosts that the allowlist would // reject — re-evaluate the new URL through the same checks. CheckRedirect: func(redirReq *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("too many redirects") } h := redirReq.URL.Hostname() if !r.hostAllowed(h) { return fmt.Errorf("%w: redirect to %s", ErrHTTPHostDenied, h) } // Address vetting happens in the guarded dial the redirect will // flow through — no pre-resolve needed here either. return nil }, } resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("plugin http: do: %w", err) } defer func() { _ = resp.Body.Close() }() // Cap body size so a hostile/large response cannot OOM the host. We // LimitReader to maxResponseBytes+1 so we can detect truncation. limited := io.LimitReader(resp.Body, maxResponseBytes+1) body, err := io.ReadAll(limited) if err != nil { return nil, fmt.Errorf("plugin http: read body: %w", err) } if int64(len(body)) > maxResponseBytes { return nil, fmt.Errorf("plugin http: response exceeds %d bytes", maxResponseBytes) } hdr := make(map[string]string, len(resp.Header)) for k, v := range resp.Header { if len(v) > 0 { hdr[k] = v[0] } } return &HTTPResponse{ StatusCode: resp.StatusCode, Body: body, Header: hdr, }, nil } // hostAllowed reports whether host matches any allowlist entry. Matching is // either exact (host == entry) or proper suffix bounded by a dot // (host == "api."+entry or host ends with "."+entry). This rejects // "evilexample.com" against an allowlist of "example.com". // // Empty allowlist entries are ignored to prevent the empty-suffix wildcard // bug. host is expected to already be a clean hostname (no scheme/port/path). func (r *Registry) hostAllowed(host string) bool { if host == "" { return false } host = strings.ToLower(strings.TrimSuffix(host, ".")) for _, entry := range r.cfg.HTTPAllowlist { entry = strings.ToLower(strings.TrimSpace(entry)) if entry == "" { continue } if host == entry { return true } if strings.HasSuffix(host, "."+entry) { return true } } return false } // lookupIPAddr and dialContext are swappable seams so the guarded dial can be // tested without real DNS or network reachability. var ( lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { return (&net.Resolver{}).LookupIPAddr(ctx, host) } dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { d := &net.Dialer{Timeout: httpTimeout} return d.DialContext(ctx, network, addr) } ) // GuardedDialContext returns the SSRF-guarded dial used by HTTPDo's // transport. It is exported so every other outbound-HTTP call site in the // server (e.g. the GIF proxy in package api) shares one vetted dialer instead // of reaching for a bare net.Dialer. // // Behaviour: resolve once, validate every returned address, then dial vetted // concrete IPs. All addresses are validated before any dial (one poisoned // record among them refuses the whole request), and every vetted address is // tried in order — a dual-stack or round-robin host whose first record is // down must still connect via the next one. func GuardedDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, network, addr string) (net.Conn, error) { h, port, splitErr := net.SplitHostPort(addr) if splitErr != nil { return nil, splitErr } // IP literal: validate and dial as-is (no resolution happens). if ip := net.ParseIP(h); ip != nil { if err := ipAllowed(ip); err != nil { return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) } return dialContext(ctx, network, addr) } ips, lookupErr := lookupIPAddr(ctx, h) if lookupErr != nil { return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) } if len(ips) == 0 { return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) } for _, resolved := range ips { if err := ipAllowed(resolved.IP); err != nil { return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) } } var dialErr error for _, resolved := range ips { conn, err := dialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) if err == nil { return conn, nil } dialErr = err } return nil, dialErr } } // cgnRange covers RFC6598 carrier-grade NAT (100.64.0.0/10). net.IP.IsPrivate // does NOT include this range, but it is non-routable on the public internet // and may reach internal services on carrier networks. var cgnRange = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)} // ipAllowed reports nil if ip is a public, routable address. Loopback, // link-local, multicast, unspecified, RFC1918, RFC4193, and RFC6598 (CGN) // ranges are rejected. func ipAllowed(ip net.IP) error { if ip == nil { return fmt.Errorf("nil ip") } if ip.IsLoopback() { return fmt.Errorf("loopback address %s", ip) } if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return fmt.Errorf("link-local address %s", ip) } if ip.IsPrivate() { return fmt.Errorf("private address %s", ip) } if ip.IsUnspecified() { return fmt.Errorf("unspecified address %s", ip) } if ip.IsMulticast() { return fmt.Errorf("multicast address %s", ip) } if v4 := ip.To4(); v4 != nil && cgnRange.Contains(v4) { return fmt.Errorf("carrier-grade NAT address %s", ip) } return nil }