Skip to content

fix(tasks): enforce the network:private grant at execute time for webhook notify tasks - #736

Merged
sroussey merged 2 commits into
claude/libs-issues-triage-prs-mh6x2o-241from
claude/optimistic-goldberg-gzrguj-notify-ssrf
Aug 10, 2026
Merged

fix(tasks): enforce the network:private grant at execute time for webhook notify tasks#736
sroussey merged 2 commits into
claude/libs-issues-triage-prs-mh6x2o-241from
claude/optimistic-goldberg-gzrguj-notify-ssrf

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Stacks onto #678 — base is claude/libs-issues-triage-prs-mh6x2o-241, not main. Review after that PR.

The SSRF

allow_private_destination is an input, and a graph root task's run-input is applied by TaskRunner.run() — strictly after TaskGraphRunner has already called activeEnforcer.checkTask(task) on task.entitlements(). The declaration the enforcer graded is therefore not necessarily the one execute() receives.

Concretely, under an enforcer that denies network:private (the browser profile grants network:http, credential and friends but deliberately not network:private):

const graph = new TaskGraph();
graph.addTask(new WebhookNotifyTask({ id: "notify", defaults: { payload: { ping: true } } }));

await new TaskGraphRunner(graph).runGraph(
  { url: "http://169.254.169.254/latest/meta-data/", allow_private_destination: true },
  { registry, enforceEntitlements: true }
);

...resolved successfully and posted to the cloud metadata endpoint. The task instance's allow_private_destination was still the schema default false when the enforcer graded it, so no network:private requirement was ever declared, and by the time execute() ran the flag was true and postWebhookJson had nothing left to consult. The same window is what makes a credential-resolved URL ungradeable at declaration time — the enforcer runs before the credential resolver, so the URL does not exist yet.

Reproduced first as a failing test: before the source change the metadata endpoint was posted to (mockFetch called once) and the run resolved.

The fix

postWebhookJson now re-checks the network:private grant at execute time, against the URL actually resolved, using the registry the task is executing under (context.registry, threaded through by all three notify tasks). New exported helper assertPrivateDestinationGranted in packages/tasks/src/util/WebhookPost.ts; it throws PRIVATE_DENIED with the origin only (never the path — the webhook URL is the credential).

  • No enforcer registered → no policy to satisfy → behaviour unchanged.
  • Public destination → never checked.
  • Private destination → must hold the grant, scoped to the destination's own origin (a broad grant still covers it via grantCoversResources).

Secondary, defence in depth: webhookPrivateEntitlements now opts out of declaring network:private only on an explicit false (was: only declared on an explicit true). The branch is unreachable while the three input schemas keep default: false — which is retained deliberately, since removing it would make every ordinary Slack/Discord/webhook post demand network:private — but it means a later removal of that default fails closed rather than open. The four existing private-destination entitlements tests that assign runInputData wholesale now pass allow_private_destination: false explicitly; their assertions are unchanged, and a new test pins that an ordinary instance carrying the schema default still needs no grant.

Also, the MEDIUM follow-up (test-only): the allowPrivate / privateResourceScopes arguments handed to safeFetch are now pinned directly, including the DNS-rebinding invariant that allow_private_destination never widens the transport for a public hostname.

Behavioural caveats worth flagging

  1. A registered-but-unused enforcer now gates private posts. An application that registers ENTITLEMENT_ENFORCER but runs with enforceEntitlements: false will see its private webhook posts checked where previously nothing was checked. IExecuteContext exposes no "enforcement is active this run" signal, so registry presence is the only available seam; adding such a field is deliberately out of scope here.
  2. An ask policy may be consulted twice. The declaration-time check and this execute-time check are two checkAll/checkTask calls. createPolicyEnforcer consults resolver.lookup before prompting and saves the answer, so the second one is answered from cache rather than re-prompting — but implementations of IEntitlementResolver that do not cache would prompt again.

Files

  • packages/tasks/src/util/WebhookPost.ts — new assertPrivateDestinationGranted, WebhookPostRequest.registry, !== false tightening, doc updates
  • packages/tasks/src/task/{Webhook,Slack,Discord}NotifyTask.ts — one line each, passing context.registry
  • packages/tasks/README.md — three private-destination bullets updated
  • packages/test/src/test/task/NotifyTask.test.ts — regression + companion tests

Tests

  • bun scripts/test.ts task vitest — 73 files, 1235 passed, 24 skipped
  • bun scripts/test.ts graph vitest — 126 files, 1088 passed
  • bun run format, bun run build:types clean

NotifyTask.test.ts goes from 79 to 89 tests. New coverage: run-input smuggling is refused with zero fetches; a granted scope permits the identical run (a gate, not a blanket refusal); a grant scoped elsewhere does not cover the destination; a credential-resolved private URL is checked (the case declaration-time enforcement can never reach); and no registered enforcer leaves a declared private post working.


🤖 Generated with Claude Code

https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS


Generated by Claude Code

claude added 2 commits August 10, 2026 08:52
… a private destination

`allow_private_destination` is an input, and a graph ROOT task's run-input is
applied by `TaskRunner.run()` — strictly after `TaskGraphRunner` has already
graded `task.entitlements()`. So a run-input carrying
`{ url: "http://169.254.169.254/...", allow_private_destination: true }` reached
`execute()` with a declaration the enforcer never saw, and the post went out
under a policy that denies `network:private`. The same window is what makes a
credential-resolved URL ungradeable at declaration time: the enforcer runs
before the credential resolver.

`postWebhookJson` now re-checks the `network:private` grant at execute time
against the URL actually resolved, via the registry the task is executing under
(`context.registry`). No enforcer registered means no policy to satisfy and the
post proceeds unchanged; a public destination is never checked.

Also tightens `webhookPrivateEntitlements` so only an explicit `false` opts out
of declaring `network:private`. The branch is unreachable while the input
schemas keep `default: false` (which is retained — every ordinary Slack/Discord
notification would otherwise demand the grant), but it means a later removal of
that default fails closed rather than open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
…anded to safeFetch

The transport enforces against these two arguments, so the outcome assertions
elsewhere only imply them. Pins that a public destination is fetched with
`allowPrivate: false` and no scopes, that a declared private one is scoped to
its own origin, and — the DNS-rebinding invariant — that
`allow_private_destination` never widens the transport for a public hostname.
All three notify tasks are covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 63.39% 29617 / 46718
🔵 Statements 63.26% 30696 / 48521
🔵 Functions 63.65% 5624 / 8835
🔵 Branches 52.74% 14860 / 28172
File CoverageNo changed files found.
Generated in workflow #2967 for commit 3736a3b by the Vitest Coverage Report Action

@sroussey
sroussey merged commit 46309a2 into claude/libs-issues-triage-prs-mh6x2o-241 Aug 10, 2026
10 checks passed
@sroussey
sroussey deleted the claude/optimistic-goldberg-gzrguj-notify-ssrf branch August 10, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants