package delivery import ( "errors" "fmt" "net/http" "net/url" "strings" ) // maxDeliveryRedirects caps a redirect chain. Installing a // CheckRedirect replaces net/http's default policy including its // own limit, so the limit is restated rather than dropped. const maxDeliveryRedirects = 10 // schemeHTTPS names the scheme the origin comparison treats // specially: a step down from it is never the same origin. const schemeHTTPS = "https" var errTooManyRedirects = errors.New("too many redirects") // configuredHeaderRedirectPolicy returns a CheckRedirect that // drops a target's configured headers once a redirect leaves the // origin the operator configured. // // net/http withholds Authorization and Cookie across a host change // and forwards everything else. A target header is routinely a // credential under another name — X-Api-Key, PRIVATE-TOKEN, // X-Auth-Token — so an open redirect at an otherwise trusted // destination would hand that credential to a host the operator // never named. Redirects are still followed: refusing them would // break every destination that legitimately redirects and would // record the 3xx as the delivery's result. // // Each hop is dialled through the same SSRF-safe transport, whose // guard runs per connection, so a redirect aimed at a private or // reserved address is still refused at connect time. func configuredHeaderRedirectPolicy( headers map[string]string, ) func(*http.Request, []*http.Request) error { names := make([]string, 0, len(headers)) for name := range headers { names = append(names, http.CanonicalHeaderKey(name)) } return func(req *http.Request, via []*http.Request) error { if len(via) >= maxDeliveryRedirects { return fmt.Errorf( "%w: stopped after %d", errTooManyRedirects, maxDeliveryRedirects, ) } if sameDeliveryOrigin(via[0].URL, req.URL) { return nil } for _, name := range names { req.Header.Del(name) } return nil } } // sameDeliveryOrigin reports whether dest is close enough to the // configured target URL to keep carrying its configured headers. // // This is stricter than the rule net/http applies to Authorization: // the port is part of the comparison (a different port is a // different service), and a subdomain of the configured host is not // the same origin. An https origin stepping down to http is never // the same origin whatever the hosts are, because that puts the // header on the wire in clear. func sameDeliveryOrigin(origin, dest *url.URL) bool { if origin.Scheme == schemeHTTPS && dest.Scheme != schemeHTTPS { return false } return originHostPort(origin) == originHostPort(dest) } // originHostPort renders a URL's host for comparison, lowercased // and with the scheme's default port normalised away so that // "https://h" and "https://h:443" are one origin. func originHostPort(u *url.URL) string { host := strings.ToLower(u.Hostname()) port := u.Port() if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == schemeHTTPS && port == "443") { return host } return host + ":" + port }