All checks were successful
check / check (push) Successful in 2m56s
Three findings from the review of the per-target request headers feature, plus the follow-up they raised about the inbound headers the same delivery path forwards. One rule now governs every header a delivery carries on someone else's behalf: a redirect hop that leaves the origin the target names carries none of them. That covers the operator's configured headers and the inbound event headers forwarded from the sender alike. net/http withholds only Authorization and Cookie across a host change, so an operator's X-Api-Key or a sender's X-Hub-Signature would otherwise follow a 302 to a host nobody 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 the lot. The shared SSRF-safe transport is kept on that client, so each hop is still dialled through the private-IP guard. The set to strip is not a name list. applyRequestHeaders now returns the canonical names of everything it applied on the sender's or operator's behalf, and the redirect policy strips exactly that, so a header added to the forward set is covered without a second edit. Content-Type and User-Agent are the delivery path's own rather than anyone else's, and both are excluded from that set so they always travel: Content-Type is set from the event and a 307 preserves the body across hosts, so it has to stay typed, and User-Agent is overwritten with this delivery path's own after the forwarded headers are applied, so the sender's never reaches the wire and stripping it off-origin would only substitute net/http's default. The origin comparison no longer collapses two IPv6 origins into one. Hostname() unwraps a literal's brackets, so re-appending the port with a bare colon rendered https://[2001:db8::1]:8080 and https://[2001:db8::1:8080] identically — a different address on a different port passing as the same origin. The port is joined with net.JoinHostPort, and both spellings are in TestSameDeliveryOrigin. The ten-hop cap gains a regression test. Installing a CheckRedirect is precisely what discards net/http's own limit, so a self-redirecting destination is driven through the policy and asserted to stop after exactly ten requests with the sentinel surfacing to the caller. 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 as one rule over both header classes, including that the drop is per hop rather than permanent: net/http re-copies the initial request's headers each hop, so a chain returning to the configured origin carries them again, exactly as it treats Authorization. It also records what following a 301, 302 or 303 costs, since that is net/http's own behaviour and the decision to follow redirects is what buys it: the POST becomes a GET and the event body and its Content-Type are dropped, so the destination the chain ends at receives no event while the delivery is still recorded Delivered. The edit form's hint gains Trailer and the redirect note. Closes #243
108 lines
3.6 KiB
Go
108 lines
3.6 KiB
Go
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)
|
|
}
|