Skip to content

node:wasi: bounds-check guest pointers in every hostcall and return EOVERFLOW - #39100

Open
robobun wants to merge 1 commit into
mainfrom
farm/6a6d586f/wasi-hostcall-bounds
Open

robobun wants to merge 1 commit into
mainfrom
farm/6a6d586f/wasi-hostcall-bounds

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • node:wasi hostcalls dereference the pointers a guest passes them without checking that they fit in the guest's linear memory. Node returns the WASI errno EOVERFLOW (61) for every such pointer; on Bun 1.4.0 the outcome depends on which hostcall and which pointer (full table in the details below):
    • args_get, args_sizes_get, environ_get, environ_sizes_get, clock_res_get, clock_time_get, poll_oneoff and random_get are not wrapped in wrap(), so the RangeError: Out of bounds access from the DataView/Uint8Array escapes the import and unwinds through the running guest (the pointer-validation-guest.wasm test below dies this way on 1.4.0). The fd_*/path_* hostcalls are wrapped, but wrap() only converts WASIErrors and errors with a string code, so their RangeError escapes too.
    • args_get, environ_get, fd_prestat_dir_name, path_readlink and poll_oneoff write through Buffer#write or with a 16 byte stride, so a buffer that runs past the end of memory is silently truncated and the call returns success.
    • fd_fdstat_get, fd_filestat_get, path_filestat_get, fd_readdir, args_sizes_get, environ_sizes_get, poll_oneoff and path_readlink store field by field, so a struct that straddles the end of memory is partially written before the failure.
    • Path arguments are decoded with Buffer.from(buffer, ptr, len), whose error has a code, so every path_* hostcall reports an out-of-bounds path as EINVAL (28).
    • path_open stores the new descriptor last, so an out-of-bounds fd pointer opens the file (creating it under O_CREAT), registers it in FD_MAP and then fails: one leaked host descriptor per call, and the guest never learns the number. The probe in the details leaks 3 descriptors and creates a file on 1.4.0.
  • Cause: nothing in src/js/node/wasi.ts validates a guest pointer before use, except what node:wasi: return EOVERFLOW for out-of-bounds iovecs instead of throwing/logging #34468 adds (see the relationship bullet under Fix).

Fix

  • Adds CHECK_BOUNDS(ptr, len) next to CHECK_FD and calls it at the top of every hostcall that takes a pointer, once per pointer, with the size the hostcall will actually access: the fixed preview1 struct sizes (fdstat_t 24, filestat_t 64, prestat_t 8, u32/u64 outputs 4/8, subscription_t 48 and event_t 32 per entry for poll_oneoff), the guest-supplied length for paths and buffers, and for args_get/environ_get the same byte counts that args_sizes_get/environ_sizes_get report. The checks run after the fd checks and before the hostcall reads guest memory, calls into the filesystem or touches FD_MAP, so a rejected call has no side effects (the path_open leak included).
  • Wraps the 8 unwrapped hostcalls in wrap() so the WASIError becomes the errno like everywhere else. proc_exit/proc_raise/sched_yield/sock_* take no pointers and are unchanged.
  • args_get/args_sizes_get and environ_get/environ_sizes_get now share writeStringTable/writeStringTableSizes, so the sizes reported to the guest, the sizes checked, and the bytes written are computed from the same strings.
  • Why this is right: it is Node's behavior. node_wasi.cc runs uvwasi_serdes_check_bounds on every pointer argument before calling into uvwasi, and that function's rule is the one CHECK_BOUNDS implements: the start of the range has to lie inside memory even when the range is empty, and the range must not run past the end. Every row of the table below was checked against Node v26.3.0, including the accepted boundary (ranges that end exactly at the end of memory, and empty ranges on the last byte, still succeed) and the two edges that differ from a plain ptr + len <= size check (an empty range starting at the end of memory is rejected; a pointer or length that is not a u32, which is what a wasm guest delivers for an address >= 2**31, is rejected). The compiled guest in the test prints byte for byte the same output under Node.
  • Deliberately unchanged: the fd is still checked before the pointers, so a bad fd plus a bad pointer gives EBADF where Node gives EOVERFLOW (same call as node:wasi: return EOVERFLOW for out-of-bounds iovecs instead of throwing/logging #34468); fd_prestat_dir_name with an in-bounds buffer that is too short still truncates (Node returns ENOBUFS), and fd_readdir's own entry serialization can still run a few bytes past buf_len when an entry header does not fit; both are separate from pointer validation and are being reported separately, as is support for addresses >= 2 GiB in larger memories (previously a RangeError, now EOVERFLOW; the hostcalls would need to reinterpret the i32 as unsigned to actually address them).
  • Relationship to node:wasi: return EOVERFLOW for out-of-bounds iovecs instead of throwing/logging #34468: that PR fixes the iovec decoding shared by fd_read/fd_write/fd_pread/fd_pwrite (not touched here) and, as of its latest revision, also checks the fd_seek/fd_tell/path_open output pointers and adds a wrap() fallback that maps a RangeError to EOVERFLOW. The fallback does not reach the 8 hostcalls that are not wrapped, and it cannot undo the truncated or partial writes listed above, which is why the checks here run before the work; the two PRs overlap only on the bounds helper and the three output-pointer lines, and whichever lands second drops its copies.
  • Verification, all in test/js/bun/wasm/wasi.test.js:
    • A table of 51 out-of-bounds shapes covering all 25 pointer-taking hostcalls this PR changes (one bad pointer each, including ranges that straddle the end of memory, empty ranges at the end, a negative pointer and a negative length) expects 61 from every row, guest memory unchanged after each call, and FD_MAP and the preopened directory unchanged afterwards. On 1.4.0 all 51 rows differ.
    • A boundary test pins the accepted edge: 17 calls whose ranges end exactly at the end of memory succeed and write the expected values. This passes before and after; it guards against over-strict checks.
    • pointer-validation-guest.wasm (source in pointer-validation-guest.c, 1.2 KB, freestanding, build command in the header) reads argv/environ the way a libc start-up does, then makes seven calls across five hostcalls with the end-of-memory address or 0xfffffff0 and prints the errnos. Expected output is args: guest --flag / environ: K=v / errnos: 61 61 61 61 61 61 61; on 1.4.0 _start throws RangeError: Out of bounds access with nothing printed. Node v26.3.0 prints the same three lines.
    • bun bd test test/js/bun/wasm/wasi.test.js: 8 pass. With src/ stashed: 2 fail (the table and the guest), 6 pass. USE_SYSTEM_BUN=1: the same 2 fail.
    • oxlint, prettier --check and tsc --noEmit are clean on the touched files.

Background

  • A WASI preview1 hostcall is a JS function the runtime installs as a wasm import. Its pointer arguments are i32 offsets into the instance's linear memory (memory.buffer), and it reports failure by returning an errno number; a guest compiled against wasi-libc cannot catch a JS exception thrown out of an import, so an exception there terminates the program. Bun's implementation is src/js/node/wasi.ts (derived from wasi-js), where wrap() turns errors thrown inside a hostcall into errnos; Node's is node_wasi.cc on top of uvwasi, a C library.
  • args_get/environ_get fill two guest buffers whose sizes the guest first asks for via args_sizes_get/environ_sizes_get: a table of u32 pointers and the NUL-terminated strings they point at. poll_oneoff reads nsubscriptions 48 byte subscription records and writes up to that many 32 byte event records. path_open writes the new descriptor number through its last argument; FD_MAP is the per-instance table from guest descriptor numbers to host descriptors.
errno for each probed shape: Node v26.3.0 / Bun 1.4.0 / this branch

Probe setup: args: ["argv0", "b"], env: { K: "v" }, one preopen on fd 3 containing file.txt, link -> file.txt and subdir, file.txt opened on its own fd, a 1 page memory so END = 65536, OOB = END + 1000. "memory written" means guest memory differed after a call that did not return success.

case                                             node   bun 1.4.0                                this branch
args_get argv OOB                                61     throws RangeError                        61
args_get argvBuf OOB                             61     throws RangeError (memory written)       61
args_get argvBuf END-2 (needs 8)                 61     0 (memory written)                       61
args_get argv END-4 (needs 8)                    61     throws RangeError (memory written)       61
args_sizes_get argc OOB                          61     throws RangeError                        61
args_sizes_get size OOB                          61     throws RangeError (memory written)       61
args_sizes_get argc END-2                        61     throws RangeError                        61
environ_get environ OOB                          61     throws RangeError                        61
environ_get environBuf OOB                       61     throws RangeError (memory written)       61
environ_get environBuf END-2 (needs 4)           61     0 (memory written)                       61
environ_sizes_get count OOB                      61     throws RangeError                        61
environ_sizes_get size OOB                       61     throws RangeError (memory written)       61
clock_res_get OOB                                61     throws RangeError                        61
clock_res_get END-4                              61     throws RangeError                        61
clock_res_get bad clock + OOB                    61     throws Error                             61
clock_time_get OOB                               61     throws RangeError                        61
clock_time_get END-4                             61     throws RangeError                        61
clock_time_get bad clock + OOB                   61     28                                       61
fd_fdstat_get OOB                                61     throws RangeError                        61
fd_fdstat_get END-1 (needs 24)                   61     throws RangeError (memory written)       61
fd_fdstat_get END-23                             61     throws RangeError (memory written)       61
fd_filestat_get OOB                              61     throws RangeError                        61
fd_filestat_get END-8 (needs 64)                 61     throws RangeError (memory written)       61
fd_filestat_get END-63                           61     throws RangeError (memory written)       61
fd_prestat_get OOB                               61     throws RangeError                        61
fd_prestat_get END-4 (needs 8)                   61     throws RangeError                        61
fd_prestat_dir_name OOB                          61     28                                       61
fd_prestat_dir_name END len 1                    61     0                                        61
fd_prestat_dir_name END-1 len 2                  61     0 (memory written)                       61
fd_prestat_dir_name END len 0                    61     0                                        61
fd_prestat_dir_name ok len 0 (too short)         42     0                                        0
fd_readdir buf OOB                               61     throws RangeError                        61
fd_readdir buf END-8 len 256                     61     throws RangeError (memory written)       61
fd_readdir bufused OOB                           61     throws RangeError (memory written)       61
fd_readdir bufused END-2                         61     throws RangeError (memory written)       61
fd_tell OOB                                      61     throws RangeError                        61
fd_tell END-4                                    61     throws RangeError                        61
fd_seek OOB                                      61     throws RangeError                        61
path_create_directory path OOB                   61     28                                       61
path_create_directory path END-2 len 5           61     28                                       61
path_filestat_get path OOB                       61     28                                       61
path_filestat_get buf OOB                        61     throws RangeError                        61
path_filestat_get buf END-8                      61     throws RangeError (memory written)       61
path_filestat_set_times path OOB                 61     28                                       61
path_link old OOB                                61     28                                       61
path_link new OOB                                61     28                                       61
path_open path OOB                               61     28                                       61
path_open path END-2 len 8                       61     28                                       61
path_open fd OOB                                 61     throws RangeError                        61
path_open fd END-2                               61     throws RangeError                        61
path_open O_CREAT fd OOB                         61     throws RangeError                        61
path_readlink path OOB                           61     28                                       61
path_readlink buf OOB                            61     28                                       61
path_readlink buf END-2 len 100                  61     0 (memory written)                       61
path_readlink bufused OOB                        61     throws RangeError (memory written)       61
path_remove_directory path OOB                   61     28                                       61
path_rename old OOB                              61     28                                       61
path_rename new OOB                              61     28                                       61
path_symlink old OOB                             61     28                                       61
path_symlink new OOB                             61     28                                       61
path_unlink_file path OOB                        61     28                                       61
poll_oneoff in OOB                               61     throws RangeError                        61
poll_oneoff in END-16 (needs 48)                 61     throws RangeError                        61
poll_oneoff out OOB                              61     throws RangeError                        61
poll_oneoff out END-16 (needs 32)                61     0 (memory written)                       61
poll_oneoff out END-32                           0      0 (memory written)                       0 (memory written)
poll_oneoff nevents OOB                          61     throws RangeError (memory written)       61
poll_oneoff nevents END-2                        61     throws RangeError (memory written)       61
poll_oneoff nsubscriptions huge                  (see note) 28 (memory written)                      61
poll_oneoff nsub 0, in/out at END                61     0                                        61
random_get OOB                                   61     throws RangeError                        61
random_get END-2 len 4                           61     throws RangeError                        61
random_get END len 0                             61     0                                        61
random_get END-1 len 0                           0      0                                        0
random_get END-4 len 4                           0      0 (memory written)                       0 (memory written)
path_create_directory path END len 0             61     20                                       61
path_create_directory path END-1 len 0           28     20                                       20

On 1.4.0 the probe also ends with FD_MAP grown from 5 to 8 entries and new-entry created in the preopen (the three path_open rows); on this branch both are unchanged. The fd_prestat_dir_name ... too short and path_create_directory ... END-1 len 0 rows pass bounds checking and exercise behavior this PR leaves alone. Node allocates nsubscriptions records before bounds-checking, so the "nsubscriptions huge" row has no Node value (the process died with SIGSEGV).

…OVERFLOW

Every hostcall now validates each guest pointer against linear memory
before it reads or writes memory, touches the host filesystem or
registers a descriptor, and reports a pointer that does not fit as
WASI_EOVERFLOW, which is what Node (uvwasi) returns. Previously the
pointer was dereferenced as-is: most hostcalls threw a RangeError out of
the import into the running guest, args_get/environ_get,
fd_prestat_dir_name, path_readlink and poll_oneoff truncated the write
and reported success, fd_fdstat_get/fd_filestat_get wrote part of the
struct before failing, path pointers came back as EINVAL, and path_open
with a bad fd pointer opened (or created) the file and leaked the
descriptor in FD_MAP.

args_get, args_sizes_get, environ_get, environ_sizes_get, clock_res_get,
clock_time_get, poll_oneoff and random_get were not wrapped in wrap(), so
they are now, and the args/environ pairs share one implementation so the
sizes reported by *_sizes_get are exactly what *_get checks and writes.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f68dc6fb-7cd5-48cf-aa9f-605a0c496d04

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 3acd950.

⛔ Files ignored due to path filters (1)
  • test/js/bun/wasm/pointer-validation-guest.wasm is excluded by !**/*.wasm
📒 Files selected for processing (3)
  • src/js/node/wasi.ts
  • test/js/bun/wasm/pointer-validation-guest.c
  • test/js/bun/wasm/wasi.test.js

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; waiting on CI.

  • Reproduced on Bun 1.4.0 with a 93 shape probe of the 25 pointer-taking hostcalls (table in the PR body): Node v26.3.0 returns EOVERFLOW (61) for every out-of-bounds shape, Bun 1.4.0 throws a RangeError into the guest, truncates and reports success, returns EINVAL, or (for path_open) leaks the opened descriptor, depending on the hostcall.
  • Fix and tests are in this PR: test/js/bun/wasm/wasi.test.js gains a 51 row out-of-bounds table, an accepted-boundary test, and a compiled guest (pointer-validation-guest.wasm, source alongside it) that dies with RangeError: Out of bounds access on 1.4.0 and prints the Node output on this branch. 2 of the 3 new tests fail without the src/ change.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:wasi: return EOVERFLOW for out-of-bounds iovecs instead of throwing/logging #34468 - Adds an equivalent checkBounds helper in src/js/node/wasi.ts for the same purpose (return WASI_EOVERFLOW instead of letting a RangeError escape into the guest), and duplicates the path_open fdPtr, fd_seek, and fd_tell output-pointer checks.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #34468, though the two are adjacent and meant to land together. #34468 fixes the iovec decoding used by fd_read/fd_write/fd_pread/fd_pwrite (this PR does not touch those four) and adds a wrap() fallback that converts a RangeError into EOVERFLOW after the fact. This PR validates the pointers of the other 25 hostcalls before they do anything, which the fallback cannot provide:

  • args_get, args_sizes_get, environ_get, environ_sizes_get, clock_res_get, clock_time_get, poll_oneoff and random_get are not wrapped at all, so with node:wasi: return EOVERFLOW for out-of-bounds iovecs instead of throwing/logging #34468 alone they still throw into the guest (the compiled guest test here still dies on that branch).
  • args_get, environ_get, fd_prestat_dir_name, path_readlink and poll_oneoff never throw for a buffer that runs past the end of memory; they truncate and return success, so there is nothing for a fallback to map.
  • fd_fdstat_get, fd_filestat_get, path_filestat_get, fd_readdir and the *_sizes_get calls store field by field, so the fallback returns the errno after part of the struct has been written.

The intentional overlap is the bounds helper plus the fd_seek/fd_tell/path_open output pointer lines (three lines, so each PR is complete on its own); whichever lands second drops its copies. The 51 row table and the guest test in this PR fail on the #34468 branch for the reasons above.

@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 reviewed this PR and didn't find any bugs. Because it systematically touches ~25 WASI hostcalls on the guest/host sandbox boundary, refactors the args/environ paths, and adds a compiled .wasm binary artifact, a human look would still be worthwhile.

What was reviewed:

  • CHECK_BOUNDS matches uvwasi_serdes_check_bounds semantics (start-in-bounds even for len 0, u32-only, no overflow via byteLength - ptr); nsubscriptions * 48 overflow is caught by the len >>> 0 === len clause.
  • Every check is placed before the first guest-memory read, fs call, or FD_MAP/stats.offset mutation, so rejected calls have no side effects (verified for path_open, fd_seek, fd_filestat_get).
  • The writeStringTable/writeStringTableSizes refactor preserves the old byte-count arithmetic (NUL now inside the string vs. + 1 before).
  • Wrapping clock_res_get/poll_oneoff in wrap() doesn't change their non-WASIError throw paths (wrap re-throws those); test rows work on Windows because CHECK_BOUNDS fires before any symlink-dependent fs call.
Extended reasoning...

Overview

This PR adds guest-pointer bounds checking to every pointer-taking hostcall in src/js/node/wasi.ts (Bun's node:wasi shim, derived from wasi-js). It introduces a CHECK_BOUNDS(ptr, len) helper mirroring Node's uvwasi_serdes_check_bounds, calls it once per pointer at the top of ~25 hostcalls with the correct preview1 struct sizes, wraps the 8 previously-unwrapped hostcalls in wrap() so the thrown WASIError becomes an errno, and refactors args_get/environ_get and their _sizes_get twins to share writeStringTable/writeStringTableSizes so reported sizes, checked sizes, and written bytes derive from one source. Tests add a 51-row OOB table asserting EOVERFLOW with no memory/FD_MAP/directory side effects, a 17-call boundary test pinning the accepted edge, and a compiled freestanding wasm guest (source + 1.2 KB binary) exercising the end-to-end path.

Security risks

WASI is a sandbox boundary: the guest's only access to the host is through these hostcalls, and pointer arguments are guest-controlled offsets into linear memory. The change is strictly a hardening — it adds validation where there was none, and the checks are ordered before any host-fs call or FD_MAP mutation, closing the path_open fd leak the PR documents. I checked that no check was placed after a side effect it should guard, and that the >>> 0 guards reject the negative-i32 shape a wasm guest produces for addresses ≥ 2³¹. The nsubscriptions * 48 product in poll_oneoff cannot wrap into a small accepted value because JS numbers don't wrap and the len >>> 0 === len clause rejects anything outside [0, 2³²). No new attack surface is introduced; the risk is regression (an over-strict check rejecting a valid call), which the boundary test guards against.

Level of scrutiny

High. This is a systematic change across the entire WASI hostcall surface in a Node-compat module, it sits on a security boundary, it refactors four hostcalls' bodies, and it ships a binary .wasm artifact whose contents can only be verified by rebuilding from the provided C source. Per the repo's Node/Web-compat guidance, behavior here is defined by Node's node_wasi.cc, and while the PR description cross-references every row against Node v26.3.0, a maintainer should confirm the struct sizes (fdstat 24, filestat 64, prestat 8, subscription 48, event 32) and the decision to keep CHECK_FD before CHECK_BOUNDS (documented divergence from Node's ordering).

Other factors

The test coverage is unusually thorough — every changed hostcall has at least one OOB row, memory-unchanged and FD_MAP-unchanged are asserted, and the boundary test prevents over-tightening. The PR description explicitly scopes out three adjacent issues (fd_prestat_dir_name ENOBUFS, fd_readdir header overrun, ≥2 GiB addressing) and the overlap with #34468. I found no correctness issues, but the breadth of the change and the binary artifact push this past what I'd auto-approve.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Two pointers for whoever takes the human look:

  • The .wasm is reproducible from the .c next to it with the command in its header (clang --target=wasm32 -Oz -nostdlib -Wl,--strip-all -Wl,-z,stack-size=4096); clang 17 and clang 21 both produce the checked-in bytes, sha256 18d65a549281506b1e7dd0ee7528d46a9ca008ae489ffc08939a5d506e5f866d. Its only imports are the eight hostcalls declared in the source (WebAssembly.Module.imports lists them), and it exports memory and _start.
  • The struct sizes are the preview1 ABI sizes that wasi-libc static-asserts in its api.h (fdstat_t 24, filestat_t 64, prestat_t 8, subscription_t 48, event_t 32); the first four are also what the existing write sequences in wasi.ts already assume (the poll_oneoff loop checks for a 48 byte subscription stride itself). event_t is the one size not visible in the current code, since the loop fills 16 bytes of each 32 byte record; node: implement node:wasi v26 WASI class surface (+23 tests) #35709 and a separate fix make it write the full record, and guests allocate 32 per subscription either way.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 AM PT - Aug 15th, 2026

❌ @robobun, your commit 3acd950 has some failures in Build #98238 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39100

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

bun-39100 --bun

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