Skip to content

Reject interior null bytes in Bun.mmap, Glob.scan, and FileSystemRouter paths - #33181

Open
robobun wants to merge 2 commits into
mainfrom
farm/db55f617/nul-byte-path-args
Open

Reject interior null bytes in Bun.mmap, Glob.scan, and FileSystemRouter paths#33181
robobun wants to merge 2 commits into
mainfrom
farm/db55f617/nul-byte-path-args

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

Bun.mmap(path), Glob.prototype.scan / scanSync (the pattern and the cwd option, in both object and string form), and new Bun.FileSystemRouter({ dir }) hand their path arguments to C-string syscalls without checking for interior NUL bytes. A C path stops at the first NUL, so the OS operates on a different, shorter path than the one JS-level code saw and validated.

Bun.file() and node:fs already reject these inputs with ERR_INVALID_ARG_VALUE; these three APIs missed the check. Debug builds trip the ZStr::as_cstr: interior NUL would truncate the C view assertion instead of truncating.

Reproduction on Bun 1.4.0:

import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
const base = mkdtempSync("/tmp/nul-");
mkdirSync(base + "/safe"); mkdirSync(base + "/secret");
writeFileSync(base + "/safe/inside.ts", "");
writeFileSync(base + "/secret/leaked.ts", "");

// JS-level resolution of this cwd yields <base>/secret; the syscall got <base>/safe.
console.log([...new Bun.Glob("*.ts").scanSync({ cwd: base + "/safe\0/../secret" })]);
// => [ "inside.ts" ]   (the contents of safe/, not secret/)

Bun.mmap(base + "/safe/inside.ts\0.png");                          // maps inside.ts, no error
new Bun.FileSystemRouter({ style: "nextjs", dir: base + "\0x" });  // routes <base>
Bun.file(base + "/safe/inside.ts\0.png").size;                     // ERR_INVALID_ARG_VALUE (correct)

Fix

Generalize the existing Valid::path_null_bytes helper (the one Bun.file and node:fs use) to take the argument name, and call it at the three JS-to-native entry points:

  • Bun.mmap: the path argument
  • Glob.scan / Glob.scanSync: the cwd option and the pattern
  • new Bun.FileSystemRouter: the dir option

Each now throws the same error shape as Bun.file:

TypeError [ERR_INVALID_ARG_VALUE]: The argument 'cwd' must be a string without null bytes. Received "/tmp/a\u0000b"

Intentionally unchanged: Glob.prototype.match and the Glob constructor. match() is a pure string matcher that never touches the filesystem, so the pattern is only validated once scan/scanSync turn it into a path.

Tests

Added to each API's existing test file (mmap.test.js, glob/scan.test.ts, filesystem_router.test.ts). All seven fail on the unfixed build; the glob pattern one fails by listing the truncated directory's contents:

Received value: [ "/tmp/glob-nul-pattern-sync_zbAZud/secret\u0000trailer/leaked.ts" ]

Also made the pre-existing filesystem_router.test.ts hash-collision test deterministic: its 600k-iteration birthday search alone exceeded the 5s default per-test timeout under debug+ASAN. The precomputed pair keeps an explicit hash32(a) === hash32(b) assertion so the test still fails, rather than going vacuous, if wyhash ever changes.

@github-actions github-actions Bot added the claude label Jul 1, 2026
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:18 PM PT - Jul 6th, 2026

@robobun, your commit 3b69257 has some failures in Build #69255 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33181

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

bun-33181 --bun

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82bf8f4e-1c6d-4f9c-a186-6e9e2daaac50

📥 Commits

Reviewing files that changed from the base of the PR and between d816daf and fa02620.

📒 Files selected for processing (7)
  • src/runtime/api/BunObject.rs
  • src/runtime/api/filesystem_router.rs
  • src/runtime/api/glob.rs
  • src/runtime/node/types.rs
  • test/js/bun/glob/scan.test.ts
  • test/js/bun/util/filesystem_router.test.ts
  • test/js/bun/util/mmap.test.js

Walkthrough

This PR adds null-byte validation for path and pattern inputs across Bun's filesystem APIs. A new reusable Valid::no_null_bytes helper is introduced and wired into Bun.mmap, FileSystemRouter, and Glob (for cwd and pattern arguments), with corresponding tests validating rejection behavior.

Changes

Null-byte validation

Layer / File(s) Summary
Reusable validation helper
src/runtime/node/types.rs
Refactors Valid::path_null_bytes to delegate to a new parameterized Valid::no_null_bytes(slice, name, accepts, global) helper that centralizes interior-NUL detection and error message formatting.
Bun.mmap path validation
src/runtime/api/BunObject.rs, test/js/bun/util/mmap.test.js
Adds a null-byte check on the path argument in mmap_file after the max-length check, with a test asserting Bun.mmap throws ERR_INVALID_ARG_VALUE for NUL-containing paths.
FileSystemRouter dir validation
src/runtime/api/filesystem_router.rs, test/js/bun/util/filesystem_router.test.ts
Validates the dir option for null bytes in FileSystemRouter::constructor, adds a regression test for rejection, and replaces a probabilistic hash-collision test with fixed strings and a wyhash-based equality check.
Glob cwd/pattern validation
src/runtime/api/glob.rs, test/js/bun/glob/scan.test.ts
Validates cwd in ScanOpts::parse_cwd and pattern in Glob::make_glob_walker for null bytes, with tests covering scanSync/scan rejection and Glob.match behavior on NUL-containing inputs.

Possibly related PRs

  • oven-sh/bun#31606: Both PRs modify ScanOpts::parse_cwd in src/runtime/api/glob.rs to add early validation guards on the cwd input.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: rejecting interior NUL bytes in the affected path APIs.
Description check ✅ Passed The description covers the problem, fix, and verification thoroughly, though it uses custom section headings instead of the template labels.
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.

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

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

LGTM — straightforward application of the existing Valid::path_null_bytes pattern to three more path-taking entry points, with thorough tests.

Extended reasoning...

Overview

This PR closes a null-byte path-truncation gap in three Bun APIs (Bun.mmap, Glob.scan/scanSync, Bun.FileSystemRouter) by adding the same interior-NUL check that Bun.file() and node:fs already perform. The change generalizes Valid::path_null_bytes into Valid::no_null_bytes(slice, name, accepts, global) (the old function becomes a thin wrapper with identical output), then adds four one-line calls at the JS→native boundaries. Tests are added to each API's existing test file, plus a drive-by speedup of an unrelated slow test that keeps its guarding assertion.

Security risks

The change is security-hardening: it adds fail-closed validation, never removes any. The refactor of path_null_bytes is verifiably behavior-preserving — same ERR_INVALID_ARG_VALUE code, byte-identical message for existing callers. The only observable behavior change is that previously-truncated paths (which are never legitimate) now throw. There is no new attack surface.

Level of scrutiny

Low-to-moderate. The production diff is ~30 lines: one helper refactor plus four validation calls placed before any allocation or side effect on their respective paths, so the new early-return error paths don't leak (ZigStringSlice/OwnedString locals drop normally). This is the mechanical extension of an established, already-reviewed pattern to sibling call sites — exactly the "fix the whole class" shape the repo guidelines ask for.

Other factors

Test coverage is thorough: sync/async, string-form and object-form cwd, the glob pattern itself, and a negative test confirming Glob.match() (pure string matcher, no FS access) intentionally still treats NUL as a plain character. The hash-collision test speedup replaces a 600k-iteration birthday search with a precomputed pair but retains the hash32(a) === hash32(b) assertion so it can't go vacuous if wyhash changes. No prior reviewer comments; bug hunter found nothing.

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

LGTM — straightforward input validation using the existing Valid helper, with tests at every entry point.

Extended reasoning...

Overview

This PR adds interior-NUL-byte rejection to three Bun-native path-taking APIs (Bun.mmap, Glob.scan/scanSync, FileSystemRouter constructor) that previously passed user strings to C syscalls unchecked. The existing Valid::path_null_bytes helper is generalized into Valid::no_null_bytes(slice, name, accepts, global) so callers can name their argument in the error message, and the original wrapper delegates to it with the exact same message text — so Bun.file / node:fs behavior is byte-for-byte unchanged. Four one-line validation calls are inserted at the JS→native boundaries, each with a corresponding test that asserts both the error code and message substring. A tangential change makes the wyhash-collision router test deterministic (precomputed pair + explicit hash32(a) === hash32(b) guard) to fix a debug+ASAN timeout.

Security risks

None introduced; this is security hardening. The change is purely additive fail-closed validation that closes a path-truncation confused-deputy vector (JS-level checks see "safe\0/../secret", the kernel sees "safe"). No security check is removed or weakened. Error paths return early via ? before any allocation or side effect that would need unwinding — in __scan the pending-activity counter is only incremented after make_glob_walker succeeds, and in filesystem_router the check runs before the arena/log setup.

Level of scrutiny

Medium — it touches path validation, which is security-adjacent, but the change shape is mechanical: reuse the same centralized helper the rest of node:fs already routes through, at the same layer (JS argument parsing, before any syscall). No new parsing logic, no control-flow changes to happy paths, no cross-platform branching. The refactored helper's fast path (index_of_char(slice, 0).is_none()) is identical to before.

Other factors

Tests are placed in the existing per-API files, cover both the object and string forms of cwd, both sync/async scan, assert the specific error code, and include a negative test confirming Glob.match (pure string matcher) still accepts NULs. The PR description confirms all seven tests fail on the unfixed build. The hash-collision test rewrite keeps an explicit collision assertion so it can't go vacuous. No outstanding reviewer comments; bug-hunting system found nothing.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and force-pushed (3b69257). The only merge conflict was in filesystem_router.test.ts: main had independently renamed a variable in the wyhash-collision test and added a 60_000 per-test timeout to work around the same slowness this PR's drive-by fix addresses. I kept the deterministic precomputed-pair rewrite (it no longer needs the inflated timeout) and adopted main's clearer collidingSegment name. All seven NUL-byte tests plus the collision test pass locally on the rebuilt debug binary.

CI status across the runs on this PR. Every run passes the full matrix except for jobs that fail on infrastructure or documented cross-PR flakes, none of which touch this change:

  • Build 67548 and 67569 (pre-rebase): 282/286 pass. Failures were test-net-connect-memleak.js (CI: test-net-connect-memleak.js fails on half of PR builds on linux-x64-musl since June 28 ~23:00 UTC #33044, fails on ~half of all PR builds), two macOS 90s timeouts in terminal.test.ts / test/regression/issue/20965.test.ts (red on other PRs too), a MySQL Docker-readiness timeout in the harness, and one macOS agent dying mid-run (uv_os_get_passwd ENOENT).
  • Build 69255 (post-rebase, final): 283 pass, 2 expired, 1 fail, zero error annotations. The single failure is on darwin 26 aarch64 and is a Buildkite artifact-download timeout (buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun') — no tests ran on that lane; the runner could not fetch the bun binary. The two expired jobs are darwin-14 test shards that never left the queue.

The three test files this PR adds to (test/js/bun/util/mmap.test.js, test/js/bun/glob/scan.test.ts, test/js/bun/util/filesystem_router.test.ts) passed on every lane in every run.

The change itself is four validation calls routed through one shared helper. It is ready for a maintainer.

robobun added 2 commits July 6, 2026 19:35
…in path arguments

Bun.mmap(path), Glob.scan/scanSync (both the pattern and the cwd
option), and new FileSystemRouter({ dir }) passed their path arguments
through to C-string syscalls without checking for interior NUL bytes.
C paths stop at the first NUL, so a string that passed JS-level
validation reached the OS as a different, shorter path (CWE-158). For
example:

  new Bun.Glob("*.ts").scanSync({ cwd: safe + "\0/../secret" })

scans `safe/`, while every JS-level check (and path.resolve) saw a path
resolving to `secret/`. Bun.file() and node:fs already reject these
inputs with ERR_INVALID_ARG_VALUE.

Generalize the existing Valid::path_null_bytes helper (the one
Bun.file uses) to take the argument name, and call it from the three
entry points. Glob's match() and constructor are intentionally
unchanged: match() never touches the filesystem, so the pattern is only
validated when scan/scanSync turn it into a path.

Also make the pre-existing FileSystemRouter 32-bit-hash-collision test
deterministic; its 600k-iteration birthday search alone exceeded the
default per-test timeout on debug+ASAN builds.
@robobun
robobun force-pushed the farm/db55f617/nul-byte-path-args branch from 620a625 to 3b69257 Compare July 6, 2026 19:42

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

LGTM — small, well-tested tightening that routes four path entry points through the existing NUL-byte validator.

Extended reasoning...

Overview

This PR adds interior-NUL-byte rejection to three Bun-native path-taking APIs (Bun.mmap, Glob.scan/scanSync cwd + pattern, new Bun.FileSystemRouter({ dir })) by generalizing the existing Valid::path_null_bytes helper into a parameterized Valid::no_null_bytes(slice, name, accepts, global) and calling it at four JS→native entry points. The refactor of path_null_bytes is a pure delegation with identical error code and message. Seven new tests are added to the existing per-API test files, plus a drive-by determinism fix to the wyhash-collision test in filesystem_router.test.ts (precomputed collision pair with an explicit hash32(a) === hash32(b) guard, replacing a 600k-iteration birthday search that timed out under debug+ASAN).

Security risks

This is a security-hardening change that tightens validation (fail-closed): previously, a NUL-embedded path could reach the syscall truncated, so JS-level validation and the actual filesystem operation disagreed on which path was used. The fix aligns these APIs with the existing behavior of Bun.file() and node:fs, using the same ERR_INVALID_ARG_VALUE error shape. There is no loosening of any check, no new attack surface, and no change to any TLS/auth/crypto path. The only behavioral change is that inputs which were previously silently truncated now throw — which is the desired outcome.

Level of scrutiny

Low-to-medium. The native diff is four one-line validator calls plus a small helper refactor that is trivially behavior-preserving for the existing path_null_bytes callers (same INVALID_ARG_VALUE code, same message text). Each call site is placed after the string is materialized and before it reaches path resolution, and the ? early-return relies on ordinary Rust Drop for the just-created slice locals — no manual cleanup is bypassed. Glob.match() is intentionally left alone (pure string matcher, never touches the FS), and a test pins that contract.

Other factors

  • No CODEOWNERS coverage on the touched files.
  • Bug-hunting system found nothing.
  • CI: the three modified test files passed on every lane across two full-matrix runs; the four failing jobs are documented cross-PR flakes (#33044, terminal.test.ts macOS timeout, MySQL docker readiness) unrelated to this change.
  • The wyhash test rewrite is a strict improvement: it keeps the collision precondition asserted, so it cannot go vacuous if the hash changes, and drops a 60s per-test timeout.
  • Tests cover both the object and string forms of cwd, both scan and scanSync, and assert both the error code and the message substring.

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