package pipeline import ( "context" "fmt" "net" "net/http" "net/netip" "time" "evobgp/internal/httpclient" ) type cdnRoundTripper struct { transport *http.Transport } func (t *cdnRoundTripper) CloseIdleConnections() { t.transport.CloseIdleConnections() } func (t *cdnRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if _, err := ValidateCDNURL(req.URL.String()); err != nil { return nil, err } resp, err := t.transport.RoundTrip(req) if err != nil { return nil, err } // Transport has already decoded gzip here when it was negotiated by net/http. resp.Body = &limitedCDNBody{ReadCloser: resp.Body, remaining: cdnBodyLimit} return resp, nil } type cdnLookupIP func(context.Context, string) ([]net.IP, error) type cdnDialContext func(context.Context, string, string) (net.Conn, error) // cdnDial resolves every new connection and dials only an address from that checked answer. // A mixed public/private DNS answer is rejected in full, regardless of address ordering. func cdnDial(ctx context.Context, network, address string, lookup cdnLookupIP, dial cdnDialContext) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("pipeline: cdn dial address: %w", err) } if isBlockedCDNHostname(host) { return nil, fmt.Errorf("pipeline: cdn url blocked host") } var ips []net.IP if addr, err := netip.ParseAddr(host); err == nil { ips = []net.IP{net.IP(addr.AsSlice())} } else { resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() ips, err = lookup(resolveCtx, host) if err != nil { return nil, fmt.Errorf("pipeline: cdn url dns lookup: %w", err) } } if len(ips) == 0 { return nil, fmt.Errorf("pipeline: cdn url dns lookup: no addresses") } checked := make([]netip.Addr, 0, len(ips)) for _, ip := range ips { addr, ok := netip.AddrFromSlice(ip) if !ok || isBlockedCDNIP(addr) { return nil, fmt.Errorf("pipeline: cdn url resolves to blocked address") } checked = append(checked, addr.Unmap()) } var dialErr error for _, addr := range checked { if err := ctx.Err(); err != nil { return nil, err } conn, err := dial(ctx, network, net.JoinHostPort(addr.String(), port)) if err == nil { return conn, nil } dialErr = err } return nil, fmt.Errorf("pipeline: cdn dial: %w", dialErr) } func secureCDNClient(hc *http.Client) (*http.Client, error) { if hc == nil { hc = httpclient.New(httpclient.DefaultTimeout) } client := *hc if pools, ok := client.Transport.(*pipelineTransport); ok { client.Transport = pools.cdn } if client.Timeout <= 0 { client.Timeout = httpclient.DefaultTimeout } if _, ok := client.Transport.(*cdnRoundTripper); !ok { base := http.DefaultTransport.(*http.Transport).Clone() switch tr := client.Transport.(type) { case nil: case *http.Transport: // Keep the caller's trust roots for TLS test servers. Other transport // hooks (including custom protocols and dialers) are not inherited. if tr.TLSClientConfig != nil { base.TLSClientConfig = tr.TLSClientConfig.Clone() } default: return nil, fmt.Errorf("pipeline: cdn client requires *http.Transport") } // A proxy connects to the destination outside this process, where its DNS answer // cannot be checked. CDN requests therefore always connect directly. base.Proxy = nil base.DialTLSContext = nil base.DialTLS = nil base.TLSNextProto = nil dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} base.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { return cdnDial(ctx, network, address, func(ctx context.Context, host string) ([]net.IP, error) { return net.DefaultResolver.LookupIP(ctx, "ip", host) }, dialer.DialContext) } client.Transport = &cdnRoundTripper{transport: base} } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if _, err := ValidateCDNURL(req.URL.String()); err != nil { return err } if len(via) >= 10 { return fmt.Errorf("pipeline: too many cdn redirects") } return nil } return &client, nil } // NewCDNHTTPClient returns a reusable HTTPS client that pins each CDN connection // to a checked DNS answer. Environment HTTP proxies are intentionally ignored. func NewCDNHTTPClient() *http.Client { client, _ := secureCDNClient(httpclient.New(httpclient.DefaultTimeout)) return client }