Skip to content

Fix integer overflow in ReadFile buffer capacity calculation#29205

Closed
robobun wants to merge 2 commits into
mainfrom
farm/c8061431/fix-readfile-size-overflow
Closed

Fix integer overflow in ReadFile buffer capacity calculation#29205
robobun wants to merge 2 commits into
mainfrom
farm/c8061431/fix-readfile-size-overflow

Conversation

@robobun

@robobun robobun commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer fingerprint: a83967f18cb850d8

What

Reading a non-regular file blob (e.g. /dev/null) that has been sliced to a length near maxInt(u52) panicked with an integer overflow on a thread pool worker.

Why

In ReadFile.runAsyncWithFD, when the underlying file is not a regular file and stat.size == 0, this.size is set to this.max_length (the slice length supplied by the user). The initial read buffer is then pre-allocated with capacity this.size + 16. Since size is a u52 and max_length can be any value up to Blob.max_size - 1, adding 16 overflows.

const sliced = Bun.file("/dev/null").slice(0, 4503599627370490);
await sliced.arrayBuffer(); // panic: integer overflow

Fix

Use saturating addition (+|) for the capacity hint. If the resulting allocation is too large it is handled by the existing catch path instead of crashing the process.

When reading a non-regular file blob (e.g. /dev/null) sliced to a length
near maxInt(u52), computing the initial buffer capacity as size + 16
overflowed the u52 SizeType. Use saturating addition so the allocation
attempt fails gracefully instead of panicking.
@robobun

robobun commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:23 PM PT - Apr 11th, 2026

@autofix-ci[bot], your commit a9cca37 has 1 failures in Build #45265 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29205

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

bun-29205 --bun

@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

A capacity-initialization expression in the blob read operation was changed to use the +| operator variant. A test was added (skipped on Windows) that slices a file at a very large offset near Blob.max_size and verifies arrayBuffer() either returns an empty buffer or an Error without crashing.

Changes

Cohort / File(s) Summary
Blob Read Implementation
src/bun.js/webcore/blob/read_file.zig
Modified the capacity calculation in ReadFile.runAsyncWithFD: the initCapacity second operand changed from this.size + 16 to `this.size +
Blob Read Tests
test/js/bun/util/bun-file-read.test.ts
Added imports for devNull and isWindows. Introduced a new test (skipped on Windows) that creates Bun.file(devNull), slices at an offset near Blob.max_size, calls arrayBuffer(), and asserts non-crashing behavior by accepting either an empty ArrayBuffer or an Error.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: fixing an integer overflow issue in ReadFile buffer capacity calculation, which matches the core modification in the Zig code.
Description check ✅ Passed The description comprehensively covers both required sections: 'What' explains the bug and its manifestation, and 'Why' details the root cause and the fix using saturating addition.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix integer overflow in ReadFile buffer capacity computation #28849 - Fixes the same this.size + 16 overflow in ReadFile.runAsyncWithFD using saturating addition (+|)
  2. ReadFile: avoid integer overflow when computing buffer capacity #29000 - Fixes the same overflow in ReadFile.runAsyncWithFD by casting to usize before addition
  3. blob: avoid size overflow when allocating ReadFile buffer #29008 - Fixes the same overflow in ReadFile.runAsyncWithFD by @intCast to usize and reordering the guard condition

🤖 Generated with Claude Code

@robobun

robobun commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #28849.

@robobun robobun closed this Apr 12, 2026
@robobun robobun deleted the farm/c8061431/fix-readfile-size-overflow branch April 12, 2026 04:07
Comment on lines 384 to 390

// add an extra 16 bytes to the buffer to avoid having to resize it for trailing extra data
if (!this.could_block or (this.size > 0 and this.size != Blob.max_size))
this.buffer = std.ArrayListUnmanaged(u8).initCapacity(bun.default_allocator, this.size + 16) catch |err| {
this.buffer = std.ArrayListUnmanaged(u8).initCapacity(bun.default_allocator, this.size +| 16) catch |err| {
this.errno = err;
this.onFinish();
return;

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.

🔴 When initCapacity fails with OOM in runAsyncWithFD, the catch block (line 388) only sets this.errno but leaves this.system_error null; since then() only checks system_error to decide whether to propagate an error, the OOM is silently swallowed and the caller receives an empty ArrayBuffer as success instead of a rejected promise. Before this PR the catch path was unreachable (the + 16 overflow would panic first), but the +| fix makes it reachable for the first time while exposing this pre-existing incomplete error handling — and the new test inadvertently masks the bug by accepting an empty ArrayBuffer as a valid outcome alongside an Error.

Extended reasoning...

What the bug is and how it manifests

In ReadFile.runAsyncWithFD, when std.ArrayListUnmanaged(u8).initCapacity fails with error.OutOfMemory, the catch block at lines 387–390 sets this.errno = err but never sets this.system_error. The then() function (lines 239–267) exclusively uses this.system_error to decide whether to return an error to the JS caller:

const system_error = this.system_error;
// ...
if (system_error) |err| {
    cb(cb_ctx, ReadFileResultType{ .err = err });
    return;
}
cb(cb_ctx, .{ .result = .{ .buf = buf, .total_size = total_size, .is_temporary = true } });

Because system_error is null after an OOM, then() takes the success path and invokes the callback with buf = this.buffer.items — an empty slice — delivering an empty ArrayBuffer to the JS caller as if the read succeeded.

The specific code path that triggers it

  1. A Bun.file("/dev/null").slice(0, N) where N is near maxInt(u52) is awaited as arrayBuffer().
  2. resolveSizeAndLastModified sets this.size = this.max_length (≈ 4.5 PB) because stat.size == 0 and could_block == true.
  3. this.size +| 16 saturates to maxInt(u52), so initCapacity attempts to allocate ~4 petabytes.
  4. The allocator returns error.OutOfMemory; the catch block sets only this.errno.
  5. then() sees system_error == null and calls cb with an empty buffer → success.

Why existing code doesn't prevent it

Before this PR, step 3 used this.size + 16 (wrapping/overflowing addition), which panicked at runtime before the allocator was ever called, making the catch block dead code. The +| fix is correct in intent but exposes the pre-existing incomplete error handling in the catch block. Every other OOM path in both ReadFile and ReadFileUV correctly sets both this.errno AND this.system_error (e.g., ReadFileUV.onFileInitialStat lines ~701-705, queueRead lines ~730-733, ~763-766). The initCapacity catch block is the sole exception.

Impact

An application reading a sliced non-regular file near max_size that would OOM the process receives a resolved promise with an empty ArrayBuffer instead of a rejection. This silently hides a critical resource-exhaustion condition, making it appear the file was empty rather than signalling a system error.

How to fix it

Mirror what ReadFileUV.onFileInitialStat does on allocation failure:

this.buffer = std.ArrayListUnmanaged(u8).initCapacity(bun.default_allocator, this.size +| 16) catch {
    this.errno = error.OutOfMemory;
    this.system_error = bun.sys.Error.fromCode(bun.sys.E.NOMEM, .read).toSystemError();
    this.onFinish();
    return;
};

Step-by-step proof

  1. Bun.file("/dev/null").slice(0, 4503599627370490)this.max_length ≈ maxInt(u52).
  2. fstat returns size=0, mode=character-device → this.size = this.max_length.
  3. initCapacity(allocator, maxInt(u52) +| 16) → OOM → catch fires.
  4. catch: this.errno = error.OutOfMemory; this.system_error stays null.
  5. onFinish() → eventually then() is called.
  6. then(): system_error == nullcb(cb_ctx, .{ .result = .{ .buf = &[][0]u8{}, ... } }).
  7. JS side receives a resolved promise with ArrayBuffer { byteLength: 0 } — no error thrown.
  8. The new test's if (result instanceof ArrayBuffer) { expect(result.byteLength).toBe(0); } branch passes, masking the incorrect behavior.

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