diff --git a/packages/fetch/src/body.test.ts b/packages/fetch/src/body.test.ts
index 03192c2a..40c8eee0 100644
--- a/packages/fetch/src/body.test.ts
+++ b/packages/fetch/src/body.test.ts
@@ -344,10 +344,41 @@ describe('toFetchBody', () => {
expect(headers).toEqual({
'content-disposition': 'inline; filename="__mocked__"',
'content-length': '0',
- 'content-type': '',
+ 'content-type': 'application/octet-stream',
+ 'x-custom-header': 'custom-value',
+ 'standard-server': 'file',
+ })
+ })
+
+ it('file whose type the File constructor normalized to empty', async () => {
+ // a declared type with a byte outside 0x20-0x7E is dropped, like an untrusted upload's could be
+ const file = new File([''], 'a.png', { type: 'image/png\xFF' })
+ expect(file.type).toBe('')
+
+ generateContentDispositionSpy.mockReturnValue('inline; filename="__mocked__"')
+
+ const [body, headers] = toFetchBody(file, baseHeaders, {})
+
+ expect(body).toBe(file)
+ expect(headers).toEqual({
+ 'content-disposition': 'inline; filename="__mocked__"',
+ 'content-length': '25',
+ 'content-type': 'application/octet-stream',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
+
+ const response = new Response(body, { headers: toFetchHeaders(headers) })
+ expect(response.headers.get('content-type')).toBe('application/octet-stream')
+ expect(await response.text()).toBe('')
+ })
+
+ it('file without type keeps an explicit content-type', () => {
+ const file = new File(['foo'], 'foo.txt')
+
+ const [, headers] = toFetchBody(file, { ...baseHeaders, 'content-type': 'text/plain' }, {})
+
+ expect(headers['content-type']).toBe('text/plain')
})
it('file', () => {
@@ -417,6 +448,18 @@ describe('toFetchBody', () => {
expect(headers['content-type']).toEqual([])
})
+ it('file without type and with removed content-type header', () => {
+ const file = new File(['foo'], 'foo.bin')
+
+ const [body, headers] = toFetchBody(file, { ...baseHeaders, 'content-type': [] }, {})
+
+ expect(body).toBe(file)
+ expect(headers['content-type']).toEqual([])
+
+ const response = new Response(body, { headers: toFetchHeaders(headers) })
+ expect(response.headers.has('content-type')).toBe(false)
+ })
+
it('file with size=nan', () => {
// BunS3 is a File instance but has an unknown size (NaN), so to support it we should return a stream in this case.
const file = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
@@ -547,6 +590,17 @@ it.each([
expect(await file.text()).toEqual('foo')
},
},
+ {
+ // received with the type it was sent with, the same one FormData gives an untyped File
+ name: 'file-without-type',
+ createBody: () => new File(['foo'], 'foo.bin'),
+ assertBody: async (file: any) => {
+ expect(file).toBeInstanceOf(File)
+ expect(file.name).toEqual('foo.bin')
+ expect(file.type).toEqual('application/octet-stream')
+ expect(await file.text()).toEqual('foo')
+ },
+ },
{
name: 'event-stream',
createBody: async function* gen() {
diff --git a/packages/fetch/src/body.ts b/packages/fetch/src/body.ts
index 216c3e17..2205b381 100644
--- a/packages/fetch/src/body.ts
+++ b/packages/fetch/src/body.ts
@@ -106,7 +106,8 @@ export function toFetchBody(
// and a transport can drop the empty ones (bun) or a proxy rewrite the content-length.
headers['standard-server'] ??= 'file' satisfies StandardBodyHint // A File is also a Blob
- 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'
// FIX: Bun returns `undefined` for an empty File name, despite the spec requiring a string
headers['content-disposition'] ??= generateContentDisposition(body instanceof File ? body.name ?? '' : 'blob')
diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts
index 891faf50..9239b444 100644
--- a/packages/node/src/body.test.ts
+++ b/packages/node/src/body.test.ts
@@ -599,10 +599,41 @@ describe('toNodeHttpBody', () => {
expect(headers).toEqual({
'content-disposition': 'inline; filename="__mocked__"',
'content-length': '0',
- 'content-type': '',
+ 'content-type': 'application/octet-stream',
+ 'x-custom-header': 'custom-value',
+ 'standard-server': 'file',
+ })
+ })
+
+ it('file whose type the File constructor normalized to empty', async () => {
+ // a declared type with a byte outside 0x20-0x7E is dropped, like an untrusted upload's could be
+ const file = new File([''], 'a.png', { type: 'image/png\xFF' })
+ expect(file.type).toBe('')
+
+ generateContentDispositionSpy.mockReturnValue('inline; filename="__mocked__"')
+
+ const [body, headers] = toNodeHttpBody(file, baseHeaders, {})
+
+ expect(body).toBeInstanceOf(Readable)
+ expect(headers).toEqual({
+ 'content-disposition': 'inline; filename="__mocked__"',
+ 'content-length': '25',
+ 'content-type': 'application/octet-stream',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
+
+ const response = new Response(body, { headers: toFetchHeaders(headers) })
+ expect(response.headers.get('content-type')).toBe('application/octet-stream')
+ expect(await response.text()).toBe('')
+ })
+
+ it('file without type keeps an explicit content-type', async () => {
+ const file = new File(['foo'], 'foo.txt')
+
+ const [, headers] = toNodeHttpBody(file, { ...baseHeaders, 'content-type': 'text/plain' }, {})
+
+ expect(headers['content-type']).toBe('text/plain')
})
it('file with size=nan', async () => {
@@ -759,5 +790,19 @@ describe('toNodeHttpBody', () => {
const fetchHeaders = toFetchHeaders(headers)
expect(fetchHeaders.has('content-type')).toBe(false)
})
+
+ it('file without type: unset content-type', async () => {
+ const file = new File(['foo'], 'foo.bin')
+ const [body, headers] = toNodeHttpBody(file, {
+ ...baseHeaders,
+ 'content-type': [],
+ })
+
+ expect(body).toBeInstanceOf(Readable)
+ expect(headers['content-type']).toEqual([])
+
+ const fetchHeaders = toFetchHeaders(headers)
+ expect(fetchHeaders.has('content-type')).toBe(false)
+ })
})
})
diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts
index d20b0a75..b2cc64d0 100644
--- a/packages/node/src/body.ts
+++ b/packages/node/src/body.ts
@@ -113,7 +113,8 @@ export function toNodeHttpBody(
// and a transport can drop the empty ones (bun) or a proxy rewrite the content-length.
headers['standard-server'] ??= 'file' satisfies StandardBodyHint // A File is also a Blob
- 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'
// FIX: Bun returns `undefined` for an empty File name, despite the spec requiring a string
headers['content-disposition'] ??= generateContentDisposition(body instanceof File ? body.name ?? '' : 'blob')
diff --git a/packages/peer/src/body.test.ts b/packages/peer/src/body.test.ts
index 7606a0bf..a79819e7 100644
--- a/packages/peer/src/body.test.ts
+++ b/packages/peer/src/body.test.ts
@@ -340,6 +340,33 @@ describe('encodeAtomicStandardBody', () => {
expect(removedHeaders['content-type']).toEqual([])
})
+ it('encodes File body without type as application/octet-stream', async () => {
+ // a declared type with a byte outside 0x20-0x7E is dropped, like an untrusted upload's could be
+ const file = new File([''], 'a.png', { type: 'image/png\xFF' })
+ expect(file.type).toBe('')
+
+ const { jsonBody, headers, binary } = await encodeAtomicStandardBody(file, {})
+
+ expect(jsonBody).toBe(undefined)
+ expect(headers['content-type']).toBe('application/octet-stream')
+ expect(headers['content-disposition']).toBe(generateContentDisposition('a.png'))
+ expect(headers['content-length']).toBe('25')
+ expect(binary).toBe(file)
+
+ const { headers: emptyHeaders } = await encodeAtomicStandardBody(new Blob([]), {})
+ expect(emptyHeaders['content-type']).toBe('application/octet-stream')
+ })
+
+ it('encodes File body without type and preserves existing content-type header', async () => {
+ const file = new File(['foo'], 'foo.txt')
+
+ const { headers } = await encodeAtomicStandardBody(file, { 'content-type': 'text/plain' })
+ expect(headers['content-type']).toBe('text/plain')
+
+ const { headers: removedHeaders } = await encodeAtomicStandardBody(file, { 'content-type': [] })
+ expect(removedHeaders['content-type']).toEqual([])
+ })
+
it('encodes URLSearchParams body', async () => {
const params = new URLSearchParams('a=1&b=2')
const { jsonBody, headers, binary } = await encodeAtomicStandardBody(params, {})
@@ -440,4 +467,23 @@ describe('encodeAtomicStandardBody', () => {
expect(received.type).toBe('application/pdf')
expect(await received.text()).toBe('file content')
})
+
+ it('round-trips a File without type as application/octet-stream', async () => {
+ const file = new File(['file content'], 'data.bin')
+ const encoded = await encodeAtomicStandardBody(file, {})
+
+ const { resolveBody } = toStandardBody({
+ id: '1',
+ kind: 'request',
+ json: { url: '/upload', headers: encoded.headers, body: encoded.jsonBody },
+ binary: encoded.binary,
+ }, vi.fn())
+
+ const received = await resolveBody() as File
+ expect(received).toBeInstanceOf(File)
+ expect(received.name).toBe('data.bin')
+ // the same type FormData gives an untyped File
+ expect(received.type).toBe('application/octet-stream')
+ expect(await received.text()).toBe('file content')
+ })
})
diff --git a/packages/peer/src/body.ts b/packages/peer/src/body.ts
index 81f3733e..182ddaf1 100644
--- a/packages/peer/src/body.ts
+++ b/packages/peer/src/body.ts
@@ -123,7 +123,8 @@ export async function encodeAtomicStandardBody(
}
if (body instanceof Blob) {
- 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'
// FIX: Bun returns `undefined` for an empty File name, despite the spec requiring a string
headers['content-disposition'] ??= generateContentDisposition(
body instanceof File ? body.name ?? '' : 'blob',
diff --git a/tests/bun/tests/data-transfer.test.ts b/tests/bun/tests/data-transfer.test.ts
index 7850b622..17a1dd49 100644
--- a/tests/bun/tests/data-transfer.test.ts
+++ b/tests/bun/tests/data-transfer.test.ts
@@ -120,14 +120,15 @@ for (const [adapter, createClientServer] of ADAPTERS) {
},
},
{
- // Bun drops empty headers like content-type, so only the body hint identifies this one
+ // Bun drops empty headers, so an empty file must not rely on one to be identified
name: 'empty-file',
createBody: () => new File([], '', { type: '' }),
assertBody: async (body: any) => {
expect(body).toBeInstanceOf(File)
// Bun returns `undefined` instead of '' for an empty File name
expect(body.name ?? '').toEqual('')
- expect(body.type).toEqual('')
+ // an untyped file is sent as application/octet-stream, the same type FormData gives it
+ expect(body.type).toEqual('application/octet-stream')
expect(body.size).toEqual(0)
expect(await body.text()).toEqual('')
},
diff --git a/tests/data-transfer.test.ts b/tests/data-transfer.test.ts
index a1e865c1..91283cd9 100644
--- a/tests/data-transfer.test.ts
+++ b/tests/data-transfer.test.ts
@@ -45,7 +45,7 @@ describe.each([
['message-port-fetch-streamed', () => createMessagePortClientServerTest({ fetchStreamed: true })],
['node-ws', () => createNodeWsClientServerTest()],
['node-ws-fetch-streamed', () => createNodeWsClientServerTest({ fetchStreamed: true })],
-])('data transfer: $0', (_, createClientServer) => {
+])('data transfer: $0', (transport, createClientServer) => {
const clientServer = createClientServer()
beforeEach(() => {
@@ -135,7 +135,9 @@ describe.each([
assertBody: async (body: any) => {
expect(body).toBeInstanceOf(File)
expect(body.name).toEqual('')
- expect(body.type).toEqual('')
+ // an untyped file is sent as application/octet-stream, the same type FormData gives it,
+ // except in process, where the File is handed over untouched
+ expect(body.type).toEqual(transport === 'inprogress' ? '' : 'application/octet-stream')
expect(body.size).toEqual(0)
expect(await body.text()).toEqual('')
},
diff --git a/tests/deno/tests/data-transfer.test.ts b/tests/deno/tests/data-transfer.test.ts
index fc6df411..c3c2b5e3 100644
--- a/tests/deno/tests/data-transfer.test.ts
+++ b/tests/deno/tests/data-transfer.test.ts
@@ -137,7 +137,8 @@ for (const [adapter, createClientServer] of ADAPTERS) {
assertBody: async (body: any) => {
expect(body).toBeInstanceOf(File)
expect(body.name ?? '').toEqual('')
- expect(body.type).toEqual('')
+ // an untyped file is sent as application/octet-stream, the same type FormData gives it
+ expect(body.type).toEqual('application/octet-stream')
expect(body.size).toEqual(0)
expect(await body.text()).toEqual('')
},