Bun.serve: omit the automatic content-disposition for a file name with a control byte or a backslash

RO
19d ago
+100-1

Problem

  • Bun.serve adds content-disposition: filename="<basename>" to a body with a file name. A control byte or DEL in the name makes the field invalid. Node fails with HPE_INVALID_HEADER_TOKEN, bun's fetch() with Malformed_HTTP_Response.
  • A backslash starts a quoted-pair. filename="report\" never closes. A client that unescapes reads filename="a\b.bin" as ab.bin.
  • The cause is the check in render_metadata (src/runtime/server/RequestContext.rs:3823). It leaves the header out only for CR, LF, NUL and ".

Fix

  • Write the header only when every name byte that it writes is qdtext. A name that fails gets no header, as a name with " does today (#30722). Other names keep their header.
  • Correct because the value then always parses as a quoted-string. A control byte cannot be escaped. RFC 6266 Appendix D says to avoid \ here: some user agents do not unescape it.
  • Verified: three new tests in test/js/bun/http/bun-serve-file.test.ts. The unfixed build fails all three. Also ran bun-serve-headers.test.ts, bun-serve-static.test.ts.
  • Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • A quoted-string (RFC 9110 5.6.4) is ", content, ". The content is qdtext bytes (HTAB, SP, !, # to [, ] to ~, 0x80 to 0xFF) and quoted-pairs. A quoted-pair is \ plus one byte, and it means that byte.
  • The name is the Bun.file() path or File.name. A multipart upload name keeps a control byte through req.formData().

Downsides

  • A name with \ loses its filename hint. No client fails on that header today.
  • A lenient client (curl) accepts the control byte today and loses the hint too.
Notes

Origin: a review comment on #43677 found the backslash case. No user report exists. The control-byte case came up during the work on it.

Repro on Linux, release 1.4.3 and main:

const server = Bun.serve({
  port: 0,
  fetch: req =>
    new Response(new File(["x"], new URL(req.url).searchParams.get("name"), { type: "application/octet-stream" })),
});
const get = name => fetch(`${server.url}?name=${encodeURIComponent(name)}`);
console.log((await get("report\\")).headers.get("content-disposition")); // filename="report\"
await get("ctl\x01name.bin"); // rejects: Malformed_HTTP_Response

Node v26.3.0 http.get against the same server fails with HPE_INVALID_HEADER_TOKEN for the names with 0x01 and 0x7F. It returns 200 for both backslash names. curl 8.14.1 accepts all four names. curl -OJ saves back\slash.bin as slash.bin, so clients do not agree on what a \ in the name means. A raw multipart upload with filename="up\x01load.bin", returned with new Response(form.get("f")), puts content-disposition: filename="up\x01load.bin" on the wire.

What the tests pin: which names get the automatic header. On the unfixed build the Bun.file() test and the File test each fail with 4 headers that must not be sent, and the long-name test fails with 1. They read the header off the wire with Bun.connect, because fetch() cannot parse the response that the unfixed build sends for a control byte. The names with " and LF pass on both builds and guard the rewritten predicate. The tests do not make the rest of the value a contract. The header still has no disposition type (#33655), and a non-ASCII name still goes out as UTF-8 bytes with no filename* parameter.

Windows: \ is a path separator there, so it never reaches the basename. The Bun.file() test is skipped on Windows because a Windows file name cannot hold \, " or a control byte. The File test runs on Windows. I ran it there against the unfixed canary build. It fails only on the two control-byte names, so the Windows expectations for the other 9 names hold.

Long names: the header carries at most the first 992 bytes of the basename, as before. The check covers exactly those bytes, as the check from #30722 did. A File name of 994 a bytes followed by \x01tail.bin gets filename="aaa...a" with the first 992 bytes, and fetch() returns 200. A name with \ as byte 992, the last written byte, gets no header. This keeps the scan bounded for a File name of any length. If the cut splits a UTF-8 character, the formatter writes U+FFFD (EF BF BD) for the split part, so the value stays valid UTF-8. The third test pins both sides of that boundary with a File body, because a file name on disk cannot be this long.

Not in this PR:

  • The multipart/form-data serializer (src/runtime/webcore/Blob.rs) also writes filename="...". It follows the WHATWG form-data encoding, which is a different grammar.
  • Header values that the handler sets. This PR only changes the header that bun adds by itself.
  • Static routes file responses. They do not send the automatic header.
  • The bytes for a file name that is not valid UTF-8. A byte path such as Bun.file(Buffer) can have one. The header is written through a formatter that puts U+FFFD (EF BF BD) in place of each invalid sequence. A file named caf\xE9.bin gets filename="caf\xEF\xBF\xBD.bin" on release 1.4.3, and the code on main is the same. Bun 1.3.14 sent the byte E9. file.name holds U+FFFD for that file, and #40436 made this header follow file.name. A change needs a decision on that rule and on the name cache in Blob::get_name_string (#35523). A build of this branch sends the same bytes as release 1.4.3 for such a name.

Cost: the new check is a scalar loop over the basename, at most 992 bytes, and it runs only for a response that gets the automatic header. The old check used the SIMD strings::contains_any for names of 16 bytes or more. A qdtext test needs byte ranges, which that helper cannot express. is_valid_blob_type (src/runtime/webcore/Blob.rs) checks a byte class the same way.

Related open PRs:

  • #33655 adds the attachment; disposition type on the write! line. It does not touch the predicate, and a trial merge of the two branches is clean. The new tests assert the exact header value, so the PR that lands second must update the 5 filename= expectations.
  • #43677 moves this block into a free function write_filename_disposition. The PR that lands second must apply the one-line predicate change again.

Also ran: cargo clippy -p bun_runtime --no-deps, test/internal/source-lints/, the whole of bun-serve-file.test.ts (129 pass).


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/bun-serve-file.test.ts