fix(tasks)!: require a graded network:private grant to widen past a URL's own classification - #831
Conversation
…RL's own classification BREAKING CHANGE: setting `allow_private_destination` on a destination whose URL does not classify private now requires a registered `ENTITLEMENT_ENFORCER` granting `network:private` for that origin. Loopback / RFC1918 / link-local URLs are unaffected — a declaration on one of those is still authorized by the declaration alone. `19354ebaa` did not newly expose the literal-`169.254.169.254`-plus-flag case: with `allowPrivate = classification.kind === "private"` a statically private URL already received `allowPrivate: true`. What it newly widened is the PUBLIC-LOOKING URL — setting the flag there now disables `SafeFetch.server.ts`'s resolved-address check for a name `classifyUrl` reads as public. That check is the only DNS-rebinding defence in the stack: the scope re-check is tautological (`privateResourceScopes` is `urlResourcePattern(url)` of the same URL) and `redirect: "error"` means there is no second hop. Meanwhile `assertPrivateDestinationGranted` returned immediately when the registry carried no `ENTITLEMENT_ENFORCER`, and that token has no default registration — `TaskGraphRunner` is the only registrar and is guarded by `enforceEntitlements`, which defaults to false — while `TaskRunner.registry` defaults to `globalServiceRegistry`. So the documented guarantee that "every widened request is entitlement-checked" was false for the common case. The rule now enforced: a declaration may widen the transport only as far as the URL itself already declares. A private-reading URL is visible in the operator's configured value, so the flag on it authorizes nothing reading that configuration would not already show. A public-reading one is invisible in configuration AND buys the DNS-guard bypass, so it must be graded by a policy — and with no enforcer to grade it, the post is refused with `PRIVATE_DENIED` naming both remedies. Failing closed on EVERY declaration was rejected: with no default enforcer it would make the flag inert for approximately every current user, remove the stock "post to my internal webhook" case, and contradict `FetchUrlTask`'s shipped treatment of a declared private URL. Documenting the gap alone was rejected too: the DNS bypass on a public-looking hostname is genuinely new. `assertPrivateDestinationGranted` now returns whether a policy actually graded the destination (`"granted" | "unenforced"`) rather than `void`, so the caller can tell "allowed by policy" from "no policy exists". Widening the return type is source-compatible for external callers. Residual, unfixed here: with no enforcer registered the flag on an already-private URL is authorized by configuration alone, and if that URL arrives by dataflow the operator authorized nothing. The right shape is a config-only port, which is a breaking schema change affecting saved graph JSON and the builder UI's port rendering; tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
Coverage Report
File CoverageNo changed files found. |
|
CI status: every test job is green (7/7 suites, plus The failure is:
The fix belongs in #744, not here — one line, either consume Happy to re-run once the base is green. Generated by Claude Code |
What this changes
allow_private_destinationmay now widen the transport only as far as the URLitself already declares:
widened by the declaration alone — unchanged, and what
FetchUrlTaskalreadydoes for a declared private URL;
network:privategrant. Withno
ENTITLEMENT_ENFORCERregistered there is nothing able to grade it, so thepost is refused with
PRIVATE_DENIED.assertPrivateDestinationGrantedreturns its verdict(
PrivateDestinationAuthorization = "granted" | "unenforced") instead ofPromise<void>, so the caller can tell "a policy graded this and allowed it"apart from "no policy exists". Widening
Promise<void>→Promise<"granted" | "unenforced">is source-compatible for external callers.The correction that drives the fix
The literal-
169.254.169.254-plus-flag case was already reachable before19354ebaa: withallowPrivate = classification.kind === "private"astatically-private URL got
allowPrivate: truetoo. So that is not whatregressed.
What
19354ebaanewly widened is the public-looking URL. Setting theflag on
https://hooks.mycorp.com/…now disablesSafeFetch.server.ts:161—the resolved-address check — for a name
classifyUrlreads as public. Thatcheck is the only DNS-rebinding defence in the stack: the scope re-check is
tautological (
privateResourceScopesisurlResourcePattern(url)of the sameURL), and
redirect: "error"means there is no second hop to catch it later.Meanwhile
assertPrivateDestinationGrantedreturns immediately when theregistry carries no
ENTITLEMENT_ENFORCER— and that token has no defaultregistration (
TaskGraphRunner.ts:896is the only one, guarded byenforceEntitlements, which defaults tofalse), whileTaskRunner.registrydefaults to
globalServiceRegistry. So the README's "so every widened requestis entitlement-checked" (three occurrences) was false for the common case.
Why scoped fail-closed, and not the alternatives
Blanket fail-closed was rejected. There is no default enforcer and
enforceEntitlementsdefaults tofalse, so refusing every declaration wouldmake the flag inert for approximately every current user, remove the stock "post
to my internal Grafana webhook" case, contradict
FetchUrlTask's shippedtreatment of a declared private URL, and rewrite ~13 tests plus all the
real-transport tests (which post to
127.0.0.1throughslackNotify(input, config), a path with no way to inject a registry).Doc-only was rejected. The DNS bypass on a public-looking hostname is
genuinely new, and nothing else in the stack catches it.
The rule that separates them: a private-reading URL is visible in the
operator's configured value, so the flag on it authorizes nothing a reader of
that configuration could not already see. A public-reading URL is the opposite
on both counts — the widening is invisible in configuration AND it buys the
DNS-guard bypass — so it has to be graded by a policy.
Migration
BREAKING: setting
allow_private_destinationon a destination whose URLdoes not classify private now requires a registered
ENTITLEMENT_ENFORCERgranting
network:privatefor that origin. Loopback / RFC1918 / link-local URLsare unaffected.
The error names both remedies (register an enforcer with the grant, or clear the
flag for a genuinely public destination).
19354ebaaalready carries aBREAKING CHANGE:trailer; this commit carries its own. Thepackages/tasksCHANGELOG appears to be generated from commit trailers rather than hand-edited
(
19354ebaatouched no CHANGELOG), so the note lives in the commit message.Risk to watch: a workflow that harmlessly sets the flag on a genuinely
public webhook, with no enforcer registered, now fails where it previously
posted. That is deliberate — the flag was doing something real there, just not
something anything graded — but it is worth watching for in the downstream repos
(
sec,embarc-data) viabun run dev-link.Residual, stated honestly
With no enforcer registered, the flag on a URL that already reads private is
authorized by configuration alone. If that URL arrives by dataflow rather
than from the instance's own configuration, the operator authorized nothing —
the declaration travels the same trust channel as
url, and is computed fromrunInputDatabefore run-input lands.Filed as a follow-up rather than fixed here, because the right shape is
config-only (constructor
defaults/x-ui-hidden, mirroringcredential_scheme) and that is a breaking schema change affecting saved graphJSON, the builder UI's port rendering, and
Task.setInputmerge semantics:#830
Tests
graph-root entitlement enforcement:a public-looking declared private destination is refused when no enforcer can grade it(the gate; verified failing against the pre-fix source)
a statically private declared destination still posts with no enforcer registered(pins that this is scoped, not blanket)
a grant makes the public-looking destination reachable again(pins that
19354ebaa's split-horizon feature survives)webhookNotify(...)tonew WebhookNotifyTask().run(input, { registry: grantingRegistry([...]) })— registry only, assertions unchanged.
grantingRegistry(resources)helper; the graph-root suite'sthree ad-hoc copies now use it.
no registered enforcer leaves a declared private post working(still passes —
127.0.0.1is statically private) and theNotifyTaskTransport.test.tsheader. All real-transport tests pass unchanged(8 in that file, including the
getSafeFetchImpl().name === "serverSafeFetch"tripwire).
Verification:
NotifyTask.test.ts,NotifyTaskTransport.test.ts,FetchUrlSsrf.test.tsandSafeFetchServerTransport.test.ts— 4 files, 270tests, all passing.
bun run formatclean.Out of scope, explicitly
allow_private_destinationconfig-only (the follow-up above).ENTITLEMENT_ENFORCER, or changingenforceEntitlements' default.FetchUrlTask'scredential_keytitle.SafeFetch.server.ts, including the tautologicalprivateResourceScopesre-check.🤖 Generated with Claude Code
https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
Generated by Claude Code