Harden operator-set target headers (closes #233)
All checks were successful
check / check (push) Successful in 3m8s
All checks were successful
check / check (push) Successful in 3m8s
Three findings from the review of the per-target request headers feature. Configured headers no longer follow a redirect off the origin the target names. net/http withholds only Authorization and Cookie across a host change, so an operator's X-Api-Key or PRIVATE-TOKEN would follow a 302 to a host they never configured. Redirects are still followed — refusing them would break every destination that legitimately redirects and would record the 3xx as the delivery's result — but a hop to another host, another port, or down from https to http drops every header the target configured. The shared SSRF-safe transport is kept on that client, so each hop is still dialled through the private-IP guard. Trailer joins the reserved names. net/http strips it from the request it writes, so a configured one was accepted, stored, and provably never sent. The invalid-header-name error no longer quotes the text before the first colon. That text is only a name if it parses as one; when it does not, a pasted value whose own colon split the line put half a token into a 400 body. TestParseTargetHeaders_ErrorsNeverQuoteAValue asserted this invariant while only exercising the after-the-colon case, and now covers the before-the-colon one. README documents the http target's config keys, the 300-second timeout ceiling, the reserved-header list and the redirect behaviour; the edit form's hint gains Trailer and the redirect note.
This commit is contained in:
97
internal/delivery/redirect.go
Normal file
97
internal/delivery/redirect.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user