From 5a86e03b852e69ae85dba8bbc2731f1d2f1dc8d4 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Fri, 24 Apr 2026 10:54:30 -0500 Subject: [PATCH] fix: add RFC 5987 filename* encoding for non-attr-char characters When filenames contain characters outside the RFC 5987 attr-char set (such as parentheses, spaces, or non-ASCII characters), servers like .NET 8 reject the Content-Disposition header. Add a properly encoded filename* parameter alongside the existing filename parameter per RFC 6266 / RFC 5987. The simple filename is preserved for backward compatibility, while filename* provides the standards-compliant percent-encoded form. Characters in the attr-char set (ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~") are left unencoded; everything else is percent-encoded from its UTF-8 bytes. Fixes #572 --- lib/form_data.js | 31 +++- .../test-rfc5987-filename-encoding.js | 134 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/integration/test-rfc5987-filename-encoding.js diff --git a/lib/form_data.js b/lib/form_data.js index 63a0f01..eb48723 100644 --- a/lib/form_data.js +++ b/lib/form_data.js @@ -46,6 +46,28 @@ util.inherits(FormData, CombinedStream); FormData.LINE_BREAK = '\r\n'; FormData.DEFAULT_CONTENT_TYPE = 'application/octet-stream'; +// RFC 5987 Section 3.2 attr-char: ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" +// Matches any character NOT in the attr-char set +FormData.NON_ATTR_CHAR_RE = /[^A-Za-z0-9!#$&+\-.^_`|~]/; + +FormData._rfc5987Encode = function (str) { + var bytes = Buffer.from(str, 'utf8'); + var result = ''; + + for (var i = 0; i < bytes.length; i++) { + var c = String.fromCharCode(bytes[i]); + + if ((/[A-Za-z0-9!#$&+\-.^_`|~]/).test(c)) { + result += c; + } else { + var hex = bytes[i].toString(16).toUpperCase(); + result += '%' + (hex.length < 2 ? '0' + hex : hex); // eslint-disable-line no-magic-numbers + } + } + + return result; +}; + FormData.prototype.append = function (field, value, options) { options = options || {}; // eslint-disable-line no-param-reassign @@ -234,7 +256,14 @@ FormData.prototype._getContentDisposition = function (value, options) { // eslin } if (filename) { - return 'filename="' + filename + '"'; + var result = 'filename="' + filename + '"'; + + // RFC 5987 / RFC 6266: add filename* with percent-encoding for non-attr-char characters + if (FormData.NON_ATTR_CHAR_RE.test(filename)) { + return [result, "filename*=UTF-8''" + FormData._rfc5987Encode(filename)]; + } + + return result; } }; diff --git a/test/integration/test-rfc5987-filename-encoding.js b/test/integration/test-rfc5987-filename-encoding.js new file mode 100644 index 0000000..0b50c06 --- /dev/null +++ b/test/integration/test-rfc5987-filename-encoding.js @@ -0,0 +1,134 @@ +'use strict'; + +/* + * test RFC 5987 filename* encoding in Content-Disposition: + * re: https://github.com/form-data/form-data/issues/572 + * + * Parentheses are RFC 2616 separators, not tokens. Per RFC 5987 Section 3.2, + * they are not in the attr-char set and must be percent-encoded in filename*. + */ + +var common = require('../common'); +var assert = common.assert; + +var FormData = require(common.dir.lib + '/form_data'); + +(function testParenthesesInFilename() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'file(1).txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename="file(1).txt"') !== -1, + 'Should have simple filename parameter' + ); + assert.ok( + header.indexOf("filename*=UTF-8''file%281%29.txt") !== -1, + 'Should have RFC 5987 encoded filename* with parentheses percent-encoded' + ); +}()); + +(function testSpaceInFilename() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'my file.txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename="my file.txt"') !== -1, + 'Should have simple filename parameter with space' + ); + assert.ok( + header.indexOf("filename*=UTF-8''my%20file.txt") !== -1, + 'Should have RFC 5987 encoded filename* with space percent-encoded' + ); +}()); + +(function testNonAsciiFilename() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'café.txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename="café.txt"') !== -1, + 'Should have simple filename parameter with non-ASCII' + ); + assert.ok( + header.indexOf("filename*=UTF-8''caf%C3%A9.txt") !== -1, + 'Should have RFC 5987 encoded filename* with UTF-8 percent-encoding' + ); +}()); + +(function testSimpleAsciiFilename() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'test.txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename="test.txt"') !== -1, + 'Should have simple filename parameter' + ); + assert.ok( + header.indexOf('filename*=') === -1, + 'Should NOT have filename* when filename is pure attr-char' + ); +}()); + +(function testMultipleSpecialChars() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'report [final] (v2).txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf("filename*=UTF-8''") !== -1, + 'Should have filename* for brackets, spaces, and parentheses' + ); + assert.ok( + header.indexOf('%28') !== -1, + 'Parenthesis ( should be percent-encoded' + ); + assert.ok( + header.indexOf('%29') !== -1, + 'Parenthesis ) should be percent-encoded' + ); + assert.ok( + header.indexOf('%5B') !== -1, + 'Bracket [ should be percent-encoded' + ); + assert.ok( + header.indexOf('%5D') !== -1, + 'Bracket ] should be percent-encoded' + ); +}()); + +(function testFilepathWithSpecialChars() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filepath: 'uploads/file (copy).txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename="uploads/file (copy).txt"') !== -1, + 'Should have simple filename parameter with filepath' + ); + assert.ok( + header.indexOf("filename*=UTF-8''uploads%2Ffile%20%28copy%29.txt") !== -1, + 'Should have RFC 5987 encoded filename* with filepath special chars encoded' + ); +}()); + +(function testAttrCharNotEncoded() { + var form = new FormData(); + form.append('file', Buffer.from('content'), { filename: 'a!#$&+-.^_`|~z.txt' }); + + var header = form.getBuffer().toString(); + + assert.ok( + header.indexOf('filename*=') === -1, + 'Should NOT have filename* when filename only contains attr-char characters' + ); +}());