Skip to content

install: reject dependency names containing control characters - #38615

Open
robobun wants to merge 3 commits into
mainfrom
farm/7cf53a95/reject-control-chars-in-dep-names
Open

install: reject dependency names containing control characters#38615
robobun wants to merge 3 commits into
mainfrom
farm/7cf53a95/reject-control-chars-in-dep-names

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A registry manifest can list a dependency whose name contains terminal control characters (served as an ordinary JSON string whose key spells ESC and BEL with JSON unicode escapes; the name used below is ev + OSC 52 "write to the clipboard" + CSI 2J "clear the screen" + il). bun install accepts it: the name is requested from the registry, written raw onto the progress line (ev<ESC>]52;c;aGkK<BEL><ESC>[2Jil [9/8]), printed raw in error: InvalidURL downloading tarball <name>@1.0.0 and in the <name>@1.0.0 failed to resolve summary, stored in bun.lock, and node_modules/ev<ESC>]52;...il/ is created; bun pm ls and bun why then print the raw bytes again on every run. A hand-edited bun.lock with such a package name behaves the same.
  • Cause: nothing validates dependency names between parsing them and using them. enqueue_dependency_with_main_and_success_fn (src/install/PackageManager/PackageManagerEnqueue.rs) turns the name into the manifest request and later the package name without looking at it; is_safe_install_folder_name (src/install/dependency.rs), which the tree builder, the installers and the bun.lock parser apply to folder names, only knows about path rules (.., \, :); and the messages that report a bad or unresolvable name (Tree.rs, isolated_install.rs, PackageInstaller.rs, extract_tarball.rs, TarballStream.rs, PackageManagerResolution.rs::verify_resolutions, bun.lock.rs::dependency_resolution_failure) all wrote the name through BStr/fmt::s, i.e. raw.

Fix

  • is_safe_install_folder_name additionally rejects names containing C0 controls, DEL, or a UTF-8 encoded C1 control (C2 80..C2 9F, which some terminals also execute). This covers every place that already used it: the hoisted tree builder, the hoisted and isolated installers (alias_is_safe_install_target now delegates to it instead of duplicating its rules), package names read from bun.lock (so bun pm ls / bun install refuse a hostile lockfile instead of printing or installing from it), bin names, and tarball extraction.
  • enqueue_dependency_with_main_and_success_fn validates the dependency before resolving it, which is before anything is requested, shown on the progress line or turned into a package: the alias (the future node_modules/ folder) with the full validator, and for npm / dist-tag dependencies also the resolved registry name (npm: alias targets, overrides and catalog replacements included). The placeholder alias bun add <specifier> creates (alias == version literal, replaced by assign_resolution once the package is known) is only checked for control characters, since it is printed by the git tasks but never becomes a folder. A required dependency is reported as error: Invalid dependency name "..." (the install fails, as the tree builder's check already did for .. names), an optional one as a warning and skipped, matching how an unresolvable dependency is reported today. Auto-install callers get a new Error::InvalidDependencyName through their fail_fn.
  • The tree builder's own check now skips dependencies that did not resolve: they get no folder, and when the reason is the name, enqueue already reported it, so the error is not printed twice. Resolved dependencies (lockfile-loaded) are still checked there.
  • New bun_core::fmt::escape_control_chars renders text with C0/DEL/C1 spelled out (\x1b, \n, and a four-digit backslash-u spelling for C1 code points), everything else verbatim; all the rejection messages above and the two "failed to resolve" summaries use it, so reporting a rejected name is byte-identical to before for ordinary names and cannot replay a hostile one. The same helper (same name, table and shape) is being added by install: escape control characters in the bun pm untrusted/trust script listing #38525, pm view: escape control characters coming from the registry #38536 and audit: fail closed on malformed registry responses and escape control characters in the report #38557 for other commands, and install: escape control characters in resolutions, specifiers, bin names and registry error text #38631 (which escapes the non-name strings: resolutions, specifiers, bin names, registry error text) reuses it and this PR's PackageManagerResolution.rs hunk verbatim; whichever lands first, the others reduce to dropping their copy.
  • Because invalid names are now refused while resolving, three existing tests observe the earlier layer and were updated to its message: does not extract a tarball for a dependency alias containing '..' (the tarball is no longer even requested), the isolated rejects dependency aliases that traverse outside node_modules (the next test still exercises the installer's own check), and the registry host-pinning test, whose assertion now accepts either layer and still checks that no request reaches the other host.
  • Verified:
    • test/cli/install/bun-install.test.ts, describe("dependency names containing terminal control characters"): manifest-declared name (with BUN_INSTALL_PROGRESS=1, so the progress line is captured), unnamed git dependency in a manifest, bun add <tarball url> placeholder still allowed, optional dependency only warns, alias of a dependency, npm: alias target, package name in bun.lock (bun pm ls and bun install). Each asserts the escaped message, that the raw name appears in neither stream, that nothing with that name was requested or installed, and the exit code. All 7 fail on a build without the src/ changes (the progress line shows the raw bytes, the package installs, bun pm ls prints it) and pass with it.
    • Manually checked a U+009B (C1) name is rejected and rendered as its four-digit backslash-u spelling, and that a non-ASCII name whose UTF-8 contains a 0x80 continuation byte (okĀname) still passes and prints verbatim.
    • bun-install.test.ts (remaining failures need network), bun-install-registry.test.ts, isolated-install.test.ts, bun-add.test.ts, bun-workspaces.test.ts, overrides.test.ts, catalogs.test.ts, bun-lock.test.ts, bun-update.test.ts, bun-pm*.test.ts, migration/migrate.test.ts, bunx.test.ts, test/regression/issue/31652.test.ts (empty-name optional dependency stays tolerated), cargo clippy -p bun_core -p bun_install, test/internal/source-lints.
  • Out of scope, tracked separately: the progress-line buffer overflow for names longer than 768 bytes (install: stop panicking when a progress bar name is longer than its buffer #38558); rejecting names outside npm's URL-safe set (install: reject non-URL-friendly npm package names before registry routing #34737 does that at the same spot, and composes with this: this PR is about what gets printed and created, that one about registry routing); and a git/tarball dependency whose own package.json name is invalid, where bun currently writes a bun.lock entry that the parser then refuses (pre-existing for :/\ names, now also for control characters).

Background

  • A dependency has two names. The alias is the key in dependencies and becomes the node_modules/<alias> folder. The registry name is what is fetched and becomes the package's name in the lockfile; it equals the alias except for npm: aliases, overrides and catalogs, where it comes out of the version string. Both come out of whatever manifest declared the dependency, so a transitive package controls both.
  • Resolution flows through enqueue_dependency_with_main_and_success_fn for every dependency of every source (package.json, fetched manifests, lockfiles); a dependency left unresolved there is reported by verify_resolutions and skipped by the tree builder. Folder creation happens later from the lockfile, which is why the folder validator stays in the tree builder, both installers and the bun.lock parser as the second line of defense for lockfiles that never go through resolution.
  • bun add ./x.tgz / bun add github:o/r store the specifier itself as the alias until the package is known (assign_resolution swaps in the package name when alias == version literal); that is why such an alias must not be held to the folder rules.
  • C1 controls are U+0080..U+009F; in UTF-8 they are the two bytes C2 80..C2 9F, and e.g. U+009B is an alternative spelling of ESC [. The check is sequence-based because bytes 0x80..0x9F also occur as continuation bytes of ordinary non-ASCII names.
Before / after output

Unfixed build, manifest-declared name, stderr bytes (BUN_INSTALL_PROGRESS=1):

Resolving [1/1]
ev\x1b]52;c;aGkK\x07\x1b[2Jil [9/8]        <- raw ESC / BEL bytes
Saving lockfile...
$ ls node_modules | od -c
e   v 033   ]   5   2   ;   c   ;   a   G   k   K  \a 033   [   2   J   i   l

Fixed build:

error: Invalid dependency name "ev\x1b]52;c;aGkK\x07\x1b[2Jil"      <- literal backslashes
error: ev\x1b]52;c;aGkK\x07\x1b[2Jil@1.0.0 failed to resolve

Fixed build, bun pm ls on a bun.lock carrying such a package name:

error: Error loading lockfile: InvalidLockfile

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-registry.test.ts test/cli/install/bun-install.test.ts test/cli/install/isolated-install.test.ts

A registry manifest (or a lockfile) can declare a dependency whose name
contains terminal control characters. bun requested it, put the raw name
on the progress line and in error output, wrote it into bun.lock and
created node_modules/<name> from it, and bun pm ls / bun why printed it
raw again later.

Validate the alias and, for registry dependencies, the resolved name when
a dependency is enqueued, before anything is requested or printed, using
the same is_safe_install_folder_name the tree builder, the installers and
the bun.lock parser already apply to folder names; that validator now also
rejects C0 controls, DEL and UTF-8 encoded C1 controls. A required
dependency with such a name is an error, an optional one a warning, like
an unresolvable dependency. The messages that report a rejected or
unresolved name render it through a new bun_core::fmt::escape_control_chars
so the report cannot replay the characters it is complaining about.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0-canary with a loopback registry whose tdep manifest declares a dependency named ev<ESC>]52;c;aGkK<BEL><ESC>[2Jil (OSC 52 + clear screen): the raw name shows up on the progress line (BUN_INSTALL_PROGRESS=1), node_modules/ev<ESC>... gets created, and bun pm ls / bun why print it raw afterwards. Fix and tests are in this PR; the 7 new tests fail without the src/ changes and pass with them.

CI on the current head (59cf72e, build 96362): 177 of 179 jobs passed with no test failures; the remaining two are the darwin 14 aarch64 - test-bun shards, which expired after an hour waiting for an agent on that queue (it is backed up for every build right now), so the build shows red without anything having failed. Ready for review.

@coderabbitai

coderabbitai Bot commented Aug 14, 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: 9 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: 1ba21915-128f-40c5-a751-954acdbea749

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5c180 and 59cf72e.

📒 Files selected for processing (14)
  • src/bun_core/fmt.rs
  • src/install/PackageInstaller.rs
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/install/PackageManager/PackageManagerResolution.rs
  • src/install/TarballStream.rs
  • src/install/dependency.rs
  • src/install/error.rs
  • src/install/extract_tarball.rs
  • src/install/isolated_install.rs
  • src/install/lockfile/Tree.rs
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/bun-install-registry.test.ts
  • test/cli/install/bun-install.test.ts
  • test/cli/install/isolated-install.test.ts

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: escape control characters in the bun pm untrusted/trust script listing #38525 - Adds a byte-identical EscapeControlChars/escape_control_chars helper at the same location in src/bun_core/fmt.rs for the same purpose (stopping registry-controlled strings from replaying terminal escape sequences); only one copy of this helper should land.
  2. install: reject non-URL-friendly npm package names before registry routing #34737 - Rejects hostile dependency names at the same seam (enqueue_dependency_with_main_and_success_fn) before any registry request and adds a sibling Error::InvalidPackageName to the same enum in src/install/error.rs; its URL-safe name gate already rejects control characters in registry names.
  3. pm view: escape control characters coming from the registry #38536 - Adds the same escape_control_chars helper (plus a multiline variant) to src/bun_core/fmt.rs for the same "escape registry-controlled text before printing" purpose, targeting bun pm view.
  4. audit: fail closed on malformed registry responses and escape control characters in the report #38557 - Adds a competing escape_control_chars implementation with a different signature to the same spot in src/bun_core/fmt.rs for the same purpose, targeting bun audit.

🤖 Generated with Claude Code

@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 the automated bug-hunting pass found no issues. Because it adds security-sensitive input validation on the critical dependency-resolution path and changes the semantics of a shared validator (is_safe_install_folder_name) that several installers, the tree builder, and the lockfile parser all depend on, a human look would still be worthwhile.

What was reviewed

  • contains_control_character correctly distinguishes UTF-8-encoded C1 (C2 80..9F) from ordinary continuation bytes; the dropped \0 in contains_any is now covered by the control-char check.
  • alias_is_safe_install_target refactor preserves the prior rules (empty / . / .. / \\ / : / NUL / component count) via delegation.
  • The alias == version.literal placeholder exemption and the is_required() error-vs-warn split in the enqueue check; a candidate concern that optional peers would error instead of warn was examined and ruled out.
  • The pkg_id != invalid_package_id guard in Tree.rs — unresolved deps skip the folder-name check there because enqueue already reported them, avoiding a duplicate error.
Extended reasoning...

Overview

This PR hardens bun install against dependency names containing terminal control characters (C0/DEL/C1), which a hostile registry manifest or lockfile could otherwise use to inject escape sequences into progress lines, error messages, and node_modules/ folder names. It touches 14 files across src/install/ and src/bun_core/: a new escape_control_chars formatter, an extended is_safe_install_folder_name (now also rejects control characters), a new pre-resolution validation gate in enqueue_dependency_with_main_and_success_fn, a refactor of alias_is_safe_install_target to delegate to the shared validator, a new Error::InvalidDependencyName variant, escaping applied to ~8 error-message sites, and 7 new tests plus 3 updated tests.

Security risks

This is itself a security fix (terminal escape injection / defense-in-depth for path traversal via control chars). The main risk to review for is whether the tightened validator now rejects legitimate inputs it previously accepted — the PR description addresses this (non-ASCII names with continuation bytes still pass; the placeholder alias for bun add <specifier> is exempted from folder-name rules), and the C1 detection is sequence-based rather than byte-based. No new attack surface is introduced.

Level of scrutiny

High. The enqueue function is on the hot path for every dependency of every install, and is_safe_install_folder_name is called from the tree builder, both installers, the lockfile parser, tarball extraction, and bin-name validation — a false positive here would break installs of legitimate packages. The refactor of alias_is_safe_install_target changes how it derives its rules. The Tree.rs guard change alters when the second-line-of-defense check fires. These are all well-reasoned in the PR body, but the blast radius warrants a maintainer confirming the exemption logic (placeholder alias, empty name, optional/peer behavior) matches intent.

Other factors

Test coverage is strong: 7 new tests covering manifest-declared names, unnamed git deps, the bun add placeholder exemption, optional-dependency warn-not-error, alias vs npm: target, and hostile lockfiles via both bun pm ls and bun install; each asserts the escaped message appears, the raw bytes never leak to either stream or the request log, nothing is installed, and the exit code. Three existing tests were updated to observe the earlier rejection layer. The PR description documents extensive test-suite runs and clippy. No prior reviewer comments to address.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of any of the four, but they are related, so for whoever triages:

Comment thread src/bun_core/fmt.rs
Comment thread src/bun_core/fmt.rs Outdated
Comment thread src/install/PackageInstaller.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/dependency.rs Outdated
Comment thread src/install/dependency.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/bun_core/fmt.rs
Comment thread src/install/PackageInstaller.rs
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs
Comment thread src/install/dependency.rs
Comment thread src/install/dependency.rs
Comment thread src/install/lockfile/Tree.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.

I reviewed this PR and the bug-hunting pass found no issues. Because it adds a new validation gate at the central enqueue_dependency seam, tightens the shared is_safe_install_folder_name path validator used by the tree builder / installers / lockfile parser, and needs landing-order coordination with #38525 / #38536 / #38557 / #34737, a human look is still worthwhile.

What was reviewed:

  • alias_is_safe_install_target refactor is behavior-preserving plus the new control-char rule (NUL moved from the \\: byte set into contains_control_character, still rejected).
  • contains_control_character's C1 check is UTF-8-sequence-based (0xC2 0x80..0x9F), so ordinary non-ASCII names whose continuation bytes fall in 0x80..0x9F are not rejected.
  • The new pkg_id != invalid_package_id guard in Tree.rs only skips the name check for unresolved deps, which never get a folder (filtered in clean() / continue 'dep).
  • Optional/peer handling: optional-peer deps return before the new check; optional deps warn and skip, matching how the same function reports other unresolvable optionals.
Extended reasoning...

Overview

This PR hardens bun install against dependency names containing terminal control characters (C0, DEL, UTF-8 C1) supplied by an untrusted registry manifest or a hand-edited bun.lock. It adds bun_core::fmt::escape_control_chars (a Display wrapper that spells out control characters), extends dependency::is_safe_install_folder_name to reject control characters, inserts a name-validation gate in enqueue_dependency_with_main_and_success_fn before any request/progress-line/package creation, refactors alias_is_safe_install_target to delegate to the shared validator, adds Error::InvalidDependencyName, and routes ~9 error/warning messages that echo a name through the new escaper. Three existing tests were updated because the rejection now surfaces at an earlier layer, and 7 new tests cover manifest-declared names, git placeholders, optional deps, npm: aliases (both directions), and hostile lockfiles.

Security risks

The change is itself a security hardening: it closes a terminal-escape-injection and directory-name-injection vector from registry-controlled data. The risk to weigh is whether the new gate over-rejects (breaking a legitimate name) or under-rejects (a bypass). The C1 check is byte-sequence-aware so multi-byte non-ASCII names are preserved; NUL moved from the explicit byte set into is_ascii_control() so it is still rejected. The bun add <specifier> placeholder alias (alias == literal) is exempted from the folder rules but still checked for control characters, which the tests exercise. No validation was removed — the tree builder / installers / lockfile parser keep their own checks as a second line.

Level of scrutiny

High. This is security-sensitive code (untrusted-input validation that becomes filesystem paths and terminal output) at the single seam every dependency of every source flows through. The repo's own review guidance calls out "security checks fail closed and cover every path to the protected effect" and "when tightening validation, enumerate every legitimate input class" — both apply directly here and merit a maintainer's eyes.

Other factors

  • Four open PRs (#38525, #38536, #38557, #34737) add the same or an adjacent helper at the same locations; a maintainer should pick the landing order.
  • Three existing tests were edited to observe the earlier layer's message; one (bun-install-registry.test.ts) now accepts either of two messages, which is a small assertion loosening a reviewer should confirm is intended.
  • The comment-cop bot flagged several comments; the author tightened them and justified the remainder as doc comments / non-local invariants — those threads are resolved.
  • No CI results are visible in the timeline yet.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on the shared escape_control_chars / EscapeControlChars helper: on #38631 Jarred asked that it not walk the text character by character but use the string helpers instead. #38631 now has that version (2fcf0d2 plus 1e3a599; same name, signature and output, so no call sites or test expectations change). If this PR keeps carrying its own copy of the helper, please swap the writer body for this one so the fmt.rs hunks stay identical across the four branches and whichever lands first still matches the review (#38536's multiline variant and #38557's byte-slice version would want the same scanning approach applied to their own shape rather than a verbatim paste):

struct EscapeControlCharsWriter<'a, 'f>(&'a mut Formatter<'f>);

impl fmt::Write for EscapeControlCharsWriter<'_, '_> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        let bytes = s.as_bytes();
        let mut start = 0;
        let mut cursor = 0;
        // `\` doubles as the quote char so the scan stops at nothing else extra.
        while let Some(offset) =
            strings::index_of_needs_escape_for_java_script_string(&bytes[cursor..], b'\\')
        {
            let i = cursor + offset as usize;
            let (code_point, len) = match bytes[i] {
                byte @ (0x00..=0x1F | 0x7F) => (byte as u32, 1),
                0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => (bytes[i + 1] as u32, 2),
                byte => {
                    let char_len = strings::wtf8_byte_sequence_length(byte) as usize;
                    cursor = (i + char_len).min(bytes.len());
                    continue;
                }
            };
            self.0.write_str(&s[start..i])?;
            match code_point {
                0x0A => self.0.write_str("\\n")?,
                0x0D => self.0.write_str("\\r")?,
                0x09 => self.0.write_str("\\t")?,
                0x00..=0x7F => write!(self.0, "\\x{:02x}", code_point)?,
                _ => write!(self.0, "\\u{:04x}", code_point)?,
            }
            start = i + len;
            cursor = start;
        }
        self.0.write_str(&s[start..])
    }
}

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

The Tree.rs change in this PR (skipping the folder name check for dependencies that did not resolve) also fixes something visible on main with no control characters involved. When bun add <tarball url> fails to download, the dependency keeps the URL as its placeholder alias (the rename in assign_resolution never runs), and the tree builder then reports that alias as a bad name. On 1.4.0-canary.1, against a local server answering 404, with either linker:

error: GET http://127.0.0.1:36403/cdn/direct-1.0.0.tgz - 404
error: Invalid dependency name "http://127.0.0.1:36403/cdn/direct-1.0.0.tgz"
error: http://127.0.0.1:36403/cdn/direct-1.0.0.tgz failed to resolve

The middle line is the one this PR removes. It also echoes credentials embedded in the URL (http://user:s3cret@host/x.tgz?token=...), and it is not covered by #38817 or #38977, since it prints the dependency name rather than the request URL or the resolution.

This path is not covered by the tests here (the bun add <tarball url> case in this PR succeeds, so the alias has already been replaced by the time the tree is built). A cheap test for it, for example in test/cli/install/bun-add.test.ts: bun add of a Bun.serve({ port: 0 }) URL that answers 404 should print failed to resolve, not print Invalid dependency name, and exit 1. That fails on main and passes with the pkg_id != invalid_package_id condition, so it would pin down that part of the change on its own.

No separate PR for this; the fix is the one already in this diff.

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.

3 participants