Harden operator-set target headers (closes #233)
All checks were successful
check / check (push) Successful in 3m5s
All checks were successful
check / check (push) Successful in 3m5s
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
This commit is contained in:
@@ -82,6 +82,16 @@ func TestParseTargetHeaders_Rejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// net/http strips Trailer from the request it writes, so accepting
|
||||
// one would store a header that never reaches the target.
|
||||
func TestParseTargetHeaders_RejectsTrailer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := delivery.ParseTargetHeaders("Trailer: X-Checksum")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Trailer")
|
||||
}
|
||||
|
||||
// A header value is routinely a bearer token and these errors are
|
||||
// rendered into a 400 body, so no message may quote one.
|
||||
func TestParseTargetHeaders_ErrorsNeverQuoteAValue(t *testing.T) {
|
||||
@@ -89,17 +99,26 @@ func TestParseTargetHeaders_ErrorsNeverQuoteAValue(t *testing.T) {
|
||||
|
||||
const secret = "QQNEVERINAMESSAGEQQ"
|
||||
|
||||
_, err := delivery.ParseTargetHeaders(
|
||||
inputs := []string{
|
||||
// The value, after the colon, in a duplicate name.
|
||||
"X-A: " + secret + "\nx-a: " + secret,
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secret)
|
||||
|
||||
_, err = delivery.ParseTargetHeaders(
|
||||
// The value after the colon of an unusable name.
|
||||
"X Bad Name: " + secret,
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secret)
|
||||
// The line splits on the value's own colon, so the
|
||||
// secret lands in the text an unusable-name error is
|
||||
// tempted to quote as the name.
|
||||
"X-Api-Key " + secret + ":x",
|
||||
// The same, with nothing before the secret at all.
|
||||
secret + " and more:x",
|
||||
// A control character in the value.
|
||||
"X-A: " + secret + "\x01",
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
_, err := delivery.ParseTargetHeaders(input)
|
||||
require.Error(t, err, input)
|
||||
assert.NotContains(t, err.Error(), secret, input)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading the edit form twice without saving must not reshuffle
|
||||
|
||||
Reference in New Issue
Block a user