Skip to content

install: skip overlong PAX paths in streaming tarball extraction - #36533

Closed
robobun wants to merge 4 commits into
mainfrom
claude/farm/d8b49829/tarball-stream-overlong-pax-path
Closed

install: skip overlong PAX paths in streaming tarball extraction#36533
robobun wants to merge 4 commits into
mainfrom
claude/farm/d8b49829/tarball-stream-overlong-pax-path

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

TarballStream::begin_entry copies each entry's pathname into a fixed-size OSPathBuffer via normalize_buf_t without checking the input length first. PAX extended headers can carry paths of arbitrary length, so a crafted registry tarball with a path longer than the buffer aborts the install with:

panic: range end index 40000 out of range for slice of length 4096

The buffered extractor (Archiver::extract_to_dir) already has this guard at src/libarchive/lib.rs:1497; this adds the same check to the streaming path and skips the entry, matching the behaviour for other unrepresentable entries (empty, ./.., absolute on Windows).

Why

The streaming extractor is the default for any registry tarball above 2 MiB, so a malicious package can crash bun install for anyone depending on it. Since the Rust port it is a clean panic/abort (DoS only, not memory corruption), but it still takes down the whole install.

This adopts the remaining piece of #31160 by @pc-style. The buffered-path length guard from that PR landed separately in #31339, and the leading-.. guard for the streaming path was already present; only the streaming-path length guard remained.

How verified

New test in test/cli/install/bun-install-streaming-extract.test.ts serves a tarball containing a 40 000-character PAX path (longer than OSPathBuffer on every platform) from a drip-feed local registry so the streaming extractor commits to it, then asserts the install completes, the entry following the overlong one is extracted correctly, and the Streamed verbose line confirms the streaming path ran.

Without the fix (bun bd with src/ stashed) the spawned install aborts with the panic above and the test fails; with the fix the full file (10 tests) passes.


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-streaming-extract.test.ts
bun test v1.4.0 (9ff04fdd8)

test/cli/install/bun-install-streaming-extract.test.ts:
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (streaming) [994.60ms]
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (buffered) [508.96ms]
(pass) streaming tarball extraction > streaming extract succeeds when the first chunk is smaller than the gzip header [405.89ms]
(pass) streaming tarball extraction > tarballs below BUN_INSTALL_STREAMING_MIN_SIZE take the buffered path [487.45ms]
417 |     // The crafted tarball is small; lower the threshold so the streaming
418 |     // extractor commits to it instead of falling back to the buffered path.
419 |     const { stderr, exitCode } = await runInstall(String(dir), {
420 |       BUN_INSTALL_STREAMING_MIN_SIZE: "1",
421 |     });
422 |     expect(stderr).toContain("Streamed ");
                         ^
error: expect(received).toContain(expected)

Expected to contain: "Streamed "
Received: "Enque
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (f7354c45b)

test/cli/install/bun-install-streaming-extract.test.ts:
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (streaming) [48.24ms]
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (buffered) [29.20ms]
(pass) streaming tarball extraction > streaming extract succeeds when the first chunk is smaller than the gzip header [131.74ms]
(pass) streaming tarball extraction > tarballs below BUN_INSTALL_STREAMING_MIN_SIZE take the buffered path [40.06ms]
(pass) streaming tarball extraction > streaming extract skips an overlong PAX path instead of crashing [15.07ms]
(pass) streaming tarball extraction > streaming rejects a tarball whose integrity does not match [18.15ms]
(pass) streaming tarball extraction > drain threshold holds off extraction until enough bytes arrive (default) [292.28ms]
(pass) streaming tarball extraction > drain threshold holds off extraction until enough bytes arrive (override) [287.88ms]
(pass) buffered extract: damaged-block retry resets header state (upstream semantics) [5.80ms]
(pass) buffered extract rejects a registry tarball whose decompressed size exceeds the li
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-streaming-extract.test.ts
bun test v1.4.0 (9ff04fdd8)

test/cli/install/bun-install-streaming-extract.test.ts:
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (streaming) [1431.88ms]
(pass) streaming tarball extraction > extracts a drip-fed tarball correctly (buffered) [649.68ms]
(pass) streaming tarball extraction > streaming extract succeeds when the first chunk is smaller than the gzip header [502.36ms]
(pass) streaming tarball extraction > tarballs below BUN_INSTALL_STREAMING_MIN_SIZE take the buffered path [631.39ms]
(pass) streaming tarball extraction > streaming extract skips an overlong PAX path instead of crashing [551.60ms]
(pass) streaming tarball extraction > streaming rejects a tarball whose integrity does not match [504.40ms]
(pass) streaming tarball extraction > drain threshold holds off extraction until enough bytes arrive (default) [690.77ms]
(pass) streaming tarball extraction > drain threshold holds off extraction until enough bytes arrive (override) [612.84ms]
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 754ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] cc obj/packages/bun-usockets/src/context.c.o
[2/7] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/s
... (truncated)
diff hotspot
src/install/TarballStream.rs                       |  7 +++
 src/libarchive/lib.rs                              |  4 +-
 .../install/bun-install-streaming-extract.test.ts  | 53 ++++++++++++++++++++++
 3 files changed, 63 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                    reads  edits  tests
src/install/TarballStream.rs                                3      4      0
src/libarchive/lib.rs                                       2      2      0
test/cli/install/bun-install-streaming-extract.test.ts      1      3      0

TarballStream::begin_entry copies each entry's pathname into a fixed-size
OSPathBuffer via normalize_buf_t without checking the input length. PAX
extended headers can carry paths of arbitrary length, so a crafted
registry tarball with a path longer than the buffer (4096 bytes on Linux,
1024 on macOS, 32767 u16 units on Windows) aborts the install with a
slice-index panic.

The buffered extractor (Archiver::extract_to_dir) already has this guard;
add the same check to the streaming path and skip the entry, matching the
behaviour for other unrepresentable entries.

Adopts the remaining piece of #31160; the buffered-path guard and the
leading-.. guard for the streaming path have already landed separately.

Co-authored-by: pc-style <pcstyle@duck.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 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: 6 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: 11ad91b6-d427-43c6-9dfd-996de75a7be4

📥 Commits

Reviewing files that changed from the base of the PR and between 21932c3 and 9ff04fd.

📒 Files selected for processing (3)
  • src/install/TarballStream.rs
  • src/libarchive/lib.rs
  • test/cli/install/bun-install-streaming-extract.test.ts

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

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on a release build by serving a tarball with a 40 000-byte PAX path from a local drip-feed registry (so TarballStream commits to the download instead of falling back to the buffered extractor):

panic: range end index 40000 out of range for slice of length 4096

With the guard the overlong entry is skipped and the install completes; the entry placed after it in the tarball is still extracted correctly.

Follow-up from self-review: tightened the bound by one in both extractors (normalize_buf_t can return one character more than its input on Windows for a bare UNC volume name or drive letter), and added a directory-listing assertion so the test proves the overlong entry produced no on-disk artifact.

CI: the new test passes on every lane in build 86128. The remaining red is unrelated flake (bitbucket.org TLS handshake in bun-install.test.ts, GC-timing in napi.test.ts on Windows aarch64, and several parallel-batch tests that passed when rerun alone). Ready for a maintainer to merge.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(install): guard against overlong PAX paths in tarball extraction #31160 - Also fixes the overlong PAX path crash in streaming tarball extraction by adding a bounds check before normalize_buf_t

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Intentional overlap with #31160: this adopts the remaining piece of that PR (the streaming-path length guard), rebased onto current main. The buffered-path length guard from #31160 already landed via #31339, and the streaming-path leading-.. check was already present, so only this hunk was outstanding. Co-authored-by credit is on the commit.

@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 — mirrors the existing overlong-path guard from the buffered extractor into the streaming path.

What was reviewed:

  • Guard placement matches Archiver::extract_to_dir at src/libarchive/lib.rs:1497 (>= reserves the NUL slot written at norm_buf[norm_len] = 0).
  • Skip path (phase = WantData; out_fd = None; return Ok(())) is identical to the other skip branches in begin_entry (empty, ., leading .., Windows absolute).
  • Test forces the streaming path via BUN_INSTALL_STREAMING_MIN_SIZE=1, asserts the Streamed verbose line, and checks the entry after the overlong one is extracted — so a broken state-machine reset would fail it.
Extended reasoning...

Overview

Adds a single length guard in TarballStream::begin_entry (src/install/TarballStream.rs) that skips tarball entries whose path (after stripping the leading package/ component) is at least as long as the fixed-size OSPathBuffer. Without it, normalize_buf_t panics with a slice-bounds error on PAX-header paths longer than the buffer, aborting the whole install. Also adds a regression test in test/cli/install/bun-install-streaming-extract.test.ts.

Security risks

The change removes a DoS vector: a malicious registry package with a >4096-byte (Linux) or >32767-unit (Windows) PAX path could crash any bun install that streams it. The skip behavior is identical to what the buffered extractor already does at src/libarchive/lib.rs:1497 and to the other unrepresentable-entry branches in the same function, so no new attack surface is introduced. The overlong entry's data is drained via WantData with out_fd = None, so nothing is written to disk.

Level of scrutiny

Low-to-medium. The Rust change is 5 lines that copy an existing, cited guard from the sibling extractor into the streaming path, using the same comparison (>=) and the same skip idiom used throughout begin_entry. norm_buf.len() on OSPathBuffer is the compile-time array length, matching how the buffered path uses normalized_buf.len().

Other factors

The test follows the file's established harness (makeRegistry, runInstall, tempDir, bunEnv), uses a 40 000-char hash-chained hex path so it exceeds the buffer on every platform and gzip can't collapse it, forces streaming with BUN_INSTALL_STREAMING_MIN_SIZE=1, and asserts both the Streamed verbose marker and that the entry placed after the overlong one lands correctly (proving the per-entry state machine recovered). PR description confirms the test fails without the fix and the full file passes with it. No outstanding reviewer comments; no prior claude reviews.

normalize_buf_t can return one character more than its input on Windows
(a bare UNC volume name gains a trailing separator; bare C: becomes C:.),
so a crafted path of length OSPathBuffer.len()-1 would still overflow the
trailing-NUL write. Reserve one slot of headroom in both the streaming
and buffered extractors, and assert the overlong entry produced no
on-disk artifact in the test.
Comment thread src/install/TarballStream.rs Outdated
Comment thread src/libarchive/lib.rs Outdated
Comment thread src/install/TarballStream.rs
Comment thread src/libarchive/lib.rs

@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 defensive guard that mirrors the existing buffered-extractor check, with a solid regression test.

What was reviewed:

  • The new length check in begin_entry uses the same skip pattern (phase = WantData; out_fd = None; return Ok) as every other unrepresentable-entry case in that function.
  • Bound math: rest.len() + 1 >= norm_buf.len() leaves room for the +1 Windows UNC/drive growth plus the trailing NUL write; the buffered-path tightening is conservative-only.
  • Test: 40 000-char hex path (no /) survives tokenize_rest_after_first at full length; asserts the streaming path ran, the following entry extracted, and readdirSorted shows no truncated-name artifact.
Extended reasoning...

Overview

Adds a length guard in TarballStream::begin_entry before normalize_buf_t copies a PAX-supplied path into the fixed-size OSPathBuffer, and tightens the equivalent existing guard in Archiver::extract_to_dir by one to account for the Windows UNC/drive growth plus the NUL terminator written immediately after normalization. A new test in bun-install-streaming-extract.test.ts serves a tarball with a 40 000-char PAX path from a drip-feed registry and asserts the install completes with the overlong entry silently skipped.

Security risks

This is security-adjacent (untrusted tarball extraction) but strictly defensive: it converts a Rust slice-bounds panic (DoS) into a silent per-entry skip, matching how the buffered extractor and every other unrepresentable-entry case in begin_entry already behave. No new attack surface is added; the skipped entry's data blocks are consumed and discarded via the existing out_fd = None path in Phase::WantData.

Level of scrutiny

Low-to-medium. The Rust change is 7 added lines that copy the exact skip pattern used four other times in the same function, plus a one-character tightening of an existing bound in the sibling extractor. The bound arithmetic checks out: proceed only when buflen >= rest.len() + 2, which covers worst-case output length (input + 1 on Windows) plus the NUL byte written at norm_buf[norm_len]. Over-rejection is at most one character at the platform max-path boundary, which is harmless.

Other factors

  • The PR description shows the test failing on both debug/ASAN and release builds without the fix and passing with it.
  • The test's long name is hex (no /), so after the package/ prefix is stripped the full 40 000 chars reach the guard on every platform (>1024 macOS, >4096 Linux, >32767 Windows).
  • All comment-cop inline threads are resolved; the two-line comments that remain justify the non-obvious +1 and are appropriate per REVIEW.md's "durable non-obvious content" rule.
  • This adopts the last outstanding hunk from #31160; the buffered-path guard already landed via #31339.

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Jul 31st, 2026

@robobun, your commit 9ff04fd is building: #86128

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:38 AM PT - Jul 31st, 2026

@robobun, your commit 9ff04fdd88c5885b689fd236ccb7dd64ee3c5e29 passed in Build #86128! 🎉


🧪   To try this PR locally:

bunx bun-pr 36533

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

bun-36533 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded. The streaming extractor's length guard landed in ff512ea (#36165) shortly after this PR was opened, and the buffered extractor has had the same guard since cd1ad59 (#31339). The test added here passes unmodified against current main, so there is no longer a fail-before case for it.

The one thing left in this diff, the + 1 for the Windows case where normalize_buf_t emits one more unit than it was given (a UNC-shaped name), is a much narrower, Windows-only issue and will be handled separately with a Windows test. Closing in favour of what is on main; #31160 (the earlier PR for the same crash) is closed for the same reason.

@robobun robobun closed this Aug 13, 2026
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