package delivery import ( "errors" "fmt" "net" "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") // offOriginHeaderPolicy returns a CheckRedirect that drops every // origin-scoped header once a redirect leaves the origin the // operator configured. names is the set applyRequestHeaders // reports: the operator's configured headers and the inbound event // headers this delivery forwarded, under one rule rather than two. // // 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 — and a forwarded inbound header is routinely a // sender's signature — X-Hub-Signature — so an open redirect at an // otherwise trusted destination would hand either 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. // // The strip is per hop, not permanent: net/http re-copies the // initial request's headers for every hop, so a chain that returns // to the configured origin carries them again, exactly as net/http // treats Authorization. // // 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 offOriginHeaderPolicy( names []string, ) func(*http.Request, []*http.Request) error { 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 origin-scoped 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. // // The port is joined with net.JoinHostPort rather than a bare // colon: Hostname() unwraps an IPv6 literal's brackets, so // "[2001:db8::1]:8080" and "[2001:db8::1:8080]" — a different // address on a different port — would otherwise render the same // string and pass as 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 net.JoinHostPort(host, port) }