fix(tasks): give the webhook header credential a lifecycle — fail closed on a store miss, redact it out of output - #816
Merged
sroussey merged 3 commits intoAug 16, 2026
Conversation
…annot be resolved `url_credential_key` already refuses to post when the store cannot answer a configured key; `credential_key` failed OPEN on the identical case. `applyCredentialToHeaders` returns the caller's headers unchanged when the resolved credential is `undefined`, and the input resolver yields exactly that on a miss — so a locked store or a mistyped key sent the notification unauthenticated (or unsigned) and reported success. `resolveWebhookCredential` mirrors `resolveWebhookUrl` beside it, and `WebhookNotifyTask` calls it before applying the credential. `Object.hasOwn(input, "credential_key")` is the discriminator: the resolver writes `undefined` over a missed key, so the port is present either way and only its presence separates "no credential wanted" from "credential wanted but the store could not answer". `applyCredentialToHeaders` itself is left fail-open and untouched. It is pure, carries no `configured` signal, and is shared with `FetchUrlTask`, which must not inherit an unasked-for behavior change. The guard also fires under `credential_scheme: "none"`: resolve-but-don't-send is a debug affordance, not a reason to swallow a locked store, since the operator configured a key either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…s and stacks `WebhookNotifyTask` sets `readSuccessBody: true`, and the only redaction on a success body was `redactWebhookUrlIn`, which knows the URL and nothing else. A secret placed on `Authorization` (or a signing header) was therefore never a redaction candidate, so an echoing endpoint — webhook.site, RequestBin, a chatty 200 — returned up to 1 KB of it verbatim into the `response` output port: task output, pipeable and persisted with the run. The module header claimed the URL secret never reaches task output; the second secret shape had no equivalent guard. `createWebhookRedactor(url, secrets)` is now the single function every echoed string goes through — success body, failure body suffix, reason phrase, stringify detail, and every rewritten error message and stack. Order inside it is load-bearing: exact secrets first, longest-first, then the URL pass, because reversed the URL pass can chop a substring out of a secret and leave the remainder unmatched. Each secret is admitted raw and percent-encoded, mirroring the URL pass. There is no minimum length, unlike URL path segments — a header secret is a known exact value rather than a guess, and a mangled diagnostic beats a leaked short API key; the JSDoc states that residual. `secrets` is declared (not optional) on `WebhookPostRequest`, so all three call sites answer the question: `WebhookNotifyTask` passes its resolved credential, Slack and Discord pass `undefined`. It is redacted unconditionally, including under `credential_scheme: "none"` — a value never sent cannot be echoed, so redacting costs nothing and keeps a scheme-dependent branch out of a security path. `redactedStackFrom` and `toRedactedWebhookError` take the redactor instead of the raw url. `redactedStackFrom` is exported from the package root and `toRedactedWebhookError` is module-private; `git grep` finds no consumer of either outside this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
Coverage Report
File CoverageNo changed files found. |
…-webhook-credential-lifecycle The base branch merged origin/main (main's streaming rework of FetchUrlTask, plus the re-applied bounded Retry-After parser). No conflicts here. Verified: bun scripts/test.ts task vitest -> 68 files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
sroussey
deleted the
claude/optimistic-goldberg-d74u5n-webhook-credential-lifecycle
branch
August 16, 2026 18:15
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.
Stacked fix onto PR #744 — this branch targets
claude/notify-merge-main, notmain, so it reviews and merges as a follow-up to that PR rather than duplicating its diff.Two commits, in this order on purpose: the first guarantees a resolved secret exists, the second guarantees it cannot come back.
1. [MEDIUM]
credential_keyfailed OPEN on a store missurl_credential_keyalready refuses to post when the store cannot answer a configured key.credential_keyfailed open on the identical case:applyCredentialToHeadersreturns the caller's headers unchanged whencredentialisundefined, and the input resolver yields exactly that on a miss — so a locked store or a mistyped key silently sent the notification unauthenticated (or unsigned) and reported success.resolveWebhookCredential(credential, credentialConfigured, portName, label)now sits besideresolveWebhookUrland throwsFetchUrlErrorCode.CONFIGURATIONwhen a key is configured but the resolved value is missing or empty. The message names the port and tells the operator to unlock the store or fix the key; it never echoes a value.WebhookNotifyTaskcalls it beforeapplyCredentialToHeaders, discriminating onObject.hasOwn(input, "credential_key")—resolveSchemaInputswritesresolved[key] = undefinedon a miss, so the port is PRESENT either way and only its presence separates "no credential wanted" from "credential wanted but the store could not answer".applyCredentialToHeadersis left fail-open and untouched: it is pure, has noconfiguredsignal, and is shared withFetchUrlTask, which must not inherit an unasked-for behavior change.It fails closed also under
credential_scheme: "none". The operator configured a key; "resolve but don't send" is a debug affordance, not a reason to swallow a locked store.2. [HIGH] The header credential could be echoed straight back into
responseWebhookNotifyTasksetsreadSuccessBody: true, and the only redaction on a success body wasredactWebhookUrlIn(text, url)— which knows the URL and nothing else. The secret newly placed onAuthorization/credential_headerwas never a redaction candidate, so an echoing endpoint (webhook.site, RequestBin, a chatty 200) returned up to 1 KB of it verbatim intoresponse: task output, pipeable and persisted with the run. The module header claimed the URL secret never reaches task output; the second secret shape had no equivalent guard.createWebhookRedactor(url, secrets)returns a(text: string) => stringand is now the single function every echoed string goes through:postWebhookJson), failure body suffix,statusText, and theJSON.stringifyfailure detail;toRedactedWebhookError(both the rebuilt-message and generic-detail paths) andredactedStackFrom, whose signatures now take the redactor instead of the raw url.Order inside the redactor matters: exact secrets FIRST (longest-first), THEN
redactWebhookUrlIn. Reversed, the URL pass can chop a substring out of a secret and leave the remainder unmatched. Each secret is admitted raw plus itsencodeURIComponentvariant, mirroringadmitVariants.No length floor, unlike URL path segments — those are guesses about which part of a URL is a token, whereas a header secret is a known exact value handed in by the caller. Residual, stated in the JSDoc: a very short secret can mangle a diagnostic. That beats leaking a short API key.
readonly secrets: readonly string[] | undefinedis declared, not optional, onWebhookPostRequest— house rule, and it forces all three call sites to answer the question.WebhookNotifyTaskpassesinput.credential_key === undefined ? [] : [input.credential_key](the port holds the RESOLVED secret by execute time);SlackNotifyTaskandDiscordNotifyTaskpassundefined.Redaction runs unconditionally, including under
credential_scheme: "none": a value never sent cannot be echoed, so redacting costs nothing and removes a scheme-dependent branch from a security path.The module header's invariant is restated over values: no value this module was handed as a secret reaches a message, an output port, or a stack.
Signature change, called out
redactedStackFrom(original, rebuilt, redact)andtoRedactedWebhookError(error, url, label, callerSignal, redact)now take the redactor.redactedStackFromis exported from the package root (common.tsre-exports./util/WebhookPost) andtoRedactedWebhookErroris module-private. Verified withgit grepacrosspackages/,providers/andexamples/: zero consumers outsideWebhookPost.ts.Tests
Extended
credential misconfiguration: a configuredcredential_keythe store cannot answer fails closed (PermanentJobError, codeCONFIGURATION, message namescredential_key,mockFetchcalled zero times — this posted unauthenticated and returned success before); a store miss under scheme"none"also fails closed; an unconfiguredcredential_keyleaves plain headers working (regression twin of the existing URL case).New
header credential redactiondescribe: an echoing endpoint cannot return the header credential inresponse; a reason phrase quoting the credential is redacted (new Response("", {status: 400, statusText: "bad token <secret>"})— the error path actually open for this task, sinceincludeBodyInError: falsealready closes the body path; botherror.messageandformatErrorChainForDiagnosticsasserted clean); a transport error quoting the credential is redacted (aTypeErrorwhosecausequotes it; message and.stackasserted); URL redaction still applies alongside a header secret (regression guard against reordering); a secret spanning the truncation boundary leaves no usable prefix (a 4 KB body with the secret at ~1010 chars, straddling the 1024-char cut — pins redact-before-truncate).Verification
bun scripts/test.ts task vitest— 67 files passed, 1116 passed / 24 skipped. All 7 new assertions were confirmed failing on the base branch first, each for the stated reason; the two commits were re-split so the first is green on its own.bun run format— no changes inpackages/tasksorpackages/test(it also reformats pre-existing unrelated files underexamples/andproviders/; those were reverted and are not in this diff).Generated by Claude Code