package httpclient import ( "context" "io" "net/http" "time" ) // Config tunes the shared HTTP client. type Config struct { Timeout time.Duration // e.g. 30s MaxResponseBytes int64 // e.g. 1 << 20 } // Client is a thin, SSRF-aware wrapper around net/http.Client. type Client struct { cfg Config hc *http.Client } // New creates a Client with redirect following disabled. // Redirect handling is intentionally left to DoWithRedirect. func New(cfg Config) *Client { return &Client{ cfg: cfg, hc: &http.Client{ Timeout: cfg.Timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, }, } } // Do executes a single HTTP request. The response body is truncated to // MaxResponseBytes. The request URL is validated by CheckURL before sending; // hosts listed in allowedHosts bypass SSRF checks. // // TODO(prod): implement IP-pinned dialer to fully close TOCTOU between the // SSRF check here and the actual TCP dial performed by net/http. func (c *Client) Do(ctx context.Context, req *http.Request, allowedHosts ...string) (*http.Response, error) { if err := CheckURL(req.URL.String(), allowedHosts...); err != nil { return nil, err } resp, err := c.hc.Do(req.WithContext(ctx)) if err != nil { return nil, err } resp.Body = struct { io.Reader io.Closer }{ Reader: io.LimitReader(resp.Body, c.cfg.MaxResponseBytes), Closer: resp.Body, } return resp, nil }