Bun.serve: omit the automatic content-disposition for a file name with a control byte or a backslash
Problem
Bun.serveaddscontent-disposition: filename="<basename>"to a body with a file name. A control byte or DEL in the name makes the field invalid. Node fails withHPE_INVALID_HEADER_TOKEN, bun'sfetch()withMalformed_HTTP_Response.- A backslash starts a quoted-pair.
filename="report\"never closes. A client that unescapes readsfilename="a\b.bin"asab.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 ranbun-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 isqdtextbytes (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 orFile.name. A multipart upload name keeps a control byte throughreq.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 writesfilename="...". 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
routesfile 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 namedcaf\xE9.bingetsfilename="caf\xEF\xBF\xBD.bin"on release 1.4.3, and the code on main is the same. Bun 1.3.14 sent the byteE9.file.nameholds U+FFFD for that file, and #40436 made this header followfile.name. A change needs a decision on that rule and on the name cache inBlob::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 thewrite!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 5filename=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