fix(tasks): redact webhook query values and the reason phrase - #747
Merged
sroussey merged 1 commit intoAug 13, 2026
Merged
Conversation
The webhook URL is the credential, and redactWebhookUrlIn is what keeps it out of a diagnostic that quotes an endpoint's reply. Two shapes escaped it. Query values were never candidates. Only `pathname+search`, `search` and the individual path segments were admitted, so a `?token=SUPERSECRET1` endpoint that answers `bad token SUPERSECRET1` matched nothing — the whole `?token=…` pair was a candidate but the echo quotes the value alone. `?token=` auth is an ordinary deployment shape, not a corner case. Query values are now admitted in both raw and decoded form; the raw query is split by hand rather than read from searchParams, whose decoder turns `+` into a space and so would not match the bytes an endpoint echoes. All-lowercase candidates were exempted outright, on the theory that such a run is a word rather than a token. A lowercase token is still a token. The exemption existed to protect `services` and `webhooks`, the routing segments of the two supported providers' paths, which are now named explicitly instead. Accepted cost, stated in the JSDoc and pinned by a test: a generic webhook whose path carries a long lowercase word has that word redacted from echoed diagnostics. Also folds in `response.statusText`, which was interpolated into the message and stored as `httpStatusText` with no redaction at all, and unlike the body was not withheld for a private destination — leaving the SSRF read open through a narrower channel, since a server can put anything in a reason phrase. The length floor is unchanged and now applies to query values too, so a short `?t=abc` still leaks. Same policy as segments, said out loud rather than silently special-cased. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
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.
Based on #744 (retarget to
mainonce that merges). Independent of #745 — both touchpackages/tasks, different files.What
For the notification tasks the webhook URL is the credential, and
redactWebhookUrlInis the control that keeps it out of a diagnostic quoting the endpoint's reply. It reaches the caller becauseSlackNotifyTaskandDiscordNotifyTaskboth setincludeBodyInError: true. Two shapes walked straight through it.Query values were never candidates. Candidates came only from
pathname+search,search, and the individual path segments. An endpoint authenticating with?token=SUPERSECRET1that answersbad token SUPERSECRET1matched nothing: the whole?token=SUPERSECRET1pair was admitted, but the echo quotes the value on its own.?token=auth is an ordinary deployment shape.All-lowercase candidates were exempted outright, on the theory that an all-lowercase run is a word rather than a token. So
/hooks/supersecrettokenechoed back asrejected: supersecrettokenwent out verbatim, despite being 16 characters and clearly a secret.Rider, same function:
response.statusTextwas interpolated into the message unconditionally and stored ashttpStatusText, with no redaction pass over it — and, unlike the failure body, it was not withheld for a private destination. A server puts whatever it likes in a reason phrase, so that left the SSRF read the body suppression exists to prevent open through a narrower channel.Why this fix
Admission is now by length only. The
services/webhooksexemption the lowercase rule was really protecting is expressed directly, as a named set of the supported providers' routing segments — which is what it always meant, stated in terms that do not also exempt every lowercase secret.Query values are admitted in both raw and decoded form, and the raw query is split by hand rather than read from
searchParams: that decoder turns+into a space, so a decoded value would not match the bytes an endpoint echoes back. A malformed escape falls back to the raw form, which is what appears in an echo anyway.The 8-char floor (
SECURITY_LIMITS.webhookMinRedactableSegmentChars) is unchanged and now applies to query values too, so a short?t=abcstill leaks. That is the same policy segments already had; the JSDoc now says so rather than leaving it to be rediscovered.Not folded in: the
REDIRECT_NOT_FOLLOWEDdiagnostic (same file, different function — it rebuilds the transport's refusal asINVALID_URL). It requires flipping three baked-in assertions and is a diagnostics-quality issue, not a leak. Deliberately deferred.Tests
Six cases, all through the tasks —
redactWebhookUrlInis not on the public surface (WebhookPost.tsis not re-exported fromcommon.ts), andWebhookNotifyTasksetsincludeBodyInError: false, so Slack/Discord are the drivers.?token=SUPERSECRET1+ 403 bodybad token SUPERSECRET1→ absent from.messageand.stack/hooks/supersecrettoken+ 403 bodyrejected: supersecrettoken→ absent/notifications/deploy+unknown notifications route→notificationsis redacted (the accepted cost, pinned)token SECRETTOKEN bad→ absent from message andhttpStatusTextindex=cluster-secrets shard=3→ message has400, notcluster-secrets;httpStatusTextundefinedinvalid webhooks payload→webhookssurvivesThe last is the pre-existing over-redaction guard; its comment previously justified the exemption as "all-lowercase runs are words, not tokens" and now names the real reason (a provider routing segment), pinning the replacement for the deleted rule.
Actually executed:
WebhookPost.ts(reverted, tests kept): 5 failed / 89 passed — exactly the five new cases.NotifyTask+NotifyTaskTransport+FetchUrlSsrf→ 173 passed.packages/test/src/test/task/directory: 67 files, 1055 passed, 24 skipped.prettier --checkclean.No pre-existing test asserted on
statusText; the private-destination cases asserttoContain("400")andhttpStatus, and the public paths use"Not Found"/"Bad Request", which survive redaction unchanged. Confirmed by the full-directory run.Risk / blast radius
Two behaviour changes a reviewer has to accept:
/notifications/deploy) now has that word redacted out of echoed diagnostics. This is the stated cost of dropping the exemption; there is no signal separating such a word from a lowercase token. Pinned by its own test so it cannot surprise anyone later.httpStatusTextis nowundefinedfor a private destination, where it previously carried the reason phrase. Consumers reading that field for private posts see a missing value rather than a string.Confined to
redactWebhookUrlInand the failure-throw inpostWebhookJson. Both are shared by all three notification tasks;WebhookNotifyTasknever surfaces bodies, so only itshttpStatusTextbehaviour changes.Unverified
httpStatusTextbeing present for a private destination was not audited outside this repo.tsc -p packages/taskscannot run cleanly in this checkout (unbuilt.d.tsunderuse-sourcemode); no type check against real build output was possible.Generated by Claude Code