fix(tasks)!: let allow_private_destination govern the transport it declares - #770
Merged
sroussey merged 1 commit intoAug 14, 2026
Conversation
…clares BREAKING CHANGE: `allow_private_destination` now widens the transport for any destination it is set on, and any such destination's reply body and reason phrase are withheld. `allowPrivate` was derived from `classifyUrl`, which is string-only by construction and never resolves DNS. A hostname that is no literal IP and matches no reserved suffix classifies public, so the flag was inert for exactly the case it names: under split-horizon DNS a `hooks.mycorp.com` resolving to 10.1.2.3 was refused at connect time with a message asking for the `network:private` grant the operator already held, and no configuration short of hard-coding the address made the post work. The declaration now governs the transport and the GRANT still authorizes it: `assertPrivateDestinationGranted` runs for every declared private destination rather than only for a URL that reads private, so the set of requests receiving the widened transport is a superset of the set that is entitlement-checked — where previously the public-looking case was checked at all. The resource pattern is computed once and used both for the grant check and for `privateResourceScopes`, so the pattern graded and the scope enforced cannot diverge. Redirects stay refused, so a `Location` cannot pivot off the granted origin. Body, failure-body and reason-phrase suppression move to the declaration for the same reason: the URL alone cannot say whether the host was internal, so a caller who declared it may be private never gets its reply back. `WebhookNotifyTask` no longer classifies the URL a second time — that duplicate was the drift — and passes `readSuccessBody: true` as the ceiling it is, leaving `postWebhookJson` the single decider. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XKvKnyVeQQQCm6FhtaLyMa
Base automatically changed from
claude/optimistic-goldberg-hxoj5s-notify-timeout
to
claude/notify-merge-main
August 14, 2026 15:35
Coverage Report
File CoverageNo changed files found. |
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.
Problem
postWebhookJsonderivedallowPrivatefromclassifyUrl(url).kind === "private".classifyUrlis string-only by construction and never resolves DNS, so a hostname that is not a literal IP and matches neitherPRIVATE_EXACT_HOSTSnorPRIVATE_DOMAIN_SUFFIXESclassifies public — andallowPrivate: falsewas sent even when the caller setallow_private_destination: trueand held thenetwork:privategrant.Split-horizon DNS is the concrete failure.
webhookNotify({url: "https://hooks.mycorp.com/hook", allow_private_destination: true})where the name resolves to10.1.2.3, withnetwork:privategranted:fetchOneHop's DNS loop throwsPRIVATE_DENIED— "resolved to private address 10.1.2.3 … Grant the 'network:private' entitlement to allow this request." The operator has granted it, the flag is set, and no configuration makes the post work short of hard-coding the IP, which defeats their DNS and pins the request to an address that may move.The code also contradicted its own JSDoc, which said the declaration exists precisely so
allowPrivateis not "self-derived from whatever the URL happened to resolve to".Why widen rather than document the limit
The old behavior is not a conservative policy, it is an unreachable one. The flag's effect was fully determined by the URL text: for a literal private URL it was redundant with the classifier, and for a public-looking name it was inert. There is no input for which it added a security decision — it only added a message asking for a grant the operator already held.
The relaxation stays gated by the grant, not by the flag. What moves is the authorization trigger: from "the URL string looks private" to "the caller declared a private destination", which is a superset. Every request receiving the widened transport is now entitlement-checked, where the public-looking case previously received neither the transport nor a check.
The secrecy posture only tightens — see the second breaking change below.
Breaking changes
allow_private_destination: trueon a genuinely public endpoint now requires thenetwork:privategrant to run at all, and widens the transport. Fail-closed, and the flag's name says what it does.Also worth stating: with no enforcer registered
assertPrivateDestinationGrantedreturns immediately, so in an unpoliced host the flag alone widens the transport — unchanged from today's behavior for literal private URLs.What still enforces authorization
assertPrivateDestinationGrantedruns wheneverallowPrivateDestinationis set, not only when the URL classifies private. It checksnetwork:privateagainst the URL actually resolved — i.e. after credential resolution — so an origin-scoped policy grant still binds and a credential-supplied destination cannot slip past.urlResourcePattern(url)is computed once into a local used both for that grant check and forprivateResourceScopes, so the pattern the enforcer graded and the scope the transport re-enforces cannot diverge.redirect: "error"is unchanged: exactly one hop, so aLocationcannot pivot off the granted origin.webhookPrivateEntitlementsis unchanged — an unset flag still fails closed and still declaresnetwork:private.Change
postWebhookJson:allowPrivate = request.allowPrivateDestination; the static classification is kept only for the "private URL, flag not set" refusal. Body / failure-body / reason-phrase suppression followallowPrivate.WebhookNotifyTask: the duplicateclassifyUrl(url).kind === "private"is deleted (that duplicate was the drift), passingreadSuccessBody: trueas the ceiling it is and leavingpostWebhookJsonthe single decider.classifyUrlimport dropped.allow_private_destinationin all three tasks, and the matchingpackages/tasks/README.mdsections, now state that the flag covers a public-looking hostname resolving into private space and that the reply is never surfaced.Tests
Five new cases in
NotifyTask.test.ts, all confirmed failing against the pre-fix tree.Replaced
"a public URL keeps allowPrivate false even when allow_private_destination is set"(and its comment) — it pinned the defect as if it were the invariant. Its successor asserts the widening plus the exact scope.The load-bearing one is "a declared private destination is refused without the grant even when the URL looks public", in
describe("graph-root entitlement enforcement")underbrowserRegistry()(grantsnetwork:http+credential, notnetwork:private). It catches the single most dangerous way to get this fix wrong: wideningallowPrivatewhile leaving the authorization check keyed on the static classification, which would hand out private reachability with no check at all. Its counterpart, "a grant scoped to the declared origin permits a public-looking private destination", proves the result is a gate rather than a blanket relaxation.Also: "the widened transport is scoped to exactly the pattern the grant was checked against" (catches the two values being computed independently) and "a declared private destination never echoes its body, even for a public-looking hostname".
Unchanged regression net: "refuses a private destination that was not declared", "a public destination is fetched with allowPrivate false and no scopes", "a credential resolving to a private URL is refused at execute time", and every case in
describe("graph-root entitlement enforcement").Coverage gap
There is no test exercising a real split-horizon resolution — the case that motivated the change.
SafeFetch.server.tscallsnode:dns/promises.lookupdirectly with no injection seam, so a name that classifies public and resolves private cannot be simulated. The tests here pin the arguments handed to the transport and the authorization decision around them; the DNS half of the path is covered only bySafeFetch's own tests against literal addresses. Adding a resolver seam toSafeFetch.server.tswould close it and is left as follow-up.Also out of scope, recorded here
SafeFetch.server.tsappliesprivateResourceScopesonly when the static classification is private, so a redirect-following caller reaching a private address via a public name is unscoped. Unreachable from the notify tasks, which useredirect: "error".Verification
bunx vitest run --project test packages/test/src/test/task/NotifyTask.test.ts— 115 passedbun scripts/test.ts task vitest— 67 files, 1079 passed, 24 skippedbun run build:types— 41/41 packages clean🤖 Generated with Claude Code
Generated by Claude Code