Skip to content

Add opt-in fetch - #3110

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:webapi/fetch
Aug 20, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:webapi/fetch

Conversation

@lahma

@lahma lahma commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Adds the fetch algorithm on top of the object model from #3101: fetch(input, init) as an opt-in global, driven entirely by the engine's own event loop.

How a request runs

FetchOperation follows the ModuleLoadCompletion pattern. The synchronous phase (argument coercion, scheme + UrlFilter policy, pre-aborted signal) runs on the engine thread and produces a rejection, never a throw. The network phase runs on the thread pool via SendAsync(..., ResponseHeadersRead) and never touches the engine: the outcome is classified off-thread into a plain CLR record, then enqueued as a generation-stamped job that enters the captured realm, builds the Response (or error) there, and settles the capability. A settle that arrives after RestoreGlobalSnapshot is discarded by the same generation fence every other cross-thread completion sits behind, and the restore path cancels the in-flight requests' CTSes so their sockets are freed rather than leaked.

ConstraintFailure.MustPropagate is hoisted out of ModuleLoadCompletion so both completion paths share one definition of "a constraint failure never becomes a rejection".

Transport and policy

FetchTransport owns the HTTP mechanics: a lazily-created shared SocketsHttpHandler client (AllowAutoRedirect = false, UseCookies = false, decompression on) unless the host supplies HttpClient or a per-request HttpClientFactory, and a manual redirect loop so every hop re-runs the scheme check and UrlFilter — a redirect to 169.254.169.254 fails policy exactly like a direct request (the SSRF shape TM-21 documents). Cross-origin hops strip Authorization/Cookie/Proxy-Authorization; 301/302/303 rewrite the method per spec; redirect: 'follow' | 'error' | 'manual' follows undici's server-side semantics. The response body is a bounded manual read of the decompressed bytes (MaxResponseBytes), so an oversized or lying Content-Length ends in a clean TypeError rejection.

Failure map: policy/DNS/connection/TLS/redirect-cap/size-cap → TypeError with the CLR exception attached to the error value (readable via TryGetClrException); abort → the signal's reason; the default 30 s timeout → TimeoutError DOMException (armed with CancelAfter, so it fires even when nobody pumps); an engine CancellationConstraint → no settlement at all.

Bounds

Defaults: https/http only, 32 MB response cap, 20 redirects, 30 s timeout, 10 concurrent requests per engine, header name/value validation (CR/LF/NUL → TypeError) as request-splitting defense. credentials/cache/mode/referrer/integrity are accepted and ignored, per the server-side runtime convention.

WebApiFeatures.Fetch is never part of Default — it implies Events | Url | Files but is only ever enabled by naming it or calling UseFetch(...). .github/THREAT_MODEL.md gains TM-21 covering the egress surface.

Tests

Jint.Tests/Runtime/WebApi/FetchTests.cs and Jint.Tests.PublicInterface/WebApiFetchTests.cs (stub-handler HttpClient, no sockets): policy refusal per hop, redirect semantics and header stripping, size cap, timeout and abort mid-flight, restore-fence-drops-the-settle-and-cancels-HTTP, constraint-never-rejects, and the full failure map. Both suites green on net10.0 and net472, with and without JINT_HOST_CONTRACT_VERIFICATION=1.

🤖 Generated with Claude Code

https://claude.ai/code/session_011G7Ud48VAs1JicxRZxLDxg

The algorithm on top of the object model: a global fetch(input, init) behind
WebApiFeatures.Fetch, which UseWebApis() never sets.

FetchOperation is patterned on ModuleLoadCompletion and obeys the same rules.
The policy checks and the Request construction run on the engine thread and
every failure is a rejection, so fetch() never throws. The event-loop
generation and the realm are captured at registration; the HTTP work runs on a
pool thread against a snapshot that mentions no engine type; the outcome is
classified off-thread into plain CLR data and a generation-stamped job builds
the Response — or the error — on the engine thread, in the captured realm. The
settle is once, by CAS.

Cancellation is classified rather than flattened: the request's AbortSignal
rejects with its own reason, the Fetch.Timeout budget rejects with a
TimeoutError DOMException, and the engine's CancellationConstraint settles
nothing at all — a constraint that became a rejection would no longer bound
anything. A RestoreGlobalSnapshot cancels every request in flight, so the
socket is let go at once while the promise stays pending behind the fence.

Redirects are driven here rather than by HttpClient, with AllowAutoRedirect
off, so every hop re-runs the scheme list and the host's UrlFilter: a server
answering 302 Location: http://169.254.169.254/ cannot launder the request past
a first-hop check. The loop honours redirect: follow/error/manual, rewrites
303 — and 301/302 on POST — to GET while dropping the body and its content
headers, strips Authorization/Cookie/Proxy-Authorization across an origin
change, and caps the hop count. The body is read under MaxResponseBytes as a
running total over the decompressed bytes, so a lying Content-Length and a
chunked compression bomb are both caught.

Every network-class failure is one TypeError saying only "Failed to fetch", so
a script cannot map the host's network by reading the failures apart; the
originating CLR exception rides the error value for the host to read with
JintException.TryGetClrException. MaxConcurrentRequests is enforced at the
call and the excess is refused rather than queued.

ModuleLoadCompletion.MustPropagate moves to Runtime/ConstraintFailure.cs so
that fetch's settle job and the module loader's cannot drift apart.

THREAT_MODEL.md gains TM-21 for the SSRF, response-size and concurrency
threats, cross-referencing TM-11; README gains a fetch section, its security
paragraph and the table row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma merged commit bd63dd0 into sebastienros:main Aug 20, 2026
5 checks passed
@lahma
lahma deleted the webapi/fetch branch August 20, 2026 19:42
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.

1 participant