diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 04861e5dde0d..20e8ae188cf8 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -4339,9 +4339,16 @@ fn _on_structured_clone_deserialize>( blob.offset.set(0); } - if !content_type.is_empty() { - blob.content_type - .set(BlobContentType::Owned(std::sync::Arc::from(content_type))); + // Wire bytes are untrusted: apply the same WHATWG ยง3.1 validate+lowercase as + // `new Blob(parts, {type})` so a crafted record cannot materialize a + // `Blob.type` no JS could construct and feed it into HTTP headers. + if !content_type.is_empty() && is_valid_blob_type(&content_type) { + blob.content_type.set( + match global_this.bun_vm().as_mut().mime_type(&content_type) { + Some(mime) => BlobContentType::from(mime), + None => BlobContentType::from_lowercased(&content_type), + }, + ); blob.content_type_was_set.set(content_type_was_set); } diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index 3ad9a1e5ddbb..f379404caa71 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -609,6 +609,61 @@ describe("structuredClone with Blob and File", () => { }); }); + test("crafted content-type is validated and normalized like the Blob constructor", async () => { + // A Blob's `type` is only stored when every byte is in U+0020..U+007E, and + // is ASCII-lowercased before storage. The deserializer must enforce the + // same rule on the wire record's content-type so a crafted image cannot + // materialize a `Blob.type` no `new Blob()` could produce and feed raw + // bytes (control chars, CR/LF) into `Response`'s `Content-Type` header. + // + // Locate the field by serializing a Blob whose type is a distinctive + // all-lowercase sentinel, then overwrite it in place; keeps the test + // robust against serializer framing changes. + const sentinel = "qwerty/probe-ct"; + const image = Buffer.from(serialize(new Blob(["body"], { type: sentinel }))); + const at = image.indexOf(Buffer.from(sentinel)); + expect(at).toBeGreaterThan(0); + + function craftType(bytes: string) { + expect(bytes.length).toBe(sentinel.length); + const out = Buffer.from(image); + out.set(Buffer.from(bytes, "binary"), at); + return out; + } + + for (const de of [deserialize, (b: Buffer) => v8.deserialize(b)] as const) { + // A control byte anywhere in the field makes the whole type the empty + // string, and nothing reaches an outgoing Content-Type header. + const ctl = de(craftType("TEXT/HTM\x01;A=BCD")); + expect(ctl).toBeInstanceOf(Blob); + expect(await ctl.text()).toBe("body"); + expect({ + type: ctl.type, + ctor: new Blob(["x"], { type: "TEXT/HTM\x01;A=BCD" }).type, + response: new Response(ctl).headers.get("content-type"), + }).toEqual({ type: "", ctor: "", response: null }); + + // CR/LF (the header-injection vector) and DEL are rejected the same way. + for (const c of ["\r", "\n", "\x7f"]) { + expect(de(craftType(`text/plain${c};q=1`)).type).toBe(""); + } + + // A byte outside ASCII (>= 0x80) is rejected. + expect(de(craftType("text/pl\u00e1in;a=bc")).type).toBe(""); + + // Valid bytes with uppercase are accepted and lowercased, matching the + // constructor's normalization. + const upper = "ABCDE/WXYZ;Q=AA"; + expect({ + type: de(craftType(upper)).type, + ctor: new Blob(["x"], { type: upper }).type, + }).toEqual({ type: "abcde/wxyz;q=aa", ctor: "abcde/wxyz;q=aa" }); + + // A value the constructor already produces round-trips unchanged. + expect(de(image).type).toBe(sentinel); + } + }); + test("truncated payload at every byte boundary throws cleanly", () => { // Every truncation point must surface as a thrown error (never a // partially-constructed Blob, never a crash). This is the functional