Skip to content

Refuse a module load from a blob: URL of an S3File or a Bun.file(fd) - #46721

Open
robobun wants to merge 4 commits into
mainfrom
robobun/a18c3a15/blob-url-module-s3-store
Open

robobun wants to merge 4 commits into
mainfrom
robobun/a18c3a15/blob-url-module-s3-store

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #46673

Problem

  • import(), require() and new Worker() of a blob: URL made from an S3File load an empty module: no request, no error.
  • The blob: arm of get_loader_and_virtual_source (src/runtime/jsc_hooks.rs:3979) takes every Blob that is not a file for bytes in memory. An S3 store has none. No user reported it: Refuse an S3-backed Blob at spawn stdin, in the shell, in Bun.build files and in redis #46673 hands this site off.
  • For a Bun.file(fd) URL the loader opens blob:<uuid> in the working directory.

Fix

  • The arm takes the bytes from in_memory_view() (None for a file or S3 store). An S3 store, or a file with no path, is a TypeError (ERR_INVALID_ARG_TYPE) that says how to read the Blob first.
  • import() rejects, require() throws, a Worker gets an error event. No request is sent.
  • Verified: test/js/web/fetch/blob.test.ts (22 new, 20 fail on main), test/js/web/workers/worker_blob.test.ts (3 new, all fail).
  • Self-reviewed: 32 concerns raised, 27 addressed, 5 rejected (see Notes).

Background

  • URL.createObjectURL(blob) registers a copy of the Blob. The loader finds it by the URL's UUID.
  • A Blob views a store: memory, a file, or an S3 key. Only memory has bytes at once.
  • Considered a read of the object: require() cannot wait, and debug builds panic when a Worker releases an S3 store.

Downsides

  • import() rejects where fetch() of the same URL reads the object.
  • Now throw: an S3 URL with { type: "file" } or a .css key, and s3file.slice(0, 0).
  • Still wrong: new Blob([text, s3file]) (Blob: read file-backed parts in multi-part new Blob([...]) #33600), a slice of Bun.file(path), the arm's copy in bun_bundler. Ordinary require(): 387 to 389 instructions, no new branch.
Notes

Origin. Found by a read of the code after the class sweep in #46673, which lists this site as handed off. No user reported it. #43931 described the class and was closed as not a user report.

Repro. A loopback Bun.serve stands in for S3 and counts requests. Linux x64.

let gets = 0;
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { gets++; return new Response("export default 42;\n"); } });
const s3 = new Bun.S3Client({ endpoint: srv.url.href, accessKeyId: "k", secretAccessKey: "s", bucket: "b" });
const url = URL.createObjectURL(s3.file("mod.mjs"));
console.log((await import(url)).default, gets); // main: undefined 0
blob: URL of main This PR
an S3File, Bun.file("s3://..."), a slice, new File([s3file], name): import(), an import statement, require(), import.meta.require(), createRequire(), require.extensions[".js"](), Bun.ModuleGraph#import(), node:vm with the default loader an empty module ({}, "" for a .txt key, a parse error for a .json key), 0 requests TypeError (ERR_INVALID_ARG_TYPE), 0 requests
an S3File: new Worker(url) the Worker runs nothing and closes with code 0 an error event
an S3File: ShadowRealm#importValue() (checked by hand) fails on the missing export of the empty module a TypeError of the caller's realm that wraps the text
an S3File with { type: "file" }, or with a .css, .html, .wasm, .sh or .sqlite key what the loader makes without the bytes: the URL, {}, a Database the same TypeError
s3file.slice(0, 0) an empty module the same TypeError
Bun.file(fd) BuildMessage: ENOENT reading "blob:<uuid>". With a file of that name in the working directory, that file runs the same TypeError
Bun.file(path), an in-memory Blob or File, new Blob([]) load unchanged
an S3File: fetch(url) then text() the object, 1 GET unchanged

Error text. Cannot load a module from "blob:<uuid>": the Blob is S3-backed or file-descriptor-backed, so its bytes are not in memory. Read it first: URL.createObjectURL(new File([await blob.bytes()], blob.name ?? "", { type: blob.type })). The File keeps the name, because the loader comes from the extension of the name when there is one and from the type when there is none. A test runs that expression for .mjs, .json, .txt and .toml keys and for three Bun.file(fd) shapes.

Why a throw and not a read.

Why not keep the loaders that never read the text. The first shape of this change did: it decided on the loader and let file, css, html, sqlite and others load as on main. Release builds of main and of that shape measured +14 instructions and +2 conditional branches in Bun__transpileFile on each require() of an ordinary file. The results it kept never came from the Blob. The review asked for a refusal on the kind of store alone. This is that shape: an ordinary require() runs 389 instructions there against 387, with no new branch or call.

The copy of the arm in bun_bundler (src/bundler/options.rs:512) is not changed. Its one caller is the error printer (VirtualMachine::fetch_without_on_load_plugins), which looks up source lines for a stack frame. An empty source and an error give the same fallback there, and no module can load from one of these URLs now.

Still wrong after this change.

Measurements. Release builds (--profile=release) of main 6292c6539d and of main with this PR's src/ diff, Linux x64. valgrind, perf and strace are not installed here. gdb single-stepped Bun__transpileFile from its first instruction to its return with calls stepped over, so the count is the function's own instructions, the inlined get_loader_and_virtual_source included.

One call of Bun__transpileFile main This PR
require() of a file (501 calls) 387 instructions, 14 calls, 47 conditional branches 389, 14, 47
import of a file (501 calls) 363, 12, 50 366, 12, 50
import() of an in-memory blob: URL (200 calls) 465, 16, 59 475, 16, 60
import() of a Bun.file(path) blob: URL (200 calls) 449, 17, 59 456, 17, 59
  • non-blob module load: +2 instructions per Bun__transpileFile call for require() and +3 for import (gdb single-step, 1,002-module graph of bench/module-loader/create.js, main 6292c65 vs PR). Every call of a kind gave the same count. The difference is register moves and block order: a trace of one call shows +5 mov, -2 xor, and for require() one jmp less. No conditional branch and no call is added.
  • in-memory blob: import: +10 instructions, +1 conditional branch (the store test of in_memory_view()), +0 allocator calls per load. Bun.file(path) blob: import: +7 instructions, +0 allocator calls, +0 openat/read/close per load. Allocator calls and syscalls were counted with gdb breakpoints on mi_malloc, mi_heap_malloc, mi_realloc and their variants and with catch syscall, callees included: 31 allocator calls per in-memory load and 31 per file load on both builds, and 1 openat, 2 read, 1 close per file load on both.
  • release .text: +512 bytes, .rodata +0 (llvm-size -A). Bun__transpileFile 6,059 -> 6,254 bytes. The new blob_not_in_memory_error is 122 bytes, #[cold] and not inlined. The copy in bun_bundler is unchanged, and so are the 13 VmLoaderCtx hooks. The runtime get_loader_and_virtual_source has no symbol in either binary: it is inlined into Bun__transpileFile.
  • requests: 0 over the 20 refusing rows and the 3 Worker rows. Each row then reads one object and expects exactly that one GET.
  • error surface: 21 rows assert TypeError, ERR_INVALID_ARG_TYPE and the exact message. The two Worker entry rows assert the first line of the error event. The expression from the message loads the right module for 7 of 7 shapes.
  • ErrorCode.ts rows: 325 -> 325. bun_runtime::Error variants: +1. Host functions: +0. Existing lines changed in the two test files: the bun:test import line of each.

Tests.

  • test/js/web/fetch/blob.test.ts: 22 new tests. 20 fail on a release build of main 6292c6539d and on the released 1.4.3. The other two pin what must not change: the expression from the error message loads the right module for 7 shapes, and an empty Blob and a Bun.file(path) still load.
  • test/js/web/workers/worker_blob.test.ts: 3 new tests, all fail on main.
  • Each refusal test reads one object after the load and expects that read to be the only request, so a request that the load started would show.
  • Full files with the change, debug build: blob.test.ts 171 pass, worker_blob.test.ts 10 pass, disallow-code-generation-from-strings.test.ts 34 pass and 4 skip. That file ran with a long timeout: on a debug build, 14 to 16 of its tests exceed the default 5 s, on main too.

Self-review. A review of the first shape raised 32 concerns and asked for this re-cut. Done: the refusal is on the kind of store alone and asks the shared in_memory_view(). The loader table, the force_loader parameter and the bun_bundler hook change are gone. The tests can see a stray request, and cover the zero-length slice and the one-part File. The printed expression fits a Bun.file(fd). The docs paragraph is gone. Not done, 5 concerns:

`import()`, `require()` and `new Worker()` of a `blob:` URL made from an
S3File loaded an empty module. No request went to S3 and nothing threw.
The `blob:` arm of the module loader took every Blob that is not a file
for bytes in memory, and an S3-backed Blob has none. For a `Bun.file(fd)`
URL the loader opened `blob:<uuid>` in the working directory.

The arm now takes the bytes from `in_memory_view()`. An S3-backed Blob, or
a file-backed one with no path, is a TypeError with the code
`ERR_INVALID_ARG_TYPE`: `import()` rejects, `require()` throws and a
Worker gets an `error` event. The message says how to read the Blob first.

Found by a read of the code, not by a user report. The same class at
other consumers is #46673.
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 8a0e49e and 6468950.

Files selected for processing: 4
  • src/runtime/error.rs
  • src/runtime/jsc_hooks.rs
  • test/js/web/fetch/blob.test.ts
  • test/js/web/workers/worker_blob.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

Blob-backed module loading now rejects Blobs whose bytes are not in memory. It reports ERR_INVALID_ARG_TYPE with guidance to read the bytes first. Tests cover module-loading paths, worker loading, and cases that remain supported.

Changes

Blob module loading

Layer / File(s) Summary
Loader error handling
src/runtime/error.rs, src/runtime/jsc_hooks.rs
The public Error enum adds BlobNotInMemory. Blob loading reports this error when bytes are unavailable, and Bun__transpileFile maps it to ERR_INVALID_ARG_TYPE. Other loader errors retain the existing module-not-found response.
Module loader coverage
test/js/web/fetch/blob.test.ts
Tests check that module-loading paths reject S3-backed and file-descriptor-backed Blobs without requesting their contents. They also cover imports after reading the bytes, empty in-memory Blobs, and path-backed files.
Worker loading coverage
test/js/web/workers/worker_blob.test.ts
Tests check error events for S3-backed worker.mjs and worker.css entry points, and check that worker dynamic imports reject with ERR_INVALID_ARG_TYPE. The tests verify that these loads do not request the Blob.

Possibly related PRs

  • oven-sh/bun#46673: Adds in-memory byte checks for file- and S3-backed Blobs in Bun.build.
  • oven-sh/bun#46842: Changes the blob-backed module loader in src/runtime/jsc_hooks.rs.
  • oven-sh/bun#38364: Rejects synchronous FormData serialization when S3-backed Blob bytes are unavailable.

Suggested reviewers: dylan-conway, alii

Priority: Normal

Merge Risk: Minimal · up to 64689

Unavailable Blob bytes now produce an explicit loading error. No merge-blocking risk is identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: a module load from a blob: URL backed by an S3File or Bun.file(fd) is refused. The changeset adds BlobNotInMemory, a TypeError with ERR_INVALID_ARG_TYPE in the blob lo…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and relevant. It explains the problem, the fix, expected behavior, limitations, and verification results. It does not use the template headings "What does this PR do?" and …
Full details: Title check

Explanation

The title names the main change: a module load from a blob: URL backed by an S3File or Bun.file(fd) is refused. The changeset adds BlobNotInMemory, a TypeError with ERR_INVALID_ARG_TYPE in the blob loader arm, and tests for those refusals. The title is specific and matches the primary change.

Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, the fix, expected behavior, limitations, and verification results. It does not use the template headings "What does this PR do?" and "How did you verify your code works?", but it provides the required information under equivalent sections.

  • 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.

Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:40 AM PT - Oct 27th, 2026

❌ @robobun, your commit 6468950 has 1 failures in Build #133490 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46721

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

bun-46721 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 6292c6539d and on the released 1.4.3, with a local Bun.serve in place of S3:

let gets = 0;
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { gets++; return new Response("export default 42;\n"); } });
const s3 = new Bun.S3Client({ endpoint: srv.url.href, accessKeyId: "k", secretAccessKey: "s", bucket: "b" });
const url = URL.createObjectURL(s3.file("mod.mjs"));
console.log((await import(url)).default, gets); // undefined 0

With this PR (#46721) the import() rejects with a TypeError and gets stays 0.

CI. No failure on the current head comes from this change.

  • Build #133490 (6468950b80) finished with 179 of 181 jobs passed.
  • The 2 failed jobs are both darwin any aarch64 - test-bun. Each stopped before it ran a test, with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. So macOS aarch64 ran no test on this head.
  • Build #132019 (cc79cb10fd) passed on all 181 jobs, macOS aarch64 included. After that build the branch merged main and changed one test file.
  • 9 test files failed once and passed on a retry. This PR touches none of them.

The PR waits for a maintainer. The decision to make is the design: the loader refuses these Blobs and does not read them.

@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 Err(BlobNotInMemory) early return in Bun__transpileFile, which fires while blob_to_deinit is Some but before the _blob_guard scopeguard is armed: the dupe is non-heap (ref_count: 0), and RefPtr<Store> and bun_core::String both implement Drop, so the store ref and name are released by the plain drop — no leak on that path. The copy of the blob: arm in src/bundler/options.rs still calls shared_view(), but its only consumer is the error-printer source lookup, where an empty source and a refusal land on the same fallback.

Extended reasoning...

The change narrows the blob: arm of the module loader in src/runtime/jsc_hooks.rs to in-memory Blobs, adds an Error::BlobNotInMemory variant, and surfaces it as an ERR_INVALID_ARG_TYPE TypeError, with tests in blob.test.ts and worker_blob.test.ts against a loopback Bun.serve stand-in; it touches no auth, crypto, or injection surface. Two confirmed findings are posted inline, so a human should look; this note only records the ownership re-audit of the new early return and the unchanged bundler sibling.

Comment thread src/runtime/jsc_hooks.rs
Comment thread src/runtime/jsc_hooks.rs Outdated
@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main (8a0e49e) into this branch.

  • One conflict, in src/runtime/jsc_hooks.rs, where the loader builds the virtual source of a blob: module. Main (98a4365, Fix use-after-free of BuildMessage.position.file: real lifetimes for Source, Path, ImportRecord and resolve results #46834) replaced the unsafe 'static views with Source::init_path_string(path.text, blob.shared_view()). This PR adds the in_memory_view() check that returns BlobNotInMemory. The resolution keeps the check of this PR, then builds the source as main does, from the checked view.
  • On the BlobNotInMemory return, the scope guard that main added releases the blob.
  • cargo check --workspace passes on the merged tree (Linux x64).
  • Tests, on a local debug build that contains this merge and the main merges of eight other open PRs, with --timeout 30000: test/js/web/fetch/blob.test.ts (171 pass), test/js/web/workers/worker_blob.test.ts (10 pass).

@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 nit, I also checked the two new early return Err(BlobNotInMemory) paths in get_loader_and_virtual_source for a leaked Blob dupe — blob_to_deinit is a scopeguard in Bun__transpileFile (src/runtime/jsc_hooks.rs:4130), so the dupe is released on both. The new Error variant's only exhaustive consumer (name()) is updated, Bun__transpileFile is the sole caller of this arm, and in_memory_view() still yields an empty slice for a store-less new Blob([]), so that case keeps loading.

Extended reasoning...

The change refuses module loads from blob: URLs whose Blob is S3- or fd-backed in the runtime loader arm, adds an Error variant, and ships tests against a loopback Bun.serve S3 stand-in; it touches no auth, crypto or injection surface. One inline nit on test resource cleanup was reported; the new error paths release the duped Blob via the existing scopeguard, and the bundler copy of the arm in src/bundler/options.rs is intentionally unchanged with only the error printer as caller.

Comment thread test/js/web/workers/worker_blob.test.ts Outdated
…hen a test fails

Register the cleanup before the first await with a DisposableStack. Revoke the object URL of the Worker script too, and terminate the Worker of the entry point test.

@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.

I re-reviewed the current head and found no bugs; the DisposableStack cleanup in the Worker tests resolves the earlier nit. Because this changes what a blob: module load of an S3File / Bun.file(fd) does for users (a refusal rather than a read, with the documented new throws for { type: "file" } and .css keys), a maintainer should still sign off on that design choice.

What was reviewed:

  • The two new early returns in get_loader_and_virtual_source: the duped Blob is already in blob_to_deinit on both, so the caller's scopeguard releases it on the error path.
  • in_memory_view() is the existing helper (None for file-backed or S3 stores), so under !needs_to_read_file() the None arm is exactly the S3 case; the fd case is caught by the store_path() == None arm.
  • Error::BlobNotInMemory is added to both the enum and the name table; the only other match on this error is the new arm in Bun__transpileFile, and the bun_bundler twin is excluded with a stated reason.
  • Tests assert the exact TypeError name/code/message and zero GETs at the stand-in, release the server/URLs/Worker before the assertions, and the workaround path is exercised.
Extended reasoning...

The change adds a BlobNotInMemory variant to the runtime Error enum and two early returns in the blob: arm of the module loader in src/runtime/jsc_hooks.rs, plus a cold helper that turns it into an ERR_INVALID_ARG_TYPE TypeError; the rest is test coverage in test/js/web/fetch/blob.test.ts and test/js/web/workers/worker_blob.test.ts. It touches no auth, crypto, or injection surface; the sensitive part is a hot module-load path, and the PR reports no new conditional branch for ordinary loads. The source diff is small and the error path is resource-safe, but it is a deliberate user-visible behavior change (refuse instead of read) with trade-offs the author lists, which is a product decision rather than a correctness question, so a human sign-off is still worthwhile. Only the test-cleanup commit landed since the last review, and it addresses the one open nit.

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.

1 participant