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('') },