Files
webhooker/internal/delivery/redirect.go
clawbot 4d048bcb78
All checks were successful
check / check (push) Successful in 3m5s
Harden operator-set target headers (closes #233)
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 and always travel; a 307 preserves the body
across hosts and it has to stay typed.

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. The edit form's hint gains Trailer and the redirect
note.

Closes #243
2026-08-20 08:08:58 +00:00

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)
}