Files
webhooker/internal/delivery/target_headers.go
clawbot 24af4b4200
All checks were successful
check / check (push) Successful in 2m56s
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 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
2026-08-20 08:39:21 +00:00

264 lines
7.1 KiB
Go

package delivery
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
)
// MaxTargetTimeoutSeconds bounds a per-target request timeout.
// A delivery attempt holds a worker for its whole duration, so an
// unbounded timeout lets one misconfigured target stall the queue
// indefinitely. Five minutes is far beyond any healthy webhook
// receiver and still finite.
const MaxTargetTimeoutSeconds = 300
// Errors returned when a target's header or timeout form input
// cannot be turned into a configuration.
//
// None of these ever quotes a header VALUE. A target header value
// is routinely an authorization token, and these messages are shown
// to the user in an error page body.
var (
errHeaderLineMalformed = errors.New(
`each header line must be "Name: value"`,
)
errHeaderNameInvalid = errors.New(
"header name must be a valid HTTP token",
)
errHeaderValueInvalid = errors.New(
"header value must not contain control characters",
)
errHeaderDuplicate = errors.New(
"header given more than once",
)
errHeaderReserved = errors.New(
"header is set by the delivery engine and cannot be " +
"overridden",
)
errTimeoutInvalid = errors.New(
"timeout must be a whole number of seconds",
)
errTimeoutOutOfRange = errors.New(
"timeout is out of range",
)
)
// isReservedTargetHeader reports whether name (canonicalised) is a
// header a target configuration may not set, because the delivery
// path or net/http itself writes it regardless.
//
// These are rejected rather than accepted-and-ignored. Storing a
// header that provably never reaches the wire tells the operator
// their configuration took effect when it did not, which is the
// same failure mode as silently substituting a default for an
// invalid value.
func isReservedTargetHeader(name string) bool {
switch name {
case "Host", "Content-Length", "Transfer-Encoding", "Connection":
return true
case "User-Agent":
// applyRequestHeaders sets the User-Agent after it applies
// the configured headers, so a configured one would always
// be overwritten.
return true
case "Trailer":
// net/http strips Trailer from the request it writes
// (reqWriteExcludeHeader), so a configured one is accepted
// and stored and then provably never reaches the wire.
return true
default:
return false
}
}
// ParseTargetHeaders turns the target form's headers field — one
// "Name: value" pair per line, blank lines ignored — into the map
// stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
// name repeated in a different case is still a duplicate rather than
// one pair silently overwriting the other.
//
// An input with no pairs yields an empty map, which omitempty drops
// from the stored config: a target configured with no headers keeps
// the same config JSON it had before this field existed.
func ParseTargetHeaders(raw string) (map[string]string, error) {
headers := make(map[string]string)
for i, line := range strings.Split(raw, "\n") {
lineNum := i + 1
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, value, err := parseHeaderLine(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
if _, dup := headers[name]; dup {
return nil, fmt.Errorf(
"line %d: %w: %q", lineNum,
errHeaderDuplicate, name,
)
}
headers[name] = value
}
return headers, nil
}
// parseHeaderLine splits and validates one "Name: value" line,
// returning the canonicalised name and the trimmed value.
func parseHeaderLine(line string) (string, string, error) {
rawName, value, found := strings.Cut(line, ":")
if !found {
return "", "", errHeaderLineMalformed
}
rawName = strings.TrimSpace(rawName)
if !validHeaderName(rawName) {
// Quotes nothing. The text before the first colon is only
// a name if it parses as one; when it does not, it is as
// likely to be a pasted value whose own colon split the
// line, and half of a token would be echoed into the 400.
return "", "", errHeaderNameInvalid
}
name := http.CanonicalHeaderKey(rawName)
if isReservedTargetHeader(name) {
return "", "", fmt.Errorf(
"%w: %q", errHeaderReserved, name,
)
}
value = strings.TrimSpace(value)
if !validHeaderValue(value) {
return "", "", fmt.Errorf(
"%w: %q", errHeaderValueInvalid, name,
)
}
return name, value, nil
}
// validHeaderName reports whether name is a non-empty RFC 9110
// field name. Rejecting anything else here is what keeps a value
// containing CR or LF from being smuggled in as part of a name and
// injecting a second header into the outbound request.
func validHeaderName(name string) bool {
if name == "" {
return false
}
for i := range len(name) {
if !isTokenByte(name[i]) {
return false
}
}
return true
}
// isTokenByte reports whether c is a "tchar" per RFC 9110 5.6.2.
func isTokenByte(c byte) bool {
switch {
case c >= 'a' && c <= 'z',
c >= 'A' && c <= 'Z',
c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0
}
// validHeaderValue reports whether value is a legal field value:
// no control characters, which is the other half of the header
// injection guard. An empty value is legal.
func validHeaderValue(value string) bool {
for i := range len(value) {
c := value[i]
if c < 0x20 || c == 0x7f {
return false
}
}
return true
}
// FormatTargetHeaders renders a stored header map back into the
// form's textarea representation, one "Name: value" per line.
//
// Names are sorted so that loading the edit form twice without
// saving produces identical text; Go map iteration order would
// otherwise reshuffle the field on every render.
func FormatTargetHeaders(headers map[string]string) string {
if len(headers) == 0 {
return ""
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var b strings.Builder
for _, name := range names {
b.WriteString(name)
b.WriteString(": ")
b.WriteString(headers[name])
b.WriteString("\n")
}
return b.String()
}
// ParseTargetTimeout interprets the target form's timeout field as
// a whole number of seconds. An empty field means "unset" and yields
// 0, which omitempty drops from the stored config and which the
// delivery path reads as "use the shared client's timeout".
//
// Anything else that is not a whole number in range is an error, not
// a silently substituted default: a target whose timeout was typed
// wrong must say so at the form rather than deliver on a timeout its
// operator did not choose.
func ParseTargetTimeout(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errTimeoutInvalid
}
if v > MaxTargetTimeoutSeconds {
return 0, fmt.Errorf(
"%w: at most %d seconds",
errTimeoutOutOfRange, MaxTargetTimeoutSeconds,
)
}
return v, nil
}
// FormatTargetTimeout renders a stored timeout for the form field.
// An unset timeout renders as an empty field rather than "0", so the
// placeholder can describe the default the target actually uses.
func FormatTargetTimeout(timeout int) string {
if timeout <= 0 {
return ""
}
return strconv.Itoa(timeout)
}