Skip to content

fix(node, fetch, peer): fall back to application/octet-stream for untyped blobs - #136

Closed
dinwwwh wants to merge 1 commit into
mainfrom
claude/admiring-archimedes-wgtnyq
Closed

dinwwwh wants to merge 1 commit into
mainfrom
claude/admiring-archimedes-wgtnyq

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 29, 2026

Copy link
Copy Markdown
Member

Follow-up to #121. That PR made an explicit app content-type win over body.type. This one closes the remaining gap: when the app sets no content-type and body.type is '', the adapters sent an empty Content-Type: header.

body.type is '' 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 with Content-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, and content-disposition: inline makes that worse.

Before, on main:

toNodeHttpBody(new File(['<script>1</script>'], 'a.png', { type: 'image/png\xff' }), {})
// content-type: '', content-disposition: inline; filename="a.png"

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 through toNodeHttpBody.
  • These still behave as before:
    • an explicit content-type wins;
    • content-type: [] still removes the header;
    • a content-type the app explicitly sets to '' is left alone.

Receive-side round trip

An untyped Blob/File sent through an adapter now comes back with type application/octet-stream instead of ''. I kept that and did not map application/octet-stream back to '' on the standard-server: file path:

  • The platform already does this. new Response(formData).formData() turns an untyped File into application/octet-stream on node and bun, because the multipart encoder uses that type for untyped files. Untyped Blobs now behave the same way.
  • RFC 9110 §8.3 treats a missing content-type as application/octet-stream. peer's receive side already falls back to it (type: contentType ?? 'application/octet-stream').
  • Mapping it back would break the opposite case: a File explicitly typed application/octet-stream would arrive as ''. It would also add special cases to the node, fetch, peer and aws-lambda receivers.

Tests

  • node / fetch body.test.ts:
    • an empty untyped Blob now expects application/octet-stream;
    • new cases:
      • the image/png\xFF normalized-type File, checked through a real Response;
      • an explicit content-type on an untyped File;
      • [] removal on an untyped File;
    • fetch only: a file-without-type round trip in the table that runs without the standard-server header.
  • peer 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-file now expects application/octet-stream. The in-process inprogress transport 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 lint and pnpm run type:check pass.
  • vitest run passes (59 files, 1329 tests).
  • bun test in tests/bun: all data-transfer cases pass. The same 5 signal-and-cancel: bun-fetch tests fail with and without this change.
  • Deno isn't installed here, so the deno copy only got the one-line assertion change and a type check.

For maintainers to decide: x-content-type-options: nosniff

Not in this PR. We could also send x-content-type-options: nosniff by 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 the inline content-disposition default.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PZ2xs69Qg3HL5bGmx69ps3


Generated by Claude Code

…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
@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@136

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@136

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@136

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@136

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@136

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@136

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@136

commit: 91d0fda

@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/admiring-archimedes-wgtnyq (91d0fda) with main (39c1ce1)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No critical issues — minor suggestions inline. The fallback change itself is correct, and the inprogress transport carve-out is right.

Reviewed changes

  • Untyped-blob fallback — the Blob/File branch of toNodeHttpBody, toFetchBody, and encodeAtomicStandardBody now uses headers['content-type'] ??= body.type || 'application/octet-stream'; ??= still preserves an explicit content-type and the [] removal sentinel, while || closes the empty-body.type case. Fastify and aws-lambda inherit the node fix via toNodeHttpBody.
  • Tests — new/updated cases in the three body.test.ts files (constructor-normalized-to-empty File, explicit content-type, [] removal, receive-side round trip) and updated empty-file expectations in tests/{,bun/,deno/}data-transfer.test.ts, including the transport === 'inprogress' ? '' : 'application/octet-stream' carve-out for the one transport that hands the File over 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: ''`).

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

Comment thread packages/node/src/body.ts

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'

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.

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?

@dinwwwh dinwwwh closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants