Conversation
…yped blobs When the app set no content-type and `body.type` was `''`, the adapters sent an empty `Content-Type`. That happens for a File without a type, and for an upload whose declared type the File constructor drops (any byte outside 0x20-0x7E). Browsers MIME-sniff an empty content-type, so echoing such an upload back could serve attacker HTML on the app's origin. Untyped blobs are now sent as `application/octet-stream`, the type FormData already gives an untyped File. Explicit and removed (`[]`) content-types are still honored. An untyped File received over an adapter now carries `application/octet-stream` instead of `''`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ2xs69Qg3HL5bGmx69ps3
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
ℹ️ No critical issues — minor suggestions inline. The fallback change itself is correct, and the
inprogresstransport carve-out is right.
Reviewed changes
- Untyped-blob fallback — the
Blob/Filebranch oftoNodeHttpBody,toFetchBody, andencodeAtomicStandardBodynow usesheaders['content-type'] ??= body.type || 'application/octet-stream';??=still preserves an explicit content-type and the[]removal sentinel, while||closes the empty-body.typecase. Fastify and aws-lambda inherit the node fix viatoNodeHttpBody. - Tests — new/updated cases in the three
body.test.tsfiles (constructor-normalized-to-emptyFile, explicit content-type,[]removal, receive-side round trip) and updatedempty-fileexpectations intests/{,bun/,deno/}data-transfer.test.ts, including thetransport === 'inprogress' ? '' : 'application/octet-stream'carve-out for the one transport that hands theFileover untouched.
pnpm exec vitest run packages/{fetch,node,peer}/src/body.test.ts passes (117 tests). I verified the new assertions are discriminating: they fail against the pre-PR body.type result of ''.
ℹ️ Stale fastify docs and harness comment
The adapters no longer send an empty content-type for an untyped Blob/File, but packages/fastify/README.md:122 still reads "A Blob or File without a type is sent with an empty content-type header", and the workaround comment at tests/client-server.fastify.ts:19 rests on the same premise. The 415 hook still matters for non-adapter clients, so the guidance is worth keeping — only the claim needs rewording (and the hook is now dead for the empty-file case).
Technical details
# Stale untyped-content-type documentation
## Affected sites
- `packages/fastify/README.md:122` — states an untyped `Blob`/`File` is "sent with an empty `content-type` header"; only true for clients that bypass the send adapters now.
- `tests/client-server.fastify.ts:19` — comment/`onRequest` hook premised on the adapters sending `''`; no longer fires for the `empty-file` data-transfer case.
## Required outcome
- Docs/harness comments should describe the post-PR behavior (adapters send `application/octet-stream`; an empty content-type is only produced by a caller that explicitly sets `content-type: ''`).DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏
|
|
||
| headers['content-type'] ??= body.type | ||
| // An empty content-type makes browsers sniff the body, which can serve an upload as HTML | ||
| headers['content-type'] ??= body.type || 'application/octet-stream' |
There was a problem hiding this comment.
body.type || 'application/octet-stream' only replaces the empty string, but File.type can also be non-empty and non-parsable ('/', ' ', 'text', ';'). The node and aws-lambda receivers build new File(…, { type: contentType }) straight from a raw upload header (packages/node/src/body.ts:71, packages/aws-lambda/src/body.ts:60), and a browser treats such a supplied type as undefined and sniffs it — so this arm does not close the sniffing case for those files. The same applies to packages/fetch/src/body.ts:110 and packages/peer/src/body.ts:127.
Technical details
# Untyped fallback only covers the empty string
## Affected sites
- `packages/node/src/body.ts:117` — `??= body.type || 'application/octet-stream'`
- `packages/fetch/src/body.ts:110` — same
- `packages/peer/src/body.ts:127` — same
- `packages/node/src/body.ts:71` — `_streamToFile(..., contentType ?? '')` sets `File.type` from the raw header
- `packages/aws-lambda/src/body.ts:60` — same
## Required outcome
- An echoed `Blob`/`File` whose `.type` is not a parsable MIME type should not reach the wire sniffable.
## Suggested approach (optional)
- `x-content-type-options: nosniff` (already on the maintainer list) covers this more generally than tightening the MIME check here.
## Open questions for the human (optional)
- Fold this into the deferred `nosniff` decision, or also validate `body.type` at the send sites?
Follow-up to #121. That PR made an explicit app
content-typewin overbody.type. This one closes the remaining gap: when the app sets no content-type andbody.typeis'', the adapters sent an emptyContent-Type:header.body.typeis''for a File without a type. It is also''when an uploader declared a type the File constructor drops: any byte outside 0x20–0x7E, e.g. a multipart part withContent-Type: image/png\xff. Browsers MIME-sniff an empty content-type. So an app that echoes an upload back could serve attacker HTML on its own origin, andcontent-disposition: inlinemakes that worse.Before, on main:
After, node, fetch and peer all send
content-type: application/octet-stream.Changes
packages/node/src/body.ts,packages/fetch/src/body.ts,packages/peer/src/body.ts:headers['content-type'] ??= body.type || 'application/octet-stream'. Fastify and aws-lambda get the fix throughtoNodeHttpBody.content-type: []still removes the header;''is left alone.Receive-side round trip
An untyped Blob/File sent through an adapter now comes back with type
application/octet-streaminstead of''. I kept that and did not mapapplication/octet-streamback to''on thestandard-server: filepath:new Response(formData).formData()turns an untyped File intoapplication/octet-streamon node and bun, because the multipart encoder uses that type for untyped files. Untyped Blobs now behave the same way.application/octet-stream. peer's receive side already falls back to it (type: contentType ?? 'application/octet-stream').application/octet-streamwould arrive as''. It would also add special cases to the node, fetch, peer and aws-lambda receivers.Tests
body.test.ts:application/octet-stream;image/png\xFFnormalized-type File, checked through a realResponse;[]removal on an untyped File;file-without-typeround trip in the table that runs without thestandard-serverheader.body.test.ts: encoding an untyped File and an empty Blob, explicit/[]overrides, and an encode → decode round trip.tests/data-transfer.test.ts(+ bun/deno copies):empty-filenow expectsapplication/octet-stream. The in-processinprogresstransport hands over the File object untouched, so it still expects''. I reworded the bun comment on this case: it said the file's content-type header was empty and got dropped, which is no longer true.Checks run locally:
pnpm run lintandpnpm run type:checkpass.vitest runpasses (59 files, 1329 tests).bun testintests/bun: all data-transfer cases pass. The same 5signal-and-cancel: bun-fetchtests fail with and without this change.For maintainers to decide:
x-content-type-options: nosniffNot in this PR. We could also send
x-content-type-options: nosniffby default for Blob/File responses, using??=so apps can override or remove it with[]. With the change above, an untyped upload no longer gets sniffed. nosniff would also cover uploads whose declared type a browser would sniff anyway. It would also stop script/style loads of mismatched types. It adds a header to every file response, though, so I'm leaving that call to you. I didn't touch theinlinecontent-disposition default.🤖 Generated with Claude Code
https://claude.ai/code/session_01PZ2xs69Qg3HL5bGmx69ps3
Generated by Claude Code