Skip to content

Commit f6c5dda

Browse files
authored
fix(fetch): prefer filename* over filename in multipart form-data (#5068)
1 parent 2a07f01 commit f6c5dda

File tree

2 files changed

+116
-3
lines changed

2 files changed

+116
-3
lines changed

lib/web/fetch/formdata-parser.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ function multipartFormDataParser (input, mimeType) {
204204
* Parses content-disposition attributes (e.g., name="value" or filename*=utf-8''encoded)
205205
* @param {Buffer} input
206206
* @param {{ position: number }} position
207-
* @returns {{ name: string, value: string }}
207+
* @returns {{ name: string, value: string, extended: boolean } | null}
208208
*/
209209
function parseContentDispositionAttribute (input, position) {
210210
// Skip leading semicolon and whitespace
@@ -304,7 +304,7 @@ function parseContentDispositionAttribute (input, position) {
304304
value = decoder.decode(tokenValue)
305305
}
306306

307-
return { name: attrNameStr, value }
307+
return { name: attrNameStr, value, extended: isExtended }
308308
}
309309

310310
/**
@@ -368,6 +368,9 @@ function parseMultipartFormDataHeaders (input, position) {
368368
switch (bufferToLowerCasedHeaderName(headerName)) {
369369
case 'content-disposition': {
370370
name = filename = null
371+
// Track whether filename was set from the extended (RFC 5987) form so
372+
// a subsequent legacy `filename` attribute does not override it.
373+
let filenameIsExtended = false
371374

372375
// Collect the disposition type (should be "form-data")
373376
const dispositionType = collectASequenceOfBytes(
@@ -395,7 +398,15 @@ function parseMultipartFormDataHeaders (input, position) {
395398
if (attribute.name === 'name') {
396399
name = attribute.value
397400
} else if (attribute.name === 'filename') {
398-
filename = attribute.value
401+
// Per RFC 5987 §4.1, when both legacy and extended forms of the
402+
// same parameter are present, the extended (filename*) form takes
403+
// precedence regardless of the order they appear in.
404+
if (attribute.extended) {
405+
filename = attribute.value
406+
filenameIsExtended = true
407+
} else if (!filenameIsExtended) {
408+
filename = attribute.value
409+
}
399410
}
400411
}
401412

test/busboy/issue-4661.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const { Request } = require('../..')
5+
6+
const boundary = '1df6c75e-c5a7-486c-af47-67b632b19522'
7+
const contentType = `multipart/form-data; boundary=${boundary}`
8+
9+
// https://github.com/nodejs/undici/issues/4661
10+
test('content-disposition allows both filename and filename* parameters', async (t) => {
11+
const request = new Request('http://localhost', {
12+
method: 'POST',
13+
headers: { 'Content-Type': contentType },
14+
body:
15+
`--${boundary}\r\n` +
16+
'Content-Disposition: form-data; name="Abc"; filename="E 1962 029 1342-0003.JPG"; filename*=utf-8\'\'E%201962%20029%201342-0003.JPG\r\n' +
17+
'\r\n' +
18+
'Hello\r\n' +
19+
`--${boundary}--`
20+
})
21+
22+
const fd = await request.formData()
23+
const file = fd.get('Abc')
24+
t.assert.ok(file instanceof File)
25+
t.assert.strictEqual(file.name, 'E 1962 029 1342-0003.JPG')
26+
t.assert.strictEqual(await file.text(), 'Hello')
27+
})
28+
29+
test('filename* (RFC 5987) without legacy filename is parsed correctly', async (t) => {
30+
const request = new Request('http://localhost', {
31+
method: 'POST',
32+
headers: { 'Content-Type': contentType },
33+
body:
34+
`--${boundary}\r\n` +
35+
'Content-Disposition: form-data; name="Abc"; filename*=utf-8\'\'only-extended.txt\r\n' +
36+
'\r\n' +
37+
'Hello\r\n' +
38+
`--${boundary}--`
39+
})
40+
41+
const fd = await request.formData()
42+
const file = fd.get('Abc')
43+
t.assert.ok(file instanceof File)
44+
t.assert.strictEqual(file.name, 'only-extended.txt')
45+
})
46+
47+
test('filename* takes precedence over filename when both are present', async (t) => {
48+
// Per RFC 5987 §4.1, when both extended and non-extended forms of the
49+
// same parameter appear, the extended form is preferred regardless of order.
50+
const request = new Request('http://localhost', {
51+
method: 'POST',
52+
headers: { 'Content-Type': contentType },
53+
body:
54+
`--${boundary}\r\n` +
55+
'Content-Disposition: form-data; name="Abc"; filename*=utf-8\'\'extended.txt; filename="legacy.txt"\r\n' +
56+
'\r\n' +
57+
'Hello\r\n' +
58+
`--${boundary}--`
59+
})
60+
61+
const fd = await request.formData()
62+
const file = fd.get('Abc')
63+
t.assert.ok(file instanceof File)
64+
t.assert.strictEqual(file.name, 'extended.txt')
65+
})
66+
67+
test('filename* takes precedence when filename appears first', async (t) => {
68+
const request = new Request('http://localhost', {
69+
method: 'POST',
70+
headers: { 'Content-Type': contentType },
71+
body:
72+
`--${boundary}\r\n` +
73+
'Content-Disposition: form-data; name="Abc"; filename="legacy.txt"; filename*=utf-8\'\'extended.txt\r\n' +
74+
'\r\n' +
75+
'Hello\r\n' +
76+
`--${boundary}--`
77+
})
78+
79+
const fd = await request.formData()
80+
const file = fd.get('Abc')
81+
t.assert.ok(file instanceof File)
82+
t.assert.strictEqual(file.name, 'extended.txt')
83+
})
84+
85+
test('filename* with percent-encoded UTF-8 bytes is decoded', async (t) => {
86+
// %E2%82%AC = U+20AC (€), %20 = space
87+
const request = new Request('http://localhost', {
88+
method: 'POST',
89+
headers: { 'Content-Type': contentType },
90+
body:
91+
`--${boundary}\r\n` +
92+
'Content-Disposition: form-data; name="Abc"; filename*=UTF-8\'\'%E2%82%AC%20rates.txt\r\n' +
93+
'\r\n' +
94+
'Hello\r\n' +
95+
`--${boundary}--`
96+
})
97+
98+
const fd = await request.formData()
99+
const file = fd.get('Abc')
100+
t.assert.ok(file instanceof File)
101+
t.assert.strictEqual(file.name, '\u20AC rates.txt')
102+
})

0 commit comments

Comments
 (0)