Harden operator-set target headers (closes #233)
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
This commit is contained in:
clawbot
2026-08-20 08:08:58 +00:00
parent 03cd1859d7
commit 24af4b4200
9 changed files with 746 additions and 62 deletions

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"sync"
"time"
@@ -404,9 +405,9 @@ func (t *httpTarget) doHTTPRequest(
)
}
applyRequestHeaders(req, event, cfg)
originScoped := applyRequestHeaders(req, event, cfg)
client := t.clientForConfig(cfg)
client := t.clientForRequest(cfg, originScoped)
resp, doErr := executeHTTPRequest(client, req)
@@ -432,23 +433,41 @@ func (t *httpTarget) doHTTPRequest(
return resp.StatusCode, string(body), dur, nil
}
func (t *httpTarget) clientForConfig(
// clientForRequest returns the client for one delivery attempt.
// originScoped is the header set applyRequestHeaders built for that
// attempt; a request with neither a per-target timeout nor an
// origin-scoped header gets the shared client, because there is
// then nothing for the redirect policy to strip and net/http's
// default policy already withholds Authorization and Cookie across
// hosts.
func (t *httpTarget) clientForRequest(
cfg *HTTPTargetConfig,
originScoped []string,
) *http.Client {
if cfg.Timeout > 0 {
// Reuse the shared client's SSRF-safe transport so
// a per-target timeout does not drop the
// request-time private-IP guard. Only the timeout
// is overridden.
return &http.Client{
Timeout: time.Duration(
cfg.Timeout,
) * time.Second,
Transport: t.client.Transport,
}
if cfg.Timeout <= 0 && len(originScoped) == 0 {
return t.client
}
return t.client
// Reuse the shared client's SSRF-safe transport so neither a
// per-target timeout nor the redirect policy drops the
// request-time private-IP guard — which, being a dial hook,
// also covers every redirect hop.
client := &http.Client{
Timeout: t.client.Timeout,
Transport: t.client.Transport,
}
if cfg.Timeout > 0 {
client.Timeout = time.Duration(
cfg.Timeout,
) * time.Second
}
if len(originScoped) > 0 {
client.CheckRedirect = offOriginHeaderPolicy(originScoped)
}
return client
}
func parseHTTPConfig(
@@ -490,40 +509,88 @@ func isForwardableHeader(name string) bool {
}
}
// applyRequestHeaders builds one outbound delivery's header set and
// returns the canonical names of every header in it that is scoped
// to the configured origin: the inbound event headers this delivery
// forwarded, plus the operator's configured headers. The redirect
// policy strips exactly that set on a hop that leaves the origin,
// so the forward set is decided here and only here — a header added
// to it is covered off-origin without a second edit elsewhere.
func applyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
) {
) []string {
if event.ContentType != "" {
req.Header.Set(
"Content-Type", event.ContentType,
)
}
var originalHeaders map[string][]string
if event.Headers != "" {
jsonErr := json.Unmarshal(
[]byte(event.Headers),
&originalHeaders,
)
if jsonErr == nil {
for k, vals := range originalHeaders {
if isForwardableHeader(k) {
for _, v := range vals {
req.Header.Add(k, v)
}
}
}
}
}
originScoped := forwardEventHeaders(req, event)
for k, v := range cfg.Headers {
req.Header.Set(k, v)
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
}
req.Header.Set("User-Agent", "webhooker/1.0")
// Content-Type describes the body being sent rather than the
// sender, and the delivery path sets it from the event itself.
// A 307/308 preserves the body across hosts, so stripping it
// would send that body untyped.
delete(originScoped, "Content-Type")
// User-Agent is overwritten just above, so an inbound one never
// reaches the wire and the value that does identifies this
// delivery path rather than the sender. Reporting it would strip
// it off-origin and leave net/http's own default in its place.
delete(originScoped, "User-Agent")
names := make([]string, 0, len(originScoped))
for name := range originScoped {
names = append(names, name)
}
sort.Strings(names)
return names
}
// forwardEventHeaders copies the inbound event's forwardable
// headers onto the outbound request and returns the canonical names
// it forwarded. Headers the event never carried are absent from the
// result, so the redirect policy strips what was actually sent.
func forwardEventHeaders(
req *http.Request,
event *database.Event,
) map[string]struct{} {
forwarded := make(map[string]struct{})
if event.Headers == "" {
return forwarded
}
var inbound map[string][]string
if json.Unmarshal([]byte(event.Headers), &inbound) != nil {
return forwarded
}
for k, vals := range inbound {
if !isForwardableHeader(k) || len(vals) == 0 {
continue
}
for _, v := range vals {
req.Header.Add(k, v)
}
forwarded[http.CanonicalHeaderKey(k)] = struct{}{}
}
return forwarded
}
// executeHTTPRequest sends an HTTP request using the provided