Skip to content

pm view: escape control characters coming from the registry - #38536

Open
robobun wants to merge 1 commit into
mainfrom
farm/968e8117/pm-view-escape-control-chars
Open

pm view: escape control characters coming from the registry#38536
robobun wants to merge 1 commit into
mainfrom
farm/968e8117/pm-view-escape-control-chars

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun pm view <pkg> / bun info <pkg> write every string of the registry packument to the terminal byte for byte: description, license, homepage, keywords, dependency names and ranges, dist.tarball / shasum / integrity, dist-tag names, maintainer names and emails, and the time entry.
  • A packument string can therefore drive the terminal of whoever looks at the package: ESC[2J clears the screen, ESC]0;...BEL sets the window title, ESC]52;... writes the clipboard, ESC]8;;url turns a label such as react into a link to another host, \r overwrites the line bun printed so far, and an embedded \n forges further lines of bun info output (for example a fake dependencies section). 8-bit C1 controls (U+0080..U+009F, C2 80..C2 9F in UTF-8) go through the same way. The same bytes come out on a pipe; bun only strips its own colors there.
  • Cause: src/runtime/cli/pm_view_command.rs formats each field with BStr, which only replaces invalid UTF-8. The same applies to bun pm view <pkg> <field> for a string field (line 319).
  • Reproduced on bun 1.4.0 against a loopback registry serving a packument with these bytes in every field listed above; bun pm view hv | od -c shows the raw 033 bytes after bun's own color codes.

Fix

  • Adds bun_core::fmt::escape_control_chars / EscapeControlChars (src/bun_core/fmt.rs): a Display adapter that writes C0 controls, DEL and C1 controls as \n, \r, \t, \x1b, \x7f, \u009b and passes every other character through unchanged. Same helper and output format as the one install: escape control characters in the bun pm untrusted/trust script listing #38525 adds for the bun pm untrusted listing, so the two rebase onto each other cleanly; this one also adds escape_control_chars_multiline.
  • pm view prints every registry-controlled string through it. Fields of the summary view are single-line, so there a newline is escaped too; output for packages without control characters is byte-identical to before (the existing bun-info.test.ts snapshots still pass).
  • bun pm view <pkg> <field> on a string field uses the multi-line variant: \t, \n and \r\n pass through, since fields like readme are legitimately multi-line and the value is the whole output; a \r not followed by \n and every other control is still escaped.
  • Verified:
    • test/cli/install/bun-info.test.ts ("bun info with control characters in the packument"): a local Bun.serve registry puts ESC, BEL, bare CR, CRLF, tab, DEL, U+0085 and U+009B in every field; one test snapshots the whole summary view with each field escaped and asserts no control byte remains in stdout, the other checks the field view keeps CRLF and tab and escapes the rest. Both fail on the released binary (raw bytes in stdout) and pass with this change.
    • Whole bun-info.test.ts file passes with the debug build; cargo fmt, cargo clippy -p bun_core, test/internal/source-lints clean.
  • Not changed here: --json and non-string fields (bun pm view <pkg> versions) are printed by js_printer::print_json, which spells control characters as JavaScript \x1B escapes and so produces invalid JSON. That is SyntaxError parsing package.json #4823 and is fixed by the open js_printer: preserve JSON escaping in package manifests #38150, which I confirmed also fixes bun pm view --json output. bun audit prints advisory titles and URLs the same raw way and can adopt the helper separately.

Background

  • A packument is the registry's JSON document for a package (GET <registry>/<name>): top-level description, dist-tags, maintainers, time, plus one manifest per version. Its free-text fields are whatever the publisher put in package.json, so for bun info <name> they are attacker-controlled for any package name the user is persuaded to look up.
  • C0 controls are bytes 0x00..0x1F (ESC, BEL, CR, LF, ...), DEL is 0x7F, and C1 controls are U+0080..U+009F, which terminals treat as one-byte forms of common ESC sequences (U+009B is CSI, the same as ESC [). In UTF-8 output they are two-byte sequences, which is why the adapter works on decoded chars rather than bytes; non-ASCII text such as » or a non-English description is not a control and is left alone.
  • prettyln! only interprets <b>/<r> markup in the compile-time template; arguments are ordinary Display values, so wrapping each argument is sufficient and the adapter is a core::fmt::Write shim placed between the argument's Display and the real Formatter.

`bun pm view` / `bun info` printed every packument string byte for byte,
so a package description, maintainer name, dist-tag, dependency name or
dist field could emit terminal escape sequences (clear screen, set title,
OSC 52 clipboard writes, OSC 8 hyperlinks, CR overwrites) or forge extra
output lines. Add `bun_core::fmt::escape_control_chars`, which renders C0
controls, DEL and the C1 controls as visible escapes, and print every
registry-controlled string through it. `bun pm view <pkg> <field>` uses
the multi-line variant so readme-style fields keep their tabs and line
breaks while a bare CR and everything else is still escaped.
@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: 37 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: 1becfc32-adf9-41eb-9785-c0ea14514ad3

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 0aab273.

📒 Files selected for processing (3)
  • src/bun_core/fmt.rs
  • src/runtime/cli/pm_view_command.rs
  • test/cli/install/bun-info.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 with a loopback registry serving control bytes in every packument field (bun pm view hv | od -c shows the raw 033 bytes); fixed by this PR, test in test/cli/install/bun-info.test.ts fails on the released binary and passes with the change.

Related open PRs: #38525 adds the same escape_control_chars helper for the bun pm untrusted listing (same public API and output format, whichever lands second rebases onto the other); #38150 fixes the \x1B escapes in --json / non-string field output, which this PR deliberately does not duplicate.

@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 — well-scoped terminal-escape hardening with thorough coverage.

What was reviewed:

  • EscapeControlCharsWriter::write_str char-index/flush logic: C0/DEL/C1 ranges, \r peek-for-\n in multiline mode, and start advancement via c.len_utf8() for 2-byte C1 chars all check out.
  • Traced every registry-controlled BStr::new site in the summary view — all now go through escape_control_chars; remaining BStr::new uses are URL/auth-header construction and the JSON printer path, none registry-string-to-terminal.
  • Considered \r split across write_str chunks — input is parse_utf8 output via BStr, so written as one chunk; worst case would over-escape, never under-escape.
  • Tests use a local Bun.serve registry, drain pipes concurrently, assert no control bytes remain via regex plus a full snapshot, and cover both the single-line and multiline variants.
Extended reasoning...

Overview

This PR hardens bun pm view / bun info against terminal escape injection from registry packuments. It adds two Display adapters to bun_core::fmt (EscapeControlChars for single-line fields, EscapeControlCharsMultiline for the field-view path where readme etc. are legitimately multi-line), plus &[u8] convenience wrappers over BStr. pm_view_command.rs then mechanically swaps BStr::new(x) for bun_fmt::escape_control_chars(x) at every print site that renders a registry-controlled string. Two new tests in bun-info.test.ts serve a hostile packument from a local Bun.serve and assert the summary view contains no raw control bytes (regex + full snapshot) and that the field view keeps CRLF/tab while escaping everything else.

Security risks

The change is strictly defensive — it adds escaping where there was none. The escape logic writes the passed-through prefix, then a visible escape (\\n, \\x1b, \\u009b), then advances start by c.len_utf8(); the C1 range '\u{80}'..='\u{9f}' is handled as chars so the 2-byte UTF-8 encoding is accounted for. Invalid UTF-8 from the registry is already normalized to U+FFFD by BStr's Display before it reaches the writer, so it can't smuggle a control byte through. The multiline variant's \r-followed-by-\n peek is per-write_str-call, but the only input is BStr over parse_utf8 output (one contiguous chunk), and any theoretical split would over-escape rather than leak a bare CR. No new attack surface is introduced.

Level of scrutiny

Medium. The helper is ~50 lines of straightforward char iteration in safe Rust with no allocation or unsafe; the call-site changes are a mechanical BStr::newescape_control_chars swap. I walked every remaining BStr::new in pm_view_command.rs and confirmed they're either building the request URL/headers, printing the JSON-printer buffer (covered by #38150 per the description), or echoing the user's own prop_path in an error — none are registry text reaching the terminal. The PR notes output for clean packages is byte-identical, which the untouched existing snapshots in the same test file confirm.

Other factors

Test quality is high: local port: 0 server, tempDir + using, pipes drained via Promise.all, exit code asserted last, and the regex [\x00-\x09\x0b-\x1f\x7f\u0080-\u009f] gives a non-snapshot assertion that no control byte survives (only bun's own LFs are allowed). The hostile string exercises ESC/OSC/BEL, bare CR vs CRLF, tab, DEL, and both C1 controls the description calls out (NEL, CSI). The PR description also explicitly scopes out --json and bun audit with references to the PRs that handle them, and notes the helper matches #38525 so the two rebase cleanly.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Small doc nit from #38525, which adds the same helper: the rustdoc on EscapeControlChars here lists \^[ as the C1 spelling, but the writer emits \u009b style escapes for C1 (the c => write!(.., "\\u{:04x}", ..) arm), so the comment and the code disagree. The code matches what #38525 emits, so only the comment needs adjusting.

@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..])
    }
}

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.

2 participants