fix(tasks): stop notify tasks leaking webhook tokens and mass-pinging - #714
Merged
sroussey merged 1 commit intoAug 7, 2026
Conversation
Two HIGH findings in the webhook/Slack/Discord notification tasks, plus
five hardening fixes.
Credential leak via `error.stack`. `BaseError` never overrides `stack`, so
V8 bakes the original message into it. `toRedactedWebhookError` rewrote
`.message` and `.url` but then copied `.stack` verbatim, re-importing the
full webhook URL — and stacks are persisted (`formatErrorChainForDiagnostics`
walks the cause chain into the stored job error), so "logs are trusted" was
never an available defence. `redactedStackFrom` now rebuilds the stack from
the rewritten header plus the original frames (split at the first ` at `
frame, not the first line, since a message may contain newlines) and runs
the whole thing through a second redaction pass. It fails closed to a
header-only stack on runtimes with no ` at ` frames. `error.cause` is
deliberately not copied.
Mention injection. `content`/`text` was forwarded verbatim with no mention
controls, and `additionalProperties: false` meant a caller could not supply
them either — so piping a fetch result or a model summary into a
notification pinged a whole server on every run. Discord now defaults to
`allowed_mentions: { parse: [] }`. Slack has no equivalent, so the literal
`<!` is escaped to `<!` (defusing `<!channel>`, `<!here>`, `<!everyone>`,
`<!subteam^ID>` while preserving `<https://…|label>` links and `<@u123>`
mentions) and `link_names: false` is sent explicitly; `blocks` is
caller-authored and is not rewritten. Both tasks gain an opt-in
`allow_mentions` port.
Also:
- WebhookNotifyTask no longer echoes a private destination's response body:
reachability parity with FetchUrlTask is kept, but the `response` port
was a working SSRF read primitive against e.g. 169.254.169.254.
- Response bodies stream with a 1MB ceiling (`SECURITY_LIMITS
.webhookMaxResponseBodyBytes`) instead of buffering unbounded via
`response.text()` — the failure path buffered unconditionally.
- A fetch rejection named `AbortError`/`TimeoutError` is classified as an
abort (or a timeout) instead of falling through to a retryable
`FETCH_NETWORK_ERROR`, so a cancelled workflow no longer looks transient.
- Slack/Discord gain `timeout` ports and all three default to 30s, so an
endpoint that completes the handshake and never answers cannot hold a slot
forever.
- A resolved webhook credential that is not an absolute http(s) URL fails
with a configuration error naming the likely mistake, never echoing the
value.
- A configured `url_credential_key` upgrades the `credential` entitlement
from `optional: true` (which `evaluatePolicy` skips outright) to enforced.
- `success` output descriptions now say "Always true; a non-2xx response
throws"; `url` descriptions note the value is stored in the graph JSON.
- README: 429/503 raise `RetryableJobError` but nothing retries them — these
tasks run inline and task-graph has no retry consumer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report
File CoverageNo changed files found. |
sroussey
merged commit Aug 7, 2026
b4707d9
into
claude/libs-issues-triage-prs-mh6x2o-241
10 checks passed
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.
Security follow-up on the webhook / Slack / Discord notification tasks added in the base branch. Two HIGH findings plus five hardening fixes.
(Angle brackets below are written as HTML entities — an earlier revision of this description had them eaten by the API.)
HIGH — credential disclosure via
error.stackBaseErrorcallsError.captureStackTraceand never overridesstack, so V8 materializes it asname: messagefollowed by newline-separatedat …frames — the message is baked into the string.SafeFetch/SafeFetch.serverinterpolate the raw URL into seven message templates, andtoRedactedWebhookErrorrewrote.messageand.urlbut then didrebuilt.stack = error.stack, re-importing the very URL the surrounding function had just stripped.Concrete scenario: a Slack webhook whose host resolves into RFC1918 space throws
PRIVATE_DENIED..messagecorrectly readshttps://hooks.slack.com, but.stackline 1 contains.../services/T00/B00/SECRETTOKEN. This is not a "logs are trusted" problem —formatErrorChainForDiagnosticswalks the error and its.causechain and pushes every.message+.stackinto a persisted job-error string.Fix —
redactedStackFrom(original, rebuilt, url)inWebhookPost.ts:/^\s+at /(the header can span several lines when a message contains newlines, so the split point is the first frame, not the first line);name: message;redactWebhookUrlInas a second pass, covering frames that embed the URL independently;atframes — header only, never the original string.error.causeis deliberately not copied onto the rebuilt error, with a comment at the rebuild site so it is not "helpfully" added later.Why here and not in
SafeFetch.server.ts: pushing redaction lower means editing message templates shared withFetchUrlTask, where the URL is not a secret and is the most useful diagnostic there is — and it would still miss undici/DNS errors that carry the hostname independently.WebhookPost.tsis the only layer that knows "this URL is the credential", and a comment now says so.HIGH — mention injection / mass-notification amplifier
content/textwas forwarded verbatim with no mention controls. Because both input schemas areadditionalProperties: falseandTask.setInputonly copies declared properties, a caller could not supplyallowed_mentionseven knowing they should.Concrete scenario: a workflow pipes
FetchUrlTaskoutput or an LLM summary intocontent. Any@everyonein that text pings the whole server on every run, and a retry loop makes it an unmutable mass-notification amplifier.allowed_mentions: { parse: [] }, which suppresses@everyone/@here, roles and users.allowed_mentions, andlink_namesonly governs auto-linking of bare@nametext, so it is insufficient. Slack's documented control is HTML-entity escaping. This uses the narrow form: the literal two-character sequence <! is replaced with <!, which kills all four broadcast forms (<!channel>, <!here>, <!everyone>, <!subteam^ID>) while preserving <https://…|label> links and <@u123> single-user mentions that full escaping would break.link_names: falseis also sent explicitly.blocksis a caller-authored structure rather than a piped string and is not rewritten — documented as a limitation in the README.allow_mentionsboolean (defaultfalse). Whentrue, Discord omitsallowed_mentionsentirely and Slack sendstextunescaped with nolink_names.Decision 3 — no response echo for private destinations
Reachability parity with
FetchUrlTaskis deliberate prior art and is kept. What is removed is the read primitive theresponseoutput port created: a POST tohttp://169.254.169.254/latest/meta-data/iam/security-credentials/returned up to 1KB of the reply straight back into the graph.WebhookNotifyTask.executenow classifies the resolved URL and, for a private classification, passesreadSuccessBody: falseand returns an emptyresponse. That costs nothing for a notification. Documented in the README Features list.Other hardening in this PR
readBodyTextcalledresponse.text()with no ceiling; the truncation constants applied only after the whole body was in memory, so a 500 with a multi-GB body OOMed the runner — and the failure path buffers unconditionally for all three tasks. It now streamsresponse.bodyviagetReader(), decodes with a streamingTextDecoder, and cancels the reader pastSECURITY_LIMITS.webhookMaxResponseBodyBytes(1MB, new).fetchrejects an aborted/timed-out request with aDOMException, which previously fell through to a retryableFETCH_NETWORK_ERROR— so cancelling a workflow looked like a transient failure.AbortErrornow yieldscreateFetchUrlAbortedError();TimeoutErroryields an abort error when the caller's own signal is aborted and aNETWORK_ERRORtimeout otherwise.timeout: undefinedand exposed no port, andWebhookNotifyTask.timeouthad no default, so an endpoint that completed the handshake and never answered hung the task and held a slot forever. All three now default to 30000 ms and Slack/Discord gaintimeoutports.url_credential_keythat is not an absolutehttp(s)URL now fails withFETCH_CONFIGURATIONnaming the likely mistake ("…use FetchUrlTask instead") and never echoing the value. Previously a bearer token wired into that port failed with a confusing runtimeINVALID_URL.credentialentitlement was declaredoptional: true, whichevaluatePolicyskips outright — decorative. It is now upgraded to enforced bywebhookPrivateEntitlementswheneverurl_credential_keyis set. Checked for grant sets inpackages/test: these three tasks have no consumers outsidepackages/tasks/srcand their own test file, so nothing that only grantsnetwork:httpbreaks.successis now documented as "Always true; a non-2xx response throws" (no non-throwing mode added).urlport descriptions no longer imply the value is safe to set inline — they say it is stored verbatim in the graph JSON and point aturl_credential_key. Nox-ui-hiddenonurl; it is the primary field in the builder.Intentional behavior changes worth a reviewer's attention
@channelwill stop doing so unlessallow_mentions: trueis set. This changes the wire payload (text, plus a newlink_names: false), which existing payload-equality tests were updated for.AbortSignal.timeoutis armed on every request, not only when atimeoutwas supplied.allowed_mentionsunless opted out.README corrections
Retry-Afterretry date" was wrong on the second half: these tasks run inline andtask-graphhas no retry consumer (grep -rE 'RetryableJobError|retryDate' packages/task-graph/srcreturns nothing). Replaced with "…retries require a@workglow/job-queueconsumer, which these inline tasks do not have". Same correction applied to the Discordretry_afterbullet — the value is parsed onto the error, not acted on.error.url,error.stack, or task output" (true only after the stack fix above).allow_mentionsand the default neutering (notingblocksare not rewritten), the timeout defaults, the 1MB cap, and the private-destination no-echo rule.Tests
packages/test/src/test/task/NotifyTask.test.tsgrows from 26 to 45 tests, covering: the redacted.stack(incl.formatErrorChainForDiagnosticsand an undefinederror.cause), Discord/Slack default neutering and theallow_mentionsopt-out, link and single-user-mention survival, the armed default timeout and a real short one, caller-abort classification, the 1MB cap on both the success and failure paths (with stream-cancellation assertions), the private-destination no-echo, and the bearer-token configuration error.The pre-existing "204 without parsing a body" test's
response.text()spy would have gone vacuous undergetReader(); a sibling test now asserts, on a Slack 200 that does have a body, that neithertext()norgetReader()is called, thatbody.cancel()is, and that a double-cancel does not throw.Verified / not verified
Verified (commands run, output seen):
bun install— clean.bunx vitest run packages/test/src/test/task/NotifyTask.test.ts— 45 passed.bun test(JavaScriptCore) — 45 passed. The stack rebuild is heuristic across engines, so it was run under both; Bun emitsatframes, so it keeps them rather than degrading, and the fail-closed branch is the documented behavior for engines that do not.git stashofpackages/tasks/src+packages/util/src, re-run): 12 failures plus a worker crash on the uncapped-body test, which is the OOM the cap now prevents.bunx vitest run packages/test/src/test/task packages/test/src/test/task-graph— 2264 passed, 1 failed:OwnTask.test.ts→ "owns and disowns a pipe function" (Class extends value undefinedinConversions.ts), confirmed pre-existing on the base branch with all changes stashed.bun run build:types— 41/41 packages successful.bunx eslint+prettieron every changed file — clean.Adapted from the plan (and why):
AbortSignal.timeout(Node implements it on an internal timer, not the patched global) — probed and confirmedaborted === falseafter advancing 5 s past a 1 s signal. The default is therefore asserted by spying onAbortSignal.timeout(called with30000for all three tasks) and the behavior by a realtimeout: 50.&lt;!channel&gt;, which is inconsistent with the narrow <! transform the same plan prescribes (escaping the closing bracket too is what breaks links). The test asserts the half-escaped form is present and the raw broadcast absent — the broadcast is defused either way.AbortErrorwhile the caller's signal is not aborted, which its own "NETWORK_ERROR whenrequest.signalwas not aborted" rule would classify as retryable. Resolved by making the DOMException name the primary discriminator (AbortError= caller abort,TimeoutError= timeout) withrequest.signal.abortedas the refinement — which is what those names actually mean.getGlobalCredentialStore().put(...)) rather than as a literal port value: the credential resolver returnsundefinedfor an unknown key and never echoes the key back, so a raw string would have hit the "no webhook URL provided" path instead of the new one.Not verified: nothing in this PR was left unrun. The one failing test in the wider sweep is pre-existing and unrelated.
🤖 Generated with Claude Code