fix(tasks): classify invalid webhook headers permanently, add a header credential seam - #800
Merged
sroussey merged 1 commit intoAug 15, 2026
Conversation
…r credential seam
An invalid request header was reported as a RETRYABLE network error. undici
builds the `Headers` object inside `fetch` and rejects a malformed name or
value with a bare `TypeError` — name "TypeError", no `code`, not a
`FetchUrlJobError` — so `toRedactedWebhookError` matched none of its named
branches and fell through to `NETWORK_ERROR`. A queued consumer retried a typo
forever.
undici's message also quotes the offending header VALUE back, and that text is
spliced into a PERSISTED job-error string, so `assertValidRequestHeaders` names
the header but never echoes its value.
`withJsonContentType` merges the JSON content type case-insensitively:
`{ "Content-Type": …, ...headers }` left a caller's lowercase `content-type` as
a second property that `Headers` folds into one comma-joined field, sending both
types.
Adds `credential_key` / `credential_scheme` / `credential_header` to
`WebhookNotifyTask`, so a bearer/HMAC endpoint no longer forces the secret into
the `headers` port — which `Task.toJSON` writes verbatim into the graph JSON.
The credential is placed BEFORE the header validation, so the credential-produced
header is validated too; that ordering is why the two changes ship together.
`webhookPrivateEntitlements` takes an options object: either credential key
enforces the `credential` entitlement, but only `url_credential_key` unscopes a
declared `network:private` grant — a header credential changes what the request
carries, not where it goes.
Also gates the body-derived retry hint behind `!allowPrivate` (a declared
private destination's reply is never surfaced, and `retry_after` is read from
the body; the `Retry-After` header is transport-level and stays), and exports
`./util/RetryAfter` and `./util/WebhookPost` from the barrel.
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #744, targeting the same base branch. Two review findings plus two low-severity ones. #744's redaction coverage, SSRF gating, retry clamping and real-transport tests are unchanged.
M1 — an invalid request header was classified RETRYABLE
WebhookPost.tsmerged headers inside atrythat only catchesJSON.stringify. The merge itself cannot throw — but undici builds theHeadersobject insidefetch, and a malformed name or value rejects with a bareTypeError: name"TypeError", nocode, not aFetchUrlJobError.toRedactedWebhookErrormatches none of its named branches, so it fell through toNETWORK_ERROR→RetryableJobError, and a queued consumer retried a typo forever.Probed on Node v22.22.2 (
fetchagainst a loopback server):{"X-Bad Name":"v"},{"":"v"},{"X:Y":"v"}TypeError: Headers.append: "…" is an invalid header **name**.{"X-Ok":"a\nb"},{"X-Ok":"a\0b"}TypeError: Headers.append: "…" is an invalid header **value**.{"X-Ok":" "},{"X-Ok":"\nabc"},{"X-Ok":"a\tb"}Two things shape the fix:
detailWithCause(error)and is spliced into a persistedRetryableJobError. With M4 below placing a resolved secret on a header, a token containing a\nwould be written to durable storage by the very error reporting it. So the new guard names the header but never echoes the value, and M1 is a prerequisite for M4.\nvalue is normalized, not rejected. So any pre-validation stricter than undici's own rule is a new rejection, not a mirror. The guard is deliberately stricter in both halves and the JSDoc says so: names are held toisBareHeaderName(letters/digits/hyphens — narrower than RFC 9110's token production), values reject NUL/CR/LF anywhere rather than after a trim step.Also:
isBareHeaderNameis exported fromFetchUrlCredentials.ts(CREDENTIAL_HEADER_NAME_PATTERNstays private;credentialHeaderNamenow calls the predicate — no behaviour change), so request headers and credential ports are held to one rule rather than two spellings of it.JSON.stringifytryand goes through a newwithJsonContentType, which addsContent-Typeonly when no key case-insensitively matches.{ "Content-Type": …, ...headers }left a caller's lowercasecontent-typeas a second object property, andHeadersfolds the two into one comma-joined field — sending both content types.webhookPrivateEntitlementswidens from four positionalunknowns to an options object (base/url/urlCredentialKey/headerCredentialKey/allowPrivate). Either key now enforces thecredentialentitlement, butscopedkeys offurlCredentialKeyonly — a header credential does not hide the destination, so unscoping on it would hand out a wildcardnetwork:privategrant for nothing. All three call sites updated.M4 — no credential seam for auth / signature headers
WebhookNotifyTaskhad onlyurl_credential_key(the whole URL is the secret, the Slack/Discord shape). A bearer or HMAC endpoint therefore had to inline its secret in theheadersport — andTask.toJSONserializesdefaultsverbatim into the saved graph JSON.Adds
credential_key(format: "credential", hidden),credential_scheme(bearer/basic/header/none, default bearer, hidden) andcredential_header(defaultAuthorization, hidden), reusing the existingapplyCredentialToHeaders/credentialHeaderName. Descriptions matchFetchUrlTask's so the two read the same.Order matters: credential placement happens first, then M1's validation inside
postWebhookJson, so the credential-produced header is validated too.credentialHeaderNamethrows aTaskConfigurationErrorhardcoded"FetchUrlTask: …"carrying noFETCH_*code, so it is re-raised as aCONFIGURATIONFetchUrlJobErrorunder aWebhookNotifyTask:label.No credential-port stripping is needed here (unlike
FetchUrlTask, which forwards...restinto a persisted job input) — the post is assembled field by field. There is a comment saying so.WebhookNotifyTaskhas no queued path, soFetchUrlTask's credential-vs-queue refusal has no analogue to port.LOW
!allowPrivate.DiscordNotifyTaskpassesretryAfterFromJsonBody: trueand is reachable withallow_private_destination;retry_afteris read from the body, which a declared private destination never surfaces — contradicting the invariant documented onallowPrivateDestination. TheRetry-Afterheader is transport-level and stays.packages/tasks/src/common.tsre-exports./util/RetryAfterand./util/WebhookPost.MAX_REQUEST_TIMEOUT_MS,redactWebhookUrlIn,postWebhookJson,compactPayloadandretryDateFromRetryAfterHeaderwere all unreachable downstream.One review claim refuted, deliberately not acted on
WebhookPost.tsreadscomplete || surfaced.endsWith("...") ? surfaced : \${surfaced}...`— a complete body short-circuits oncomplete` and is returned unchanged. No change made.Tests
New
describe("request headers")inNotifyTask.test.ts(13 cases): invalid name →PermanentJobError/CONFIGURATION/message contains the name/mockFetchnot called; a control char in a value →CONFIGURATIONwithexpect(message).not.toContain("abc123")on a token-shaped value (this is the assertion guarding the secret echo); URL redacted in the message; a valid header still reaches the transport; a lowercasecontent-typeoverride does not double up;credential_key→Authorization: Bearer <secret>andJSON.stringify(task.toJSON())contains the key and not the secret;credential_scheme: "header"+credential_header; a callerauthorization: "stale"replaced case-insensitively;credential_header: "X Bad"→CONFIGURATIONlabelledWebhookNotifyTask; and the composition test — a credential value carrying a control char is rejected with the secret absent from both.messageandformatErrorChainForDiagnostics.Entitlement coverage extended:
credential_keyalone enforcescredentialbut does not unscope a declared private destination.NotifyTaskTransport.test.tsgets two loopback cases against the real undici transport (the mockedsafeFetchnever constructs aHeadersobject, so it can prove the guard fires but not that the guard is still in front of the thing it guards): an invalid header never reaches the server (request count 0), and a header the guard admits round-trips intact — the half that would actually break if undici tightened its validation.packages/tasks/README.md: the three credential ports, a bullet that a secret goes throughcredential_keyso it never reaches graph JSON, and a bullet that an invalid header is a permanentCONFIGURATIONfailure rather than a retried network error.Verification
(baseline on this branch's merge-base:
1084 passed | 24 skipped— +15 new)There is no root
lintscript;formatiseslint --fix && prettier --check --write, run here in check-only form.npx tsgo -p packages/tasks/tsconfig.jsonreports the same errors before and after these changes (source-modediststubs carry no real.d.ts), so it is unaffected.Generated by Claude Code