bundler: copy inlined env values into the define arena instead of aliasing the env map - #37784
bundler: copy inlined env values into the define arena instead of aliasing the env map#37784robobun wants to merge 6 commits into
Conversation
…asing the env map With env inlining on (Bun.build / bun build --env=inline, or [serve.static] env = "inline" for the dev server), each process.env.X define was an E::String whose bytes pointed straight into the dotenv Loader's map. Assigning a proxy variable on process.env goes through Bun__setEnvValue -> Map::put, which replaces and frees that entry, so any define table built before the write (the dev server keeps its for the whole server lifetime; a build keeps its across macros and plugins) then printed freed memory. Copy the value into the arena the define table is already allocated from, the same way DefineData::parse handles --define values.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: reproduced on the released 1.4.0 binary (the inlined string comes back with the allocator's free-list word over its first bytes) and on the ASAN debug build ( Ready for review. Reviews so far have no requested changes and no threads are open. On the latest build (https://buildkite.com/bun/bun/builds/93390, head ad32558) every lane that ran the changed files and the new tests passed; the red jobs are Related: #37780 (per-build loader copy for |
There was a problem hiding this comment.
LGTM — one-line UAF fix that copies env values into the define arena instead of aliasing the mutable env map.
What was reviewed:
bump.alloc_slice_copy(value)matches the documented contract ofE::EString::init(arena-owned data) and mirrors whatDefineData::parsealready does for--definevalues a few lines below.- Tests cover all three affected paths (dev server rebuild,
bun buildmacro,Bun.buildplugin), assert the startup value is inlined and the mutated value is absent, and follow harness conventions (subprocess pipe draining,bunEnvspread,tempDir,Buffer.allocover.repeat). - Checked that the key side was already safe (built into a local
key_bufand duped byget_or_put_value), so only the value needed copying.
Extended reasoning...
Overview
The PR fixes a heap-use-after-free in env inlining. env_string_store_put in src/bundler/defines.rs previously built an E::String whose bytes pointed directly into the dotenv Loader map. When JS assigns one of the proxy env vars (HTTPS_PROXY etc.) via Bun__setEnvValue, Map::put frees the old Box<[u8]>, leaving the define table pointing at freed memory. The fix is a single added line: let value: &[u8] = bump.alloc_slice_copy(value); — the value is now copied into the same arena that already owns the E::String node and the rest of the define table, so its lifetime matches the table's. The old comment claiming the env-map storage was long-lived is replaced with one explaining why it isn't.
Three tests are added covering each place the define table can outlive an env-map mutation: bake DevServer with [serve.static] env = "inline" and a runtime process.env.HTTPS_PROXY write followed by a rebuild; bun build --env=inline with a macro that assigns the variable; and Bun.build({ env: "inline" }) with a plugin onLoad that assigns it mid-build.
Security risks
None introduced. This removes a memory-safety bug (UAF reading freed heap into bundled output). The copy is into an arena the define table already lives in, so no new lifetime is created.
Level of scrutiny
Low-to-medium. The runtime change is one line in a helper with one caller (copy_env_for_define), and it brings the value handling in line with the documented contract of E::EString::init ("data is arena-owned … bulk-freed", src/ast/e.rs:1733) and with what DefineData::parse already does for user --define values in the same file. alloc_slice_copy is the existing arena primitive (src/bun_alloc/MimallocArena.rs:369). The only cost is one small copy per env var when inlining is on; the runtime VM and Bun.Transpiler don't reach this path.
Other factors
- The PR description includes an ASAN trace pinning the free to
Map::putunderBun__setEnvValueand the read to the printer, and confirms all three tests fail onUSE_SYSTEM_BUN=1/ unfixed ASAN and pass with the fix. - Tests follow the repo's conventions:
itBundledfor the CLI case withonAfterBundle+run, a spawned subprocess for the API case with concurrent pipe draining and{ ...bunEnv, HTTPS_PROXY: ... },tempDirfrom harness,Buffer.alloc(n, fill).toString()instead of.repeat, and stdout asserted before exit code. - I checked whether the key needed the same treatment: it does not —
copy_env_for_definebuilds the key into a localVec<u8>andget_or_put_valuedupes it, so only the value was aliased. - The relationship to #37780 (per-build loader clone for
Bun.build) is noted in the description; this change is independently correct and covers the dev server / CLI paths that #37780 does not.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #37780, although the two overlap on one path. #37780 changes The only duplicated piece is the |
|
Updated 3:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit ad32558 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 37784That installs a local version of the PR into your bun-37784 --bun |
There was a problem hiding this comment.
LGTM — one-line arena copy that fixes an ASAN-confirmed UAF, with regression tests for all three affected paths.
What was reviewed:
env_string_store_put:bump.alloc_slice_copy(value)matches the documented contract ofEString::init("data is arena-owned") and the existing--definepath atdefines.rs:419.- Checked
copy_env_for_define— the key already goes throughget_or_put_valuefrom a reused stack buffer, so only the value aliased the env map; nothing else in the function borrows fromenv.map. - Tests follow harness conventions (
Buffer.allocfill,tempDir,{...bunEnv}, concurrent pipe drain, stdout asserted before exitCode,.examplehosts) and cover dev-server, CLI-macro, andBun.build-plugin variants. - Comment-cop threads on the long comment are resolved; d1e4a28 trimmed it to one line.
Extended reasoning...
Overview
The production change is a single added line in src/bundler/defines.rs: env_string_store_put now copies the env value into the define arena via bump.alloc_slice_copy(value) before wrapping it in E::EString. Previously the E::String node was arena-allocated but its data bytes aliased the dotenv Loader map entry, which Bun__setEnvValue -> Map::put can free (proxy variables are the writes that reach the native map). Three regression tests are added: a bake dev-server test with [serve.static] env = "inline", an itBundled CLI test where a macro reassigns HTTPS_PROXY mid-build, and a spawned Bun.build test where a plugin onLoad does the same.
Security risks
None introduced. The change strictly removes a use-after-free by owning bytes that were previously borrowed. No new inputs are accepted, no validation is loosened, and the copy goes into an arena that already owns the surrounding E::String node and the rest of the define table.
Level of scrutiny
Low-to-medium. The mechanism is a textbook borrowed-slice-outlives-its-backing-storage UAF, confirmed by the ASAN trace in the description (freed by ... Map::put / read by best_quote_char_for_string). The fix is at the layer that creates the aliasing reference, uses the same helper (alloc_slice_copy) the file already uses for the --define JSON path (line 419), and satisfies the documented contract of EString::init in src/ast/e.rs ("data is arena-owned … bulk-freed"). There is no alternative design to weigh — copying into the arena that owns the node is the obvious and only correct shape here. Cost is one small copy per env var when --env=inline/PREFIX_* is on; the disable/load_all_without_inlining early-return means bun run startup is untouched.
Other factors
- I checked whether any other slice in
copy_env_for_definealiases the env map: the key is built into a localVecand passed toget_or_put_value(which must already own it, since the buffer is reused each iteration), so onlyvalueneeded the copy. - Tests follow the repo conventions closely:
Buffer.alloc(n, fill).toString()instead of.repeat,tempDir+using,{...bunEnv, HTTPS_PROXY: ...},Promise.all([stdout, stderr, exited]), stdout/stderr asserted beforeexitCode,test.concurrentfor the subprocess case, and.examplehostnames (no network). The dev-server test awaits the actual observable condition (fetches the rebuilt bundle) rather than sleeping. - The three comment-cop inline comments are resolved — the author trimmed the multi-line rationale to a single line in d1e4a28, which is what the diff shows.
- The known overlap with #37780 (per-build
Loadercopy forBun.build) is acknowledged in the description and the timeline; the two are complementary and whichever lands second rebases the shared test. That coordination does not block this change.
…d value Worker VMs build their define table with env inlining on, so a worker that assigns a proxy variable and then imports a module reading it hit the same freed entry. Add a test for that path, and document at Map::put (and get_http_proxy) that replacing a key frees the previous bytes; the RefCountedEnvValue doc still described the env map as borrowing them.
There was a problem hiding this comment.
LGTM — one-line UAF fix that copies env values into the define arena instead of aliasing the env map's Box<[u8]> storage.
What was reviewed:
env_string_store_putnowbump.alloc_slice_copy(value)before building theE::EString; same patternDefineData::parsealready uses for--definevalues in the same file, andbumpis the arena the define table itself lives in.- The
rare_data.rs/env_loader.rsedits are doc-comment corrections only (the oldRefCountedEnvValuecomment claiming the env map borrows its bytes was wrong —Map::putboxes its own copy). - New tests cover all three paths (dev server,
bun buildmacro,Bun.buildplugin), follow harness conventions, and per the status comment fail with ASAN heap-use-after-free on the unfixed build.
Extended reasoning...
Overview
Fixes a heap-use-after-free in env inlining (--env=inline / Bun.build({ env }) / [serve.static] env = "inline"). env_string_store_put in src/bundler/defines.rs previously built E::EString defines whose bytes pointed directly into the dotenv Loader map's Box<[u8]> values. Assigning HTTP_PROXY/HTTPS_PROXY/NO_PROXY on process.env routes through Bun__setEnvValue → Map::put, which drops the old Box<[u8]>, leaving the define pointing at freed memory. The fix is a single bump.alloc_slice_copy(value) so the bytes live in the same arena as the define table.
The other two source edits are doc comments: a warning on Map::put / get_http_proxy that put frees the old value, and a correction to the RefCountedEnvValue doc (the env map keeps its own copy, it does not borrow the .bytes slice — verified against Map::put and ProxyEnvSlots::sync_into, both of which Box::from(value)).
Four new tests: dev-server rebuild after a runtime proxy write, bun build with a macro that reassigns the proxy, Bun.build with a plugin onLoad that reassigns it, and a defensive worker test.
Security risks
None introduced. This closes a memory-safety bug (UAF surfacing freed allocator bytes into bundler output). The fix is a copy into an arena the define already depends on; no new untrusted input handling.
Level of scrutiny
Memory-safety fix, so I looked at the ownership carefully. The functional change is one line and matches the established pattern in the same file (DefineData::parse does bump.alloc_slice_copy(value_str) for user --define values). bump is the arena the E::EString node and the whole Define table are already allocated from, so the copied bytes have exactly the table's lifetime — no new leak, no lifetime mismatch. The key side is unaffected: copy_env_for_define builds key_buf locally and get_or_put_value copies it into the store.
Other factors
- No CODEOWNERS on the touched files.
- The comment-cop inline threads (about comment length in
defines.rs) are resolved; the current diff has a single-line comment. - Tests follow harness conventions (
tempDir,bunEnvspread, concurrent pipe drain viaPromise.all,exitCodeasserted last,Buffer.alloc(n, fill).toString()for large strings). Robobun's status comment confirms all three bundler tests fail with ASAN heap-use-after-free on the unfixed build and pass with the fix. - The worker test uses
toBeOneOfbecause whether the worker transpiler inlinesprocess.envreads is orthogonal; a finder flagged it as possibly vacuous but the verifier ruled that out — it's a defensive check that the value is one of the two real strings and not freed-memory garbage. - The overlap with #37780 is acknowledged in the description; the two are complementary and whichever lands second rebases
bundler_env.test.ts.
Problem
[serve.static] env = "inline",bun build --env=inline,Bun.build({ env: "inline" }), or a Worker), assigningHTTP_PROXY/HTTPS_PROXY/NO_PROXYonprocess.envafter the define table is built makes later output inline garbage such as"@z..\x03\x00\x00roxy-at-startup.example:8080/..."in place of the value.heap-use-after-freeinbun_js_printer::best_quote_char_for_string, freed byMap::putviaBun__setEnvValue.process.env.Xdefine pointed at the bytes held by the env map instead of owning them, and a proxy-variable write replaces and frees that map entry while the table is still in use.bun build(a macro can write mid-file),Bun.build(a pluginonLoadcan write mid-build) and Workers (built at spawn).Fix
--definevalues already do. (Bun.build: read env from a per-build copy instead of the VM's live loader #37780 separately givesBun.buildits own copy of the whole map; the two overlap only in tests.)bun run,bun testandBun.Transpilernever take this path.bun buildwith a macro,Bun.buildwith a plugin, Worker) fail on the released binary with the clobbered strings above, abort under ASAN on the unfixed debug build, and pass with the fix.Background
env = "inline"the bundler replaces everyprocess.env.Xread with a string literal. It builds a define table (one constant per variable) from the env map up front and reuses it for every module it prints.Loader). Mostprocess.envwrites stay in JS; the proxy variables are the exception and are written through to the native map, whereMap::putfrees the previous entry.Bun.build, the CLI) and freed with it, so anything copied into that arena has the same lifetime as the table.RefCountedEnvValueholds the last JS-assigned proxy value so later Workers inherit it; its comment said the env map borrowed those bytes, which stopped being true whenMap::putbegan copying, so the comment is corrected here.Original description
What
When env inlining is on,
copy_env_for_definebuilds oneprocess.env.Xdefine per variable.env_string_store_put(src/bundler/defines.rs) allocated theE::Stringnode in the define arena but left its bytes pointing into the dotenvLoader's map ("Value bytes alias the long-lived env-map storage"). That storage is not stable: assigningHTTP_PROXY/HTTPS_PROXY/NO_PROXY(or the lowercase variants) onprocess.envgoes throughBun__setEnvValue->Map::put, which replaces the entry and frees theBox<[u8]>the define points at. Every define table built before that write then prints freed memory the next time a module that reads the variable is parsed or printed.The define table outlives such writes in four places:
[serve.static] env = "inline"):Framework::init_transpilerrunsconfigure_definesonce whenBun.serve()starts and keeps the table for the life of the server. A runtimeprocess.env.HTTPS_PROXY = ...followed by any rebuild of a module that readsprocess.env.HTTPS_PROXYserves the freed bytes.bun build --env=inline: the macro VM shares the CLI transpiler's loader, so a macro assigning a proxy variable frees the bytes before the rest of the file is printed.Bun.build()/ non-HMR HTML routes: a pluginonLoad(JS thread) assigning the variable mid-build frees the bytes the bundle thread prints later.WebWorker::start_vmcallsconfigure_defineswith theTarget::Bundefault ofload_all(the main thread,bun test, the REPL and compiled executables switch toload_all_without_inliningfirst; workers do not, which is what Stop inlining process.env dot-reads in Worker-thread transpiles #34211 changes), so a worker's table is built from its own env map at spawn. A worker that assigns the variable and then imports a module reading it gets the freed bytes.Repro
Dev server (
bunfig.tomlhas[serve.static]/env = "inline",index.htmlloadsindex.tswhich doesconsole.log("v1", process.env.HTTPS_PROXY)), run withHTTPS_PROXY=http://proxy-at-startup.example:8080/aaaa...set:Release 1.4.0 inlines the freed buffer with the allocator's free-list word in its first bytes; the other three variants produce the same shape (
bun build --env=inlinewith a macro:"\x00\x00\x00\x00\x00\x00\x00\x00roxy-at-start.example:8080/..."; a worker posts back" \nA\x00\x02\x00\x00roxy-at-launch.example:8080/..."). The ASAN debug build reports, for all of them:Fix
env_string_store_putcopies the value intobumpbefore building theE::String.bumpis the arena the define table's nodes are already allocated from, so the bytes live exactly as long as the nodes that point at them:UserOptions.arenafor the dev server (dropped with the DevServer), the per-build arena forBun.build(dropped with the build), the CLI arena forbun build. This is also whatDefineData::parsedoes for--definevalues a few lines down, and whatE::EString::initdocuments as its contract (the data must be arena owned).A define is a snapshot of the environment at the time the table is built (that is what gets baked into the output), so owning the bytes is the semantics the code already intended; the old comment just assumed the map entries were immutable. The cost is one small copy per variable each time a table is built with inlining on. The main VM,
bun testandBun.Transpilernever reach this function, sobun runstartup is unaffected.Workers do reach it, and there the arena is the per-VM
transpiler_arena, which wraps the main mimalloc heap and is never bulk freed. The define nodes built at worker spawn already stayed allocated after the worker exited; with this change the value bytes do too, so a process that spawns workers repeatedly retains roughly the byte size of its environment per spawn on top of that (a few KB for a typical environment) until #34211 lands, which stops workers from inlining env at all and takes them off this path entirely. Leaving workers reading freed memory is the worse trade, and fixing the arena ownership or the inlining default is that PR's job rather than this one's.Two doc lines record the invariant where the next reader will look for it:
Map::putfrees the previous value (and JS can trigger it throughBun__setEnvValue), andget_http_proxyreturns a borrow of such an entry. TheRefCountedEnvValuedoc inrare_data.rsstill said the env map borrows those bytes, which has not been true sinceMap::putstarted boxing its own copy, so it is corrected as well. The two S3 call sites inblob/Store.rsthat hold aget_http_proxyborrow across JS-visible option reads are the same invariant in a different consumer and have been filed separately.#37780 is the complementary change for
Bun.build: it gives each build its own copy of the loader so the bundle thread stops reading the VM's live map at all (the map can also be mutated while the build is iterating it, which a byte copy does not address). This PR is what covers the dev server,bun buildand workers, where the table is built and later read on the same thread and only the aliased bytes were the problem. Each is correct on its own; both append tests tobundler_env.test.ts, so whichever lands second needs a trivial rebase, and theBun.buildplus plugin test here can be dropped at that point since #37780 carries the same case.Tests
test/bake/dev/bundle.test.ts: dev server with[serve.static] env = "inline"; checks the first bundle inlines the startup value, assignsprocess.env.HTTPS_PROXYthrough a route, rewritesindex.tsto force a rebuild, and checks the rebuilt bundle still inlines the startup value.test/bundler/bundler_env.test.ts:bun build --env=inlinewhere a macro assigns the variable (checks the output file and runs it), andBun.build({ env: "inline" })where a pluginonLoadassigns it mid-build.test/js/web/workers/worker.test.ts: a worker assignsHTTPS_PROXY, then imports a module that reads it; the value it posts back must be either the launch value or the assigned one (so the test stays valid once Stop inlining process.env dot-reads in Worker-thread transpiles #34211 makes workers read live), never the contents of the freed entry.All four fail on the released binary (
USE_SYSTEM_BUN=1, the clobbered strings above) and on the unfixed debug build (ASAN abort; the dev test reports "DevServer crashed while waiting for hot reload"), and pass with the fix. Also rantest/bake/dev/bundle.test.tsin full, theworker-envtests inworker.test.ts,bundler_splitting.test.ts,cli/run/env.test.tsandtranspiler/macro-test.test.tsagainst the debug build (four unrelated terminate-race tests inworker.test.tstime out on this machine's ASAN build with or without the new test; they pass on the release build and that file passed on the ASAN lane in CI for the earlier revision of this branch). Self-review surfaced the worker path and the stale docs; both are addressed above.