Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/runtime/debugger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ await fetch("https://example.com", {
```

```txt
[fetch] $ curl --http1.1 "https://example.com/" -X POST -H "content-type: application/json" -H "Connection: keep-alive" -H "User-Agent: Bun/1.3.3" -H "Accept: */*" -H "Host: example.com" -H "Accept-Encoding: gzip, deflate, br" --compressed -H "Content-Length: 13" --data-raw "{\"foo\":\"bar\"}"
[fetch] $ curl --http1.1 'https://example.com/' -X POST -H 'content-type: application/json' -H 'Connection: keep-alive' -H 'User-Agent: Bun/1.3.3' -H 'Accept: */*' -H 'Host: example.com' -H 'Accept-Encoding: gzip, deflate, br' --compressed -H 'Content-Length: 13' --data-raw '{"foo":"bar"}'
[fetch] > HTTP/1.1 POST https://example.com/
[fetch] > content-type: application/json
[fetch] > Connection: keep-alive
Expand All @@ -169,6 +169,8 @@ await fetch("https://example.com", {

The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server.

The `curl` command holds the request as it was sent, with its credentials, so that it can run again. Each argument is quoted for a POSIX shell such as `bash` or `zsh`: the shell passes the bytes of the request to `curl` and runs nothing from them. An argument with a control character (for example a line feed in the body) or with bytes that are not UTF-8 is written as `$'...'`, so the command stays on one line. `dash` and `fish` do not have `$'...'`, and PowerShell reads quotes in another way: these shells can pass other bytes for some arguments, but they also run nothing from them. Do not paste the command into `cmd.exe`, which has no single quotes. `--globoff` is added when the URL has `[`, `]`, `{` or `}`, because `curl` expands them otherwise. A request body with a NUL byte is not printed.

To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`.

```ts index.ts icon="/icons/typescript.svg"
Expand Down
162 changes: 162 additions & 0 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3363,6 +3363,168 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu
write_bytes(writer, remain)
}

/// One argument of a printed command: a POSIX shell passes `parts` unchanged, fish and PowerShell cannot run them.
pub struct ShellWord<'a>(pub(crate) &'a [&'a [u8]]);

pub fn shell_word<'a>(parts: &'a [&'a [u8]]) -> ShellWord<'a> {
ShellWord(parts)
}

impl Display for ShellWord<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut empty = true;
let mut bare = true;
let mut ansi_c = false;
for part in self.0 {
empty &= part.is_empty();
for chunk in part.utf8_chunks() {
ansi_c |= !chunk.invalid().is_empty();
for c in chunk.valid().chars() {
bare &= c.is_ascii_alphanumeric() || c == '-';
// C0, DEL and C1.
ansi_c |= matches!(c, '\0'..='\x1f' | '\x7f'..='\u{9f}');
}
}
}

if ansi_c {
// `'...'` would put a line feed, ESC or CR on the terminal as it is.
f.write_str("$'")?;
self.0
.iter()
.try_for_each(|part| shell_word_ansi_c(f, part))?;
f.write_str("'")
} else if empty {
f.write_str("''")
} else if bare {
self.0.iter().try_for_each(|part| shell_word_utf8(f, part))
} else {
let mut quoted = ShellWordQuoted {
f,
state: ShellWordIn::Nothing,
held_backslash: false,
};
self.0.iter().try_for_each(|part| quoted.part(part))?;
quoted.finish()
}
}
}

fn shell_word_utf8(f: &mut Formatter<'_>, bytes: &[u8]) -> fmt::Result {
f.write_str(core::str::from_utf8(bytes).map_err(|_| fmt::Error)?)
}

/// The inside of `$'...'`, all ASCII: a GBK or Big5 shell cannot pair a byte with the backslash of the next escape.
fn shell_word_ansi_c(f: &mut Formatter<'_>, part: &[u8]) -> fmt::Result {
let mut run = 0;
for (i, &byte) in part.iter().enumerate() {
let escape = match byte {
b'\\' => "\\\\",
b'\n' => "\\n",
b'\r' => "\\r",
b'\t' => "\\t",
// dash, fish and PowerShell read `$'...'` as `'...'`: a `'` is `\047`, never `\'`.
b' '..=b'~' if byte != b'\'' => continue,
_ => "",
};
shell_word_utf8(f, &part[run..i])?;
if escape.is_empty() {
write!(f, "\\{byte:03o}")?;
} else {
f.write_str(escape)?;
}
run = i + 1;
}
shell_word_utf8(f, &part[run..])
}

#[derive(Clone, Copy, PartialEq)]
enum ShellWordIn {
Single,
Double,
Nothing,
}

/// `'...'` runs, for UTF-8 without a control character.
struct ShellWordQuoted<'a, 'f> {
f: &'a mut Formatter<'f>,
state: ShellWordIn,
/// A backslash that waits for the byte after it.
held_backslash: bool,
}

impl ShellWordQuoted<'_, '_> {
fn enter(&mut self, next: ShellWordIn) -> fmt::Result {
let delimiter = |state| match state {
ShellWordIn::Single => "'",
ShellWordIn::Double => "\"",
ShellWordIn::Nothing => "",
};
if self.state != next {
self.f.write_str(delimiter(self.state))?;
self.f.write_str(delimiter(next))?;
self.state = next;
}
Ok(())
}

fn settle_backslash(&mut self, stays_quoted: bool) -> fmt::Result {
if !core::mem::take(&mut self.held_backslash) {
Ok(())
} else if stays_quoted {
self.enter(ShellWordIn::Single)?;
self.f.write_str("\\")
} else {
// fish reads `\\` and `\'` inside `'...'` as escapes. Outside, `\\` is one backslash in every shell.
self.enter(ShellWordIn::Nothing)?;
self.f.write_str("\\\\")
}
}

fn part(&mut self, mut rest: &[u8]) -> fmt::Result {
// A backslash, a `'`, or U+2018..=U+201B: PowerShell ends a `'...'` string at those too.
fn special(rest: &[u8]) -> Option<(usize, usize)> {
let mut from = 0;
while let Some(at) = strings::index_of_any_pos(rest, b"\\'\xe2", from) {
match &rest[at..] {
[b'\\' | b'\'', ..] => return Some((at, 1)),
[0xe2, 0x80, 0x98..=0x9b, ..] => return Some((at, 3)),
_ => from = at + 1,
}
}
None
}

while let Some((at, len)) = special(rest) {
if at > 0 {
self.settle_backslash(true)?;
self.enter(ShellWordIn::Single)?;
shell_word_utf8(self.f, &rest[..at])?;
}
self.settle_backslash(false)?;
if rest[at] == b'\\' {
self.held_backslash = true;
} else {
// Not `'\''`: PowerShell leaves the text after it outside any string.
self.enter(ShellWordIn::Double)?;
Comment on lines +3508 to +3509

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.

🟣 pre-existing, not blocking: Users who paste the curl line into an interactive bash whose history scanner does not track double quotes (the 3.2 and 4.x series) get event not found or history text spliced into the argument when a value holds ' and a later !. A ' is written as a bare "'" run at src/bun_core/fmt.rs:3509; that scanner takes the '"' as a single-quoted string, so the following '...' run is read unquoted and !x inside it is expanded. Fix: keep every ! inside quoting that interactive bash of every version honours, e.g. write ' as \' outside quotes ('it'\''s', also right for zsh, dash and fish) or document the PowerShell trade-off; the paste test runs bash with --norc non-interactively, so history expansion is never exercised.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: a value the server or request chooses holds a ' and, later in the same word, a ! (URL path, header value or body), printed under BUN_CONFIG_VERBOSE_FETCH=curl via print_request at src/http/lib.rs:1376 and shell_word. ShellWordQuoted::part at src/bun_core/fmt.rs:3505-3511 emits the ' as "'" between two '...' runs, giving 'a'"'"'b!x'. In interactive bash releases where histexpand only skips single-quoted strings and does not toggle a double-quote state before doing so, the scanner treats '"' as the quoted string and then sees !x outside any quote: !x fails with bash: !x: event not found or splices the previous command text into the argument before the shell parses the line. On the base branch every value sat in "...", so any ! was expanded in all bash versions; the change narrows but does not close the gap. The test at test/js/web/fetch/fetch.test.ts:4345-4359 runs bash --norc --noprofile on a script, where history expansion is off, so it cannot observe this.

Verification: Triggers only in an interactive bash whose history scanner skips single-quoted strings but does not track double quotes, with a word holding ' then !. ShellWordQuoted::part in src/bun_core/fmt.rs writes ' as "'", so a'b!x becomes 'a'"'"'b!x' and !x is expanded. On the base branch "a'b!x" also expanded !x. The paste test spawns bash non-interactively, where histexpand is off.

shell_word_utf8(self.f, &rest[at..at + len])?;
}
rest = &rest[at + len..];
}
if !rest.is_empty() {
self.settle_backslash(true)?;
self.enter(ShellWordIn::Single)?;
shell_word_utf8(self.f, rest)?;
}
Ok(())
}

fn finish(&mut self) -> fmt::Result {
self.settle_backslash(false)?;
self.enter(ShellWordIn::Nothing)
}
}

// js_bindings (fmtString for highlighter.test.ts) lives in src/jsc/fmt_jsc.rs
// alongside fmt_jsc.bind.ts; bun_core/ stays JSC-free.

Expand Down
13 changes: 8 additions & 5 deletions src/install/audit_fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,16 +1002,19 @@ impl FixPlan {
print_tokens(&item.ignore_tokens);
prettyln!("<r>");
for token in &item.ignore_tokens {
if !all_tokens.contains(token) {
if !token.is_empty() && !all_tokens.contains(token) {
all_tokens.push(token.clone());
}
}
}
pretty!(" <cyan>bun audit fix");
for token in &all_tokens {
pretty!(" --ignore {}", BStr::new(token));
if !all_tokens.is_empty() {
pretty!(" <cyan>bun audit fix");
for token in &all_tokens {
// The token is the registry's text, and this line is there to be pasted.
pretty!(" --ignore {}", bun_core::fmt::shell_word(&[token]));
}
prettyln!("<r>");
}
prettyln!("<r>");
prettyln!("");
}
if !self.unmatched.is_empty() {
Expand Down
37 changes: 20 additions & 17 deletions src/picohttp/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use core::fmt;

use bstr::BStr;

use bun_core::fmt::shell_word;
use bun_core::output::enable_ansi_colors_stderr;
use bun_core::pretty_fmt;

Expand Down Expand Up @@ -205,12 +206,11 @@ impl fmt::Display for HeaderCurlFormatter<'_> {
if header.value_len > 0 {
write!(
f,
"-H \"{}: {}\"",
BStr::new(header.name()),
BStr::new(header.value())
"-H {}",
shell_word(&[header.name(), b": ", header.value()])
)
} else {
write!(f, "-H \"{}\"", BStr::new(header.name()))
write!(f, "-H {}", shell_word(&[header.name()]))
}
}
}
Expand Down Expand Up @@ -330,8 +330,9 @@ pub struct RequestCurlFormatter<'a> {
}

impl<'a> RequestCurlFormatter<'a> {
fn is_printable_body(content_type: &[u8]) -> bool {
if content_type.is_empty() {
fn is_printable_body(content_type: &[u8], body: &[u8]) -> bool {
// No argument of a command can hold a NUL.
if content_type.is_empty() || body.is_empty() || strings::contains_char(body, 0) {
return false;
}

Expand All @@ -345,20 +346,27 @@ impl<'a> RequestCurlFormatter<'a> {
impl fmt::Display for RequestCurlFormatter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let request = self.request;
let url = [request.path];
let url = shell_word(&url);

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.

🔴 Windows users who paste the printed curl line into cmd.exe can now run server-chosen text, which the base's "..." form did not. src/picohttp/lib.rs:350 prints every argument through shell_word, whose '...' form (src/bun_core/fmt.rs:3401) is not a quote in cmd.exe, so a redirect to /x?a=1&calc prints 'http://h/x?a=1&calc' and cmd.exe runs calc after curl; every plain ?a=1&b=2 URL also stops replaying there. Fix: on Windows builds emit a form cmd.exe reads as one word for & | < > ^ in URL, header and body (e.g. a cfg(windows) branch keeping the base "..." quoting with " escaped). The PR text says Windows keeps "..."; the code does not.

Why this was flagged

Trigger: a Windows user runs with BUN_CONFIG_VERBOSE_FETCH=curl, a server answers a redirect whose Location query holds & (any ?a=1&b=2 URL also qualifies), and the user pastes the printed line into cmd.exe. src/http/lib.rs:1376 prints request_.curl(...) on every platform; RequestCurlFormatter::fmt at src/picohttp/lib.rs:349-350 goes through shell_word, which has no platform branch (src/bun_core/fmt.rs:3372-3410) and emits '...' for any word with a non-alphanumeric byte. cmd.exe does not treat ' as a quote and splits an unquoted line at &; so curl --http1.1 'http://h/x?a=1&calc' runs curl and then calc'. On the base branch (removed lines at src/picohttp/lib.rs:360 curl --http1.1 "{}") the URL sat inside "...", which cmd.exe does honour, and the WHATWG parser percent-encodes " in a URL, so the server-chosen URL could not break out there. The docs paragraph at docs/runtime/debugger.mdx:172 only tells users not to paste into cmd.exe; nothing in the code prevents the Windows build from printing the POSIX form.

Verification: src/http/lib.rs:1376 runs on every platform; src/picohttp/lib.rs:350 let url = shell_word(&url) emits 'http://h/x?a=1&b=2', and there is no cfg(windows) in src/picohttp/lib.rs or around ShellWord in src/bun_core/fmt.rs:3367-3410. cmd.exe does not recognize ' as a quote, so & splits the line. On the base the same URL printed as "http://h/x?a=1&b=2", so the replay worked.

if enable_ansi_colors_stderr() {
f.write_str(pretty_fmt!("<r><d>[fetch] $<r> ", true))?;

write!(
f,
pretty_fmt!("<b><cyan>curl<r> <d>--http1.1<r> <b>\"{}\"<r>", true),
BStr::new(request.path),
pretty_fmt!("<b><cyan>curl<r> <d>--http1.1<r> <b>{}<r>", true),
url,
)?;
} else {
write!(f, "curl --http1.1 \"{}\"", BStr::new(request.path))?;
write!(f, "curl --http1.1 {}", url)?;
}

// curl expands `[1-3]` and `{a,b}` in a URL unless globbing is off.
if strings::index_of_any(request.path, b"[]{}").is_some() {
f.write_str(" --globoff")?;
}

if request.method != b"GET" {
write!(f, " -X {}", BStr::new(request.method))?;
write!(f, " -X {}", shell_word(&[request.method]))?;
}

if self.ignore_insecure {
Expand All @@ -382,13 +390,8 @@ impl fmt::Display for RequestCurlFormatter<'_> {
}
}

if !self.body.is_empty() && Self::is_printable_body(content_type) {
f.write_str(" --data-raw ")?;
bun_core::js_printer::write_json_string(
self.body,
f,
bun_core::strings::Encoding::Utf8,
)?;
if Self::is_printable_body(content_type, self.body) {
write!(f, " --data-raw {}", shell_word(&[self.body]))?;
}

Ok(())
Expand Down
14 changes: 11 additions & 3 deletions src/runtime/cli/audit_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1016,10 +1016,18 @@ fn keep_vulnerability(
}

fn ignore_token(vulnerability: &VulnerabilityInfo) -> Box<[u8]> {
match strings::index_of(&vulnerability.url, b"GHSA-") {
Some(i) => Box::from(&vulnerability.url[i..]),
None => vulnerability.id.clone(),
if let Some(i) = strings::index_of(&vulnerability.url, b"GHSA-") {
// The id, not the rest of the url after it.
let id = &vulnerability.url[i..];
let len = id
.iter()
.position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'-'))
.unwrap_or(id.len());
if len > b"GHSA-".len() {
return Box::from(&id[..len]);
}
}
vulnerability.id.clone()
}

fn to_advisory(vulnerability: VulnerabilityInfo) -> Advisory {
Expand Down
57 changes: 57 additions & 0 deletions test/cli/install/bun-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
bunEnv,
bunExe,
gunzipJsonRequest,
isWindows,
normalizeBunSnapshot,
runBunInstall,
tempDir,
} from "harness";
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveBulkAdvisoryFixture } from "./registry/fixtures/audit/audit-fixtures";

Expand Down Expand Up @@ -1832,6 +1834,61 @@ describe("`bun audit fix`", () => {
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});

// The advisory's `url` and `id` are the registry's text, and the `--ignore` line is printed to be pasted.
test.concurrent("the --ignore line takes each advisory as one shell word", async () => {
const ghsa = "GHSA-xxxx-xxxx-xxxx";
const id = "2 --latest; touch pwned";
await using server = startRegistry({
"a-dep": [
{ ...adv("<=1.0.10"), url: "https://example.invalid/" + ghsa + ";touch${IFS}pwned" },
{ ...adv("<=1.0.10"), id: id as unknown as number },
],
});
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "^1.0.0" } });

const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)

no published version fixes:
a-dep@1.0.10 2 --latest; touch pwned, GHSA-xxxx-xxxx-xxxx
bun audit fix --ignore '2 --latest; touch pwned' --ignore GHSA-xxxx-xxxx-xxxx

Fixed 0 of 2 vulnerabilities (checked 1)
2 vulnerabilities remaining"
`);
expect(exitCode).toBe(1);

if (!isWindows) {
// Paste the line: `bun` is a function that prints its arguments.
const line = stdout.split("\n").find(line => line.includes("--ignore"))!;
using cwd = tempDir("audit-ignore-paste", {});
await using sh = Bun.spawn({
cmd: ["sh", "-c", `bun() { printf '[%s]\\n' "$@"; }\n${line}`],
env: bunEnv,
cwd: String(cwd),
stdout: "pipe",
stderr: "pipe",
});
const [pasted, stderr, shExitCode] = await Promise.all([sh.stdout.text(), sh.stderr.text(), sh.exited]);
expect({ pasted, stderr, shExitCode, created: readdirSync(String(cwd)) }).toEqual({
pasted: ["audit", "fix", "--ignore", id, "--ignore", ghsa].map(word => `[${word}]\n`).join(""),
stderr: "",
shExitCode: 0,
created: [],
});
}

// The printed tokens are what `--ignore` takes.
const ignored = await auditFix(dir, "--ignore", id, "--ignore", ghsa);
expect(normalizeBunSnapshot(ignored.stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)

No vulnerabilities found (checked 1 package, 2 ignored)"
`);
expect(ignored.exitCode).toBe(0);
});

// pnpm#11101: a workspace package sharing a name with an advised npm package is not audited.
test.concurrent("a workspace package is never matched against an advisory for its name", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
Expand Down
Loading
Loading