Skip to content

fetch: check the integrity option before a Response exists - #45561

Open
robobun wants to merge 5 commits into
mainfrom
robobun/5f620bbc/fetch-integrity-settle-gate
Open

robobun wants to merge 5 commits into
mainfrom
robobun/5f620bbc/fetch-integrity-settle-gate

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #5650

Problem

  • fetch(url, { integrity }) checks nothing. A body that does not match the digest reaches the caller. Request.integrity is always "".
  • The cause: fetch_impl (src/runtime/webcore/fetch.rs) never reads the option, and nothing hashes a body.

Fix

  • fetch_impl reads and parses integrity (fetch/sri.rs). FetchTasklet takes the whole body and compares the digest before it creates a Response. A mismatch, or a response without a body, rejects with a TypeError.
  • data: and in-memory blob: URLs get the same check. An uncheckable request rejects: file:, file-backed blob:, upgrades.
  • Verified: test/js/web/fetch/fetch-integrity.test.ts, WPT integrity.sub.any.js (16 of 18), fetch-args.test.ts, request.test.ts.
  • Self-reviewed: 33 concerns raised, 29 addressed. Notes names the other 4.

Background

  • FetchTasklet is the JS-thread object behind one fetch(). Without integrity, the promise resolves at the head and the body streams later.
  • Integrity metadata lists sha256-, sha384- or sha512- digests. Only the strongest algorithm counts.
  • Replaces fetch: implement the integrity option (subresource integrity) #32815 (closed by a stale sweep). The review also weighed a check around the fetch_impl result and a digest on the HTTP thread. Both let a Response exist first.

Downsides

  • A wrong digest now rejects. So does integrity with HEAD, 204, 205 or 304.
  • With integrity, the whole body stays in memory and is hashed on the JS thread: 0.67 ms per MiB (sha256), 1.48 (sha512).
  • Plain fetch(url): 5847 to 5809 instructions, 0 allocations added. fetch(request): 5955 to 6096. Request: 104 to 112 bytes, same allocator size class. Binary: +13,824 bytes.
Notes

What a caller without integrity pays

Release builds of main (5866b5f) and of this branch (22d03d5), BUN_JSC_useJIT=0. Instructions are single-stepped with gdb from the entry of the host function to its return, callees included. The request goes to a closed port, so this is the synchronous part of the call. Each cell is the first counted call of a process (call 5).

call instructions mallocs frees Gets on the init
fetch(url) 5847 → 5809 3 → 3 0 → 0 0 → 0
fetch(url, {}) 9554 → 9577 4 → 3 1 → 0 16 → 17
fetch(url, { method }) 9795 → 9817 4 → 3 1 → 0
fetch(url, { method, headers }) 12807 → 12829 13 → 12 6 → 5
fetch(request) 5955 → 6096 3 → 3 0 → 0 0 → 0
fetch(request, {}) 9662 → 9691 4 → 3 1 → 0 16 → 17
fetch(requestSubclassInstance) 13105 → 13001 4 → 3 1 → 0
fetch(url, requestAsInit) 12113 → 12388 4 → 3 1 → 0
fetch({ url }) 10248 → 10253 4 → 3 1 → 0 18 → 19
fetch("data:,x") 2216 → 2209 6 → 6 1 → 1 0 → 0
fetch("data:,x", {}) 2227 → 2445 6 → 6 1 → 1 0 → 1
new Request(url) 938 → 941 1 → 1 0 → 0 0 → 0
new Request(url, {}) 3640 → 2845 4 → 1 3 → 0 9 → 10
new Request(url, { method }) 3891 → 3096 4 → 1 3 → 0
new Request(request) 747 → 755 1 → 1 0 → 0
new Request(requestSubclassInstance) 6790 → 5831 4 → 1 3 → 0
request.clone() 1302 → 1289 2 → 2 0 → 0
finalizer of a Request 124 → 140 0 → 0 1 → 1
  • Every Bun__fetch shape has a second level about 1,380 instructions higher on some later calls of one process, on both builds (fetch(url): 7232 and 7196 on main, 7239 here).
  • One integrity read on an init object is about 210 instructions and no allocation. To pay for it, redirect in fetch, and redirect, cache and mode in new Request, are now read with common strings. Each of those by-name lookups was a malloc and a free.
  • fetch(request) is +87 to +141 (6042, 6051 and 6096 in three processes of one binary; main gave 5955 each time). The new checks are 16 of them. The rest is a starts_with_ascii call that the compiler no longer inlines (+19), code layout inside fetch_impl, and a libc copy routine that differs by process.
  • fetch("data:,x", init) pays the read in full (+218), because main reads nothing from the init on that path.
  • Sizes: Request is mi_malloc(104) → mi_malloc(112), and mi_usable_size is 112 for both. FetchTasklet is mi_malloc(832) → mi_malloc(840), usable size 896 for both. estimateShallowMemoryUsageOf(new Request(url)) is 193 → 201.
  • Release binary: text 106,331,459 → 106,345,283 bytes (+13,824), data and bss unchanged. Four more common strings (+32 bytes per global object). No new host function. No new unsafe.
  • integrity is the first key that fetch and new Request read from an init. The order of the other keys does not change.

What a caller with integrity pays

  • The body is received in the BufferAll mode, the mode that arrayBuffer() on a plain response uses. The HTTP thread decodes the bytes as they arrive and the transport never pauses. The check is one hash pass on the JS thread when the last byte is in.
  • Hash rate, one pass over 64 MiB: sha256 0.67 ms per MiB, sha384 1.47, sha512 1.48.
  • Longest gap of a 1 ms timer during a 64 MiB fetch, identity and gzip (medians of 5 fetches, 2 processes each): 4 to 7 ms without integrity, 41 to 55 ms with sha256, 97 to 117 ms with sha512.
  • Peak RSS of a process that does five such fetches and reads each with arrayBuffer(): 230 to 323 MiB with integrity, 264 to 360 MiB without.
  • undici also hashes the whole body on the main thread.

Differences from Node, on purpose

  • Tokens are split at every ASCII whitespace character, the algorithm name is case-insensitive, and base64url is accepted. That is the SRI specification and what the WPT file checks. undici splits at spaces only and is case-sensitive.
  • Bun's built-in node-fetch and undici modules call Bun's fetch, so they now enforce integrity. The node-fetch package on npm ignores the option.
  • The error is a TypeError with code: "IntegrityCheckFailed", path (the URL of the response) and a message that has the digest of the body that arrived. A value that is not a digest of its algorithm has a message of its own.

Not in this PR

Related

Self-review: the 4 concerns that are not addressed

  • A 205 that frames content keeps the fetch pending until the content ends. It is the same wait that the body read has today.
  • The only guard against a Response that is built with the metadata still present is a debug_assert! in on_resolve. The check and the call are 30 lines apart in one function, and fetch.rs has a runtime guard for its own exits.
  • No test runs integrity in a bun build --compile executable, and no test goes through a proxy.
  • packages/bun-types has no Bun-specific JSDoc for integrity. The member exists through lib.dom and undici-types.

Tests

  • On the debug build of main, fetch-integrity.test.ts has 91 failures of 169, the WPT file 4 of 16, request.test.ts 7 and fetch-args.test.ts 7. On this branch: 322 pass, 2 todo, 0 fail in the four files.
  • The guard that makes the promise wait for the whole body is covered: without it, 4 tests of fetch-integrity.test.ts fail.
  • Other suites, on debug + ASAN builds of main and of this branch in one container: fetch-leak.test.ts (31 pass), blob.test.ts (147), fetch-gzip.test.ts (89) pass on both. body.test.ts: 776 pass here, 775 and 1 timeout on main. fetch-backpressure.test.ts: 127 pass on main, 126 and 1 timeout here, and that test passes 3 of 3 alone on both. fetch.test.ts: 375 pass and 18 fail on both (5 s timeouts of the utf16 (with gc) tests, and tests that need the internet). request-clone-leak.test.ts fails on both (5 tests on main, 4 here: its RSS bound and 5 s timeouts under ASAN).
  • test/internal/source-lints/: 180 pass. The 2 that fail are timeouts in sema-parser-recursion.test.ts, a lint of the JS parser.
  • perf, valgrind, strace and bloaty are not in the container that measured this. Instruction counts are from gdb single-stepping, sizes from size and mi_usable_size.

no test proof · iteration 12 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-integrity.test.ts

…oes not match it

fetch() accepted `integrity` and checked nothing. Now fetch_impl parses the
option (fetch/sri.rs) and gives the metadata to FetchTasklet, which receives
the whole body and compares its digest before it creates a Response. A body
that does not match, or a response without a body, rejects with a TypeError.

data: URLs and blob: URLs of an in-memory Blob are checked the same way. A
request whose body fetch cannot check is refused: a file: URL, a blob: URL of
a file or an S3 object, an upgrade, and an S3 upload with a stream body.

Request stores the option in a boxed field and returns it from its
`integrity` getter. A Request that is the input of fetch() or of
new Request() gives the stored value, whatever property the object has.
@coderabbitai

coderabbitai Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 5bfdded6-10c9-42a7-a2cc-546769bed6bc
📥 Commits

Reviewing files that changed from the base of the PR and between 5866b5f and 22d03d5.

📒 Files selected for processing: 15
  • docs/runtime/networking/fetch.mdx
  • docs/runtime/nodejs-compat.mdx
  • src/jsc/CommonStrings.rs
  • src/jsc/JSValue.rs
  • src/jsc/bindings/BunCommonStrings.cpp
  • src/jsc/bindings/BunCommonStrings.h
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/fetch/sri.rs
  • test/js/web/fetch/fetch-args.test.ts
  • test/js/web/fetch/fetch-integrity.test.ts
  • test/js/web/fetch/wpt/integrity-wpt.test.ts
  • test/js/web/fetch/wpt/integrity.sub.any.js
  • test/js/web/request/request.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:57 AM PT - Oct 16th, 2026

✅ @robobun, your commit 22d03d5b71d79f47775ea00539b9babc2562b7e0 passed in Build #126768! 🎉


🧪   To try this PR locally:

bunx bun-pr 45561

That installs a local version of the PR into your bun-45561 executable, so you can run:

bun-45561 --bun

@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (5866b5f) with this script:

import { createHash } from "node:crypto";
const server = Bun.serve({ port: 0, fetch: () => new Response("evil\n") });
const integrity = "sha256-" + createHash("sha256").update("real\n").digest("base64");
const outcome = await fetch(server.url, { integrity }).then(
  async response => `resolved with ${JSON.stringify(await response.text())}`,
  error => `rejected: ${error.name}: ${error.message}`,
);
console.log("fetch with a wrong digest:", outcome);
console.log("Request.integrity:", JSON.stringify(new Request(server.url, { integrity }).integrity));
server.stop(true);

Output on main:

fetch with a wrong digest: resolved with "evil\n"
Request.integrity: ""

Output with this PR (#45561):

fetch with a wrong digest: rejected: TypeError: Integrity check failed: the response body has the digest "sha256-iGtnSA2+c7QGrYOh3W2VlvkwidkMIgzPyRlEyV8caMQ=", which is not in the 'integrity' option
Request.integrity: "sha256-nh/pfBZ+0s6XMTRmccryPtQoumRRArPQwc3eCZgFKOU="

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the new Request.integrity owning field across every lifecycle path (all five constructors, finalize, clone_into, memory-cost reporting) and found it initialized, copied and cleared consistently; the reject_on_exception fail-closed guard, which rejects any exit of fetch_impl that returns an unrejected promise while the parsed metadata is still unconsumed; and the three-state handling of integrity (undefined falls through to the input Request's stored value, "" parses to None and suppresses it, a non-empty string is read from the init object and never from a JS-visible getter on a Request input).

Extended reasoning...

The change implements fetch()/Request integrity (SRI): a new parser/hasher in src/runtime/webcore/fetch/sri.rs, a boxed field on Request, option extraction in fetch.rs with a fail-closed guard, and whole-body buffering plus a digest check in FetchTasklet before a Response exists, with data:/blob:/file:/upgrade/S3-stream paths handled explicitly. It is a security-relevant feature on the fetch hot path, so the inline findings and the ruled-out list already signal that a human should weigh the design trade-offs (JS-thread hashing, full-body buffering, HEAD/204/304 rejection); this note only records the additional lifecycle and fail-closed checks that were examined and found consistent.

Comment on lines +1143 to +1144
let rejection = if let Some(integrity) = self.integrity.take() {
self.check_integrity(&integrity)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Every fetch() with integrity now freezes the JS event loop for the time it takes to hash the whole body, roughly 0.7 to 1.5 ms per MiB, so a 500 MiB response stalls all other requests in that process for about a second. check_integrity at FetchTasklet.rs:1144 runs integrity.check over scheduled_response_buffer synchronously inside on_progress_update, while the tasklet mutex is held. The base branch resolves at the head and never hashes. Fix: hash incrementally as chunks arrive (on the HTTP thread in the callback at FetchTasklet.rs:2686, or in a work-pool job) so the JS thread only compares a finished digest, instead of one synchronous pass over the complete body under the mutex.

Why this was flagged

Trigger: fetch(url, { integrity: 'sha256-...' }) for any body larger than a few MiB, entering through on_progress_update at src/runtime/webcore/fetch/FetchTasklet.rs:1143-1144 on the final progress tick. check_integrity (FetchTasklet.rs:1219-1244) calls Metadata::check (src/runtime/webcore/fetch/sri.rs:136-153), which calls Algorithm::digest over the whole buffered body on the JS thread. The PR's own numbers are 41 to 117 ms of timer stall per 64 MiB; that scales linearly, so hundreds of MiB means seconds of event-loop stall in a server that handles other requests. On the base branch the body is never hashed and the promise resolves at the head, so no such stall exists. This runs once per integrity fetch, at production rate for any service that fetches signed assets or packages. The dismissal accepted it because the author documented it; the cost is still a new unbounded synchronous stall. Remedy: incremental hashing off the JS thread, keeping only the final compare under the mutex.

Verification: Any fetch(url, { integrity }) whose body is large triggers it. on_progress_update (src/runtime/webcore/fetch/FetchTasklet.rs:928) takes self.mutex.lock() at line 931 and, on the final tick, calls self.check_integrity(&integrity) at line 1143-1144, which runs hasher.update(bytes) over the entire buffered body synchronously. The base branch has no such stall.

@@ -0,0 +1,951 @@
// fetch() checks the response body against the `integrity` option

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get a new 951-line test file for fetch behaviour, which the root CLAUDE.md test-organization rule asks to avoid. CLAUDE.md:49 says a fetch change goes in test/js/web/fetch/fetch.test.ts and "Do not create a new file". Fix: either move the new tables into test/js/web/fetch/fetch.test.ts (and the Request cases already sit in request.test.ts), or state in the PR why a feature-sized suite with its own servers warrants its own file; the wpt/ driver and fetch-args.test.ts additions are fine as they are.

Why this was flagged

The diff adds test/js/web/fetch/fetch-integrity.test.ts as a new file for a fetch() option. The applicable root CLAUDE.md:49 reads: "Default: add your test to the existing test file for the code you're changing. Do not create a new file. A fetch bug goes in test/js/web/fetch/fetch.test.ts". No exception in CLAUDE.md:60 applies (that one is for regression issue files). The base branch has no such file; the consequence is only discoverability and a convention slip, not a behavioural defect, so this is a nit.

Verification: nit. Triggering condition: unconditional — the diff adds a new file. git diff --stat shows test/js/web/fetch/fetch-integrity.test.ts | 951 ++++ as a wholly new file. Root CLAUDE.md:49 reads verbatim: "Default: add your test to the existing test file for the code you're changing. Do not create a new file." The PR description gives no reason for a separate file. Nothing behavioural breaks.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support fetch integrity

2 participants