fix(ocap-kernel): request only the URL the fetch caveat approved - #1026
Merged
Conversation
A vat granted fetch could reach any host regardless of network.allowedHosts. The allowlist and the file: prohibition were checked against one resolution of the vat's input, and then the input itself was handed to fetch, which resolved it a second time. An input answering differently on each read was validated as an allowed host and requested as a forbidden one (CWE-367). Reported as a SEV-1 vat sandbox escape in MetaMask/MetaMask-planning#7557. Add resolveFetchInput to @metamask/kernel-utils, which resolves an input exactly once and returns a stand-in to forward in its place, and route both enforcement points through it: makeCaveatedFetch in ocap-kernel and makeHostRestrictedFetch in kernel-language-model-service. The caveat now receives a URL rather than raw input, so the old shape is no longer expressible. Two further vectors, neither in the report, are closed with it. A Request subclass can override its url getter, so the URL is read through the genuine accessor. And undici on Node 22 keeps a Request's state in a configurable own property, which lets a vat make it answer differently on each read and lets it leave a URL object of its own in there to mutate while the caveat is still awaiting; a Request is therefore copied and then rebuilt around the resolved URL as a string. The copy has to come first, because the rebuild reads its argument as a RequestInit by string name and would otherwise pick up a planted dispatcher. Verified against live HTTP servers on Node 22 and 24: every variant is refused and the forbidden host is never contacted, while GET, POST bodies, Request reuse, stream bodies and abort propagation are unaffected. Each regression test was mutation-checked against the code it guards. Not addressed, and unchanged by this: the caveat sees only the pre-flight URL, so a redirect from an allowed host to a disallowed one is still followed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fetch caveat saw only the URL a vat asked for. `redirect` defaults to 'follow', so an allowed host answering `302 Location: http://169.254.169.254/latest/meta-data/` sent the vat's request to a host outside `network.allowedHosts` and handed it the response body. An allowlist may perfectly well name a host the vat itself controls, so this is a general escape rather than a risk from a compromised third party. Left open by the previous commit, which fixed the TOCTOU escape in the same functions and said so. Add makeGuardedFetch to @metamask/kernel-utils, which follows redirects itself rather than leaving them to fetch, re-running the guard on each hop's URL, and route both enforcement points through it: makeCaveatedFetch in ocap-kernel and makeHostRestrictedFetch in kernel-language-model-service. A hop out of the allowlist fails the call and the forbidden host is never contacted; a hop that stays inside is followed as fetch would have, so the allowlist keeps meaning what it says, and response.url and response.redirected still describe the chain. The redirected flag needs a proxy: the vat fetch endowment hardens the response it returns, so the flag cannot be written onto it. A vat cannot ask to opt out. Only `follow` is overridden — on init and on a Request alike, both of which the vat controls — since it is the one mode that could walk to an unapproved host; `manual` and `error` ask for less and are obeyed. Hops follow the fetch spec's rewrite: a 303, and a 301 or 302 from a POST, becomes a bodyless GET; credentials are dropped when a hop leaves the origin; the chain gives up after 20. Three things fail closed rather than quietly. A hop that keeps the body, when that body cannot be sent a second time, errors instead of hanging or sending a truncated request — stricter than the spec, which replays a stream whose source it kept, and a Request's body is a stream however it was built. A hop to a scheme fetch will not follow is refused by name. And an opaque redirect — what a browser answers a manual redirect with, hiding the target instead of exposing it — fails rather than handing the vat a status-0 husk it would read as the resource it asked for. Closed with it, and not in the report: undici honours a `dispatcher` in `init`, which stands in for the transport and so decides where the bytes go whatever URL the caveat approved. It is refused rather than dropped, since dropping it would fall back to the global transport and egress anyway. Verified against live HTTP servers on Node 22 and 24: 49 checks pass, and 13 of them fail against the pre-fix build with the forbidden server recording the request. All 33 mutations of the new module are caught by its tests. Not addressed: the caveat matches the hostname a vat names, so an allowlisted name resolving to a loopback or link-local address still reaches it; and a refused hop is not logged, so a vat that swallows the error leaves no trace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `dispatcher` rejection read `rawInit.dispatcher` for the check and then
re-read it via `{ ...rawInit }`, so an accessor answering `undefined` once and a
dispatcher thereafter passed the check and still reached the transport — the
same substitution this wrapper exists to stop. Node's global fetch honours a
plain object with a `dispatch` method, and the Snaps endowment spreads `init`
verbatim, so it was reachable from a vat. The check now reads the snapshot, so
the value checked is the value forwarded.
Also from review:
- `redirect: 'error'` returned the 302 rather than throwing when the hop carried
no `Location`. The mode is now answered on the status alone, before the
`Location` is read, as the spec orders it and undici does.
- A `baseFetch` that walked a chain despite `redirect: 'manual'` had its response
returned as the approved resource, silently degrading to the pre-flight-only
checking this branch replaces. Refused now.
- `asRedirected` threw a proxy-invariant `TypeError` for a response carrying
`redirected` as a frozen own value, which is this repo's house style for a
test double. It reports the target's value instead of crashing.
- Two throw paths abandoned a response body without cancelling it, holding its
connection open. Every early exit now goes through `discardBody`.
- The guard is consulted before the replayability check, so a hop out of the
allowlist is reported as that rather than as a body problem.
- The `dispatcher` check is reached through `in`, so it compiles where the
ambient `RequestInit` is the DOM one. It previously failed to typecheck in the
seven DOM-lib packages and for anyone compiling from source.
`FetchGuard` takes the URL alone. It was also handed an `init` describing only
the first hop's caller-supplied fields, so a method- or body-restricting policy
would have been enforced for `{ method: 'POST' }` and skipped for the same
request wrapped in a `Request` — sound only after the first hop, which is the
hop every request makes. Normalizing could not fix it, since `init.redirect` is
unconditionally `manual` by the time a guard sees it. `requestOnce` likewise
takes the resolved `{ url, input }` pair, so passing the caller's own input is a
type error rather than a comment violation.
Four regression tests, each verified to fail against the unfixed source. One
covers the defensive `new Request(input)` copy: deleting it is a live escape
that the previous structural assertion could not see, because the rebuilt
`Request` keeps a dispatcher out of reach whether or not it carried one, so the
property is only observable on the wire.
Test setup deduplicated by about 200 lines with no assertion changes, and
comments trimmed to the non-obvious why. The changelogs now name the
browser-realm limit: checking a hop needs `redirect: 'manual'`, which a browser
answers with an opaque-redirect response, so a browser-hosted vat cannot follow
a redirect at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid
marked this pull request as ready for review
August 19, 2026 12:30
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 461793e. Configure here.
`integrity` was copied onto every hop's init, and every request this wrapper makes asks for `redirect: 'manual'`, so `fetch` held a digest of the resource against each 3xx body it was handed and turned it into a network error. Any chain longer than a single request therefore failed, however right the digest was; verified against undici, which answers a manual redirect with the real response and checks the digest against it. The digest is now withheld from `baseFetch` — as `''`, since only an empty string means "no digest" to `fetch` and an absent init member leaves a `Request`'s own integrity standing — and checked against the body actually handed back, which is where the spec spends it too, after the chain has been walked. Checking it means reading it, so it is read from a `clone`, whose tee leaves the caller's bytes intact. The new `subresource-integrity.ts` reads the metadata and digests the bytes. It is stricter than `fetch` in one respect: metadata naming no algorithm SRI is defined over is refused rather than ignored, because a caller that asked for a digest and had none checked is worse off than one that asked for nothing. Covered against live servers in `kernel-utils` — including the digest of a hop, which must be refused where a digest of the resource is accepted — and end to end in `kernel-test` through the real supervisor and the hardened Snaps `ResponseWrapper`, whose own `clone()` is what the check goes through there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contributor
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
rekmarks
reviewed
Aug 19, 2026
rekmarks
reviewed
Aug 19, 2026
Keep a Changelog has a Security category; a **SECURITY:** prefix under Fixed was standing in for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FUDCo
approved these changes
Aug 19, 2026
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.

Fixes MetaMask/MetaMask-planning#7557.
Problem
A vat granted
fetchcould reach any host regardless of itsnetwork.allowedHosts. Three routes, one shape: the URL the caveat approves is not the URL that gets requested.fetch, which resolved it a second time. An input answering differently on each read was validated as an allowed host and requested as a forbidden one.harden()does not help: it freezes the wrapper, not the vat's argument.redirectdefaults tofollowand the caveat only ever saw the pre-flight URL, so an allowed host answering302 Location: http://169.254.169.254/latest/meta-data/sent the vat's request outside the allowlist and handed the vat the response body. Not merely a risk from a compromised third party: an allowlist may perfectly well name a host the vat itself controls, which makes this a general escape.dispatcherininit, and a dispatcher decides where the bytes go whatever URL the caveat approved. Found while fixing (2); not in the report.Fix
New in
@metamask/kernel-utils:resolveFetchInputresolves an input exactly once and returns the resolved URL plus a stand-in to forward in its place.makeGuardedFetchwraps afetchso a guard runs before every request it makes, following redirects itself, one hop at a time.Both enforcement points route through them —
makeCaveatedFetch/makeHostCaveat(ocap-kernel) andmakeHostRestrictedFetch(kernel-language-model-service) — and are now one-line delegations.The caveat takes a
URLand nothing else. The deletedFetchCaveathanded every policy the raw fetch args and made it re-derive the URL itself, via the now-deletedresolveUrl— the exact CWE-367 read. A guard author now has no raw input to mis-resolve, and no partially-accurateinitto mis-read: the first request'sinitis assembled from the caller's arguments while a hop's comes from the redirect rewrite, so aninit-reading policy would have been sound only after the first hop — the hop every request makes.requestOncelikewise takes the resolved{ url, input }pair rather than a destination and the URL it is claimed to be, so passing the caller's own input is a type error rather than a comment violation.Closed
Requestsubclass overriding itsurlgetterRequeststate in a mutable own property (undici on Node 22)Requestcopied, then rebuilt around the resolved URL as a stringredirect: 'follow'ininitor on aRequestdispatcherininitinit, so an accessor cannot answer the check withundefinedand the transport with a dispatcherdispatcherplanted as an own property of aRequestbaseFetchthat walks a chain itself despiteredirect: 'manual'Semantics
allowedHostsis followed asfetchwould have, sohttp→httpsand/x→/x/keep working.response.urlnames where the chain ended andresponse.redirectedreports the chain. Gettingredirectedright needs a proxy: the vatfetchendowment hardens its response, so the flag cannot be written onto it. The proxy is not the response —Response.prototype.text.call(view)throws whereview.text()works — which the JSDoc records.redirect: 'manual', and a browser answers that with an opaque-redirect response — status 0, no headers — which hides the target instead of exposing it.VatSupervisorruns in an iframe, so this is the extension's behaviour, not a corner case: a redirected request fails there rather than being followed. Failing closed is the only option; the alternative is approving a URL and requesting whatever theLocationsaid. Non-redirected requests are unaffected.followis overridden. It is the one mode that could walk to an unapproved host.manualanderrorask for less, so they are obeyed rather than silently upgraded. The mode is answered on the status alone, before theLocationis read, as the spec orders it — soerrorfails a redirect that names nowhere to go rather than passing it off as an ordinary response, which is also what undici does.Request's body is a stream however it was built, sofetch(new Request(url, {method:'POST', body:'x'}))across a 307 now errors wherefetchwould have replayed it. Buffering every body up front to avoid that would make an unbounded upload an unbounded allocation; the error names the hop and the remedy.fetchwill not follow (data:,file:,blob:), refused by name rather than incidentally as a nameless host.baseFetchthat followed a redirect below the guard. Every request here asks formanual, so a response reportingredirectedmeans a chain was walked that the guard never saw — silently degrading back to the pre-flight-only checking this PR replaces.Verification
Against live
http.createServerinstances on Node 22.20 and 24.18 (both in the CI matrix;engines: >=22), exercising the builtdistunder lockdown — 49 checks pass on both, and 13 of them fail against the pre-fix build, with the forbidden server recordingGET /secrets.Covered: single cross-host redirect; chain ending on an allowed host; excursion to another allowed host and back; redirect loop; vat-supplied
follow/manual/errorand aRequestcarrying its own mode; 307 with string and stream bodies; 303 POST→GET; cross-origin credential stripping; non-fetchable scheme; emptyLocation;dispatcherininit, planted on aRequest, and hidden behind an accessor. Plus the non-regressions — GET, POST string/stream bodies,Requestreuse,Request+initoverride,AbortControllerpropagation across a hop — all with bodies intact.All 33 mutations of
guarded-fetch.tsare caught by its tests, including each arm of the origin comparison, each entry of both header lists, the 303 HEAD exemption, and thebody: nulland method-case edge cases. DeletingresolveFetchInput's defensivenew Request(input)copy is caught too — that copy is what sheds a planteddispatcher, and asserting it needs a live server, since the rebuiltRequestkeeps a dispatcher out of reach whether or not it carried one. Coverage is layered: real-server tests inkernel-utils, unit tests at both enforcement points, and an end-to-end test inkernel-testdriving every attack from a real vat through the real supervisor and the hardened SnapsResponseWrapper.Not addressed
logger.debug, a denial reaches nothing — and the success path carries no URL either, so there is no egress-destination trail in either direction. Worth a follow-up: anonRefusalonmakeGuardedFetchwould also catch the non-guard refusals (badLocation, non-fetchable scheme, hop cap, unreplayable body).cacheis not carried across a hop. This package compiles against undici'sRequestInit, which has nocachefield because undici ignores cache mode, so a hop in a browser realm reverts to the default.Requestrebuild passes the original through as aRequestInit, which needsduplexwhen the body is a stream. undici exposesRequest.prototype.duplex; browsers do not, so a browser vat's stream-bodiedRequestfails before any request is made. Fail-closed, and narrow — streaming uploads need HTTPS and HTTP/2 anyway.🤖 Generated with Claude Code
Note
High Risk
Changes security-critical vat network confinement and redirect/integrity semantics; incorrect behavior could allow exfiltration or break legitimate fetches.
Overview
Security fix for vat
fetchescapingnetwork.allowedHosts(CWE-367): the allowlist was checked on one URL resolution while the underlyingfetchcould read the input again or follow redirects to forbidden hosts.Adds
resolveFetchInputandmakeGuardedFetchin@metamask/kernel-utilsso the guard runs on the URL that will actually be requested, with a safe stand-in input forwarded tobaseFetch. Redirects are handled manually (redirect: 'manual'on every hop) so eachLocationis host-checked; callerredirect: 'follow'is overridden,manual/errorhonored. Also rejectsdispatcherin init, blocks non-replayable bodies on body-preserving redirects, and verifiesintegrityagainst the final response body (not intermediate 3xx bodies).makeCaveatedFetch/makeHostCaveat(ocap-kernel) andmakeHostRestrictedFetch(Ollama) now delegate tomakeGuardedFetch; the host caveat takes aURLonly (removedresolveUrl/ raw-args caveat). Regression coverage spans unit tests, live HTTP servers, and kernel vat endowment tests.Reviewed by Cursor Bugbot for commit 282fbaf. Bugbot is set up for automated code reviews on this repo. Configure here.