- 后端
【免费下载链接】formidable
The most used, flexible, fast and streaming parser for multipart form data. Supports uploading to serverless environments, AWS S3, Azure, GCP or the filesystem. Used in production.
本指南以 formidable 仓库中的测试场景
test/fixture/http/special-chars-in-filename/为主体,深入讲解 multipart/form-data 上传时文件名(filename)中包含引号、反斜杠、百分号、Unicode 特殊字符(如行分隔符、雪人符号 ☃)时,formidable 在_getFileName中的规范化解析流程,并对照 Chrome、Firefox、IE、Safari 各版本的真实 HTTP 抓包请求与配套的单元/集成测试,帮助读者理解这类"怪异文件名"的底层处理机制、测试验证方法以及服务端落盘命名时的安全考量。
一、场景缘起:一个 2011 年的浏览器兼容性备注
在test/fixture/http/special-chars-in-filename/目录下,info.md 用短短三行记录了这个测试场景的来历:
Opera does not allow submitting this file, it shows a warning to the user that the file could not be found instead. Tested in 9.8, 11.51 on OSX. Reported to Opera on 08.09.2011 (tracking email DSK-346009@bugs.opera.com).
这段备注说明:某些浏览器(当年测试的 Opera 9.8 / 11.51)在用户选择了一个文件名包含特殊字符的文件后,拒绝将其提交到服务端,而是向用户弹出"文件找不到"的警告。Opera 的行为差异被记录并反馈给了浏览器厂商。换句话说,并非所有浏览器都会把"怪名字"原封不动地放进 multipart 请求——服务端解析器必须同时面对"浏览器如何编码"与"解析器如何还原"两层问题。
该目录下随之存放了 8 个.http文件,它们是对当年各主流浏览器真实发出的 multipart 请求的逐字节抓包:
| 文件 | 对应浏览器 | 请求头特征 |
|---|---|---|
| line-separator.http | Mozilla/5.0(通用) | boundary=----SEPARATOR |
| osx-chrome-13.http | Chrome 13.0.782.220 (Mac) | WebKit boundary |
| osx-firefox-3.6.http | Firefox 3.6.22 (Mac) | 数字 boundary |
| osx-safari-5.http | Safari 5.0.5 (Mac) | WebKit boundary |
| xp-chrome-12.http | Chrome 12.0.742.122 (Win XP) | WebKit boundary |
| xp-ie-7.http | IE 7.0 (Win XP) | 数字 boundary |
| xp-ie-8.http | IE 8.0 (Win XP) | 数字 boundary |
| xp-safari-5.http | Safari 5.0.3 (Win XP) | WebKit boundary |
每个请求都包含两个 part:一个普通字段title(值为Weird filename)和一个文件字段upload,文件内容均为I am a text file with a funky name!(text/plain)。差异全部集中在Content-Disposition的filename值上——这正是本测试场景要覆盖的"特殊字符"。
二、各浏览器对特殊字符文件名的三种编码策略
1. Chrome / Safari:%22转义引号,直接发送其它符号
以 osx-chrome-13.http 为例,其文件 part 的头部为:
Content-Disposition: form-data; name="upload"; filename=": \ ? % * | %22 < > . ? ; ' @ # $ ^ & ( ) - _ = + { } [ ] ` ~.txt" Content-Type: text/plain可以看到 WebKit 系浏览器(Chrome 13、Safari 5)的处理方式:
- 双引号
"被编码为%22放进头部,避免破坏filename="..."的引号字符串结构; - 其余字符(
:、\、?、%、*、|、<、>、;、'、@、#、$、^、&、(、)、-、_、=、+、{、}、[、]、反引号、~)直接原样输出。
xp-chrome-12.http与xp-safari-5.http与之一致,只是少了开头的: \两个字符(Windows 上的 Chrome/Safari 抓包)。
2. Firefox / IE:裸引号 + HTML 实体☃
osx-firefox-3.6.http 则展示了另一种风格:
Content-Disposition: form-data; name="upload"; filename=": \ ? % * | " < > . ☃ ; ' @ # $ ^ & ( ) - _ = + { } [ ] ` ~.txt" Content-Type: text/plainFirefox 3.6 与 IE 7/8(见 xp-ie-7.http、xp-ie-8.http):
- 双引号直接裸写进
filename="..."内部(即头中出现了一串" < > . ☃); - 特殊字符
☃(U+2603 SNOWMAN)以HTML 数字实体☃的形式出现在头部; - IE 的
Content-Type使用application/octet-stream,而 WebKit/Firefox 使用text/plain。
这意味着当年 Firefox/IE 提交的头部在语法上是不规范的——filename值内部出现了未转义的"。
3. 行分隔符:一个连文件名都"换行"的极端用例
line-separator.http 用自定义的----SEPARATOR边界构造了一个更极端的情况:
Content-Disposition: form-data; name="upload"; filename=" .txt"其中filename="与.txt"之间包含的正是Unicode 行分隔符(U+2028)。这类字符出现在头部,会对基于\r\n分行的头部解析造成潜在干扰,是解析器需要格外小心的边界输入。
三、解析核心:_getFileName的逐行拆解
所有上述怪异文件名,最终都由 src/Formidable.js 中的_getFileName方法统一消化。该方法是理解本场景全部 fixture 的钥匙,其实现位于 src/Formidable.js#L591-L606:
_getFileName(headerValue) { // matches either a quoted-string or a token (RFC 2616 section 19.5.1) const m = headerValue.match( /\bfilename=("(.*?)"|([^()<>{}[\]@,;:"?=\s/\t]+))($|;\s)/i ); if (!m) return null; const match = m[2] || m[3] || ""; let originalFilename = match.substr(match.lastIndexOf("\\") + 1); originalFilename = originalFilename.replace(/%22/g, '"'); originalFilename = originalFilename.replace(/&#([\d]{4});/g, (_, code) => String.fromCharCode(code) ); return originalFilename; }整个流程分四步,与第二部分的三种浏览器编码策略一一对应:
第 1 步:用正则识别filename=值(兼容两种合法形态)。
/\bfilename=("(.*?)"|([^()<>{}[\]@,;:"?=\s/\t]+))($|;\s)/i该正则同时匹配两种形式(源码注释注明依据为 RFC 2616 section 19.5.1):
- quoted-string 形式
filename="...":捕获组(.*?)非贪婪取值。关键在于末尾的($|;\s)——若短匹配之后既不是行尾也不是;分隔,正则引擎会回溯扩展.*?,使其跨过内部多余的引号,直到整体满足条件。这正是 Firefox/IE 那种"裸引号混在文件名中间"的头部能够被完整还原的根本原因; - token 形式
filename=xxx:捕获组[^()<>{}[\]@,;:"?=\s/\t]+匹配不含括号、逗号、分号、引号、空白等特殊字符的裸 token。
第 2 步:丢弃最后一个反斜杠之前的所有内容。
match.substr(match.lastIndexOf("\\") + 1)这一行解释了为什么 osx-chrome-13.http 中实际发送的: \ ? % * | ...在解析后得到的原始文件名是? % * | ...——以: \开头的部分被当作路径前缀截断了。这是一种防御性的规范化:反斜杠在 Windows 文件名中既是合法字符又是路径分隔符,解析器宁可丢弃它前面的内容,也不愿把路径语义带进文件名。
第 3 步:还原%22为双引号。
originalFilename.replace(/%22/g, '"')对应 Chrome/Safari 的转义策略。_getFileName("...filename=\"my%22.txt\"")返回my".txt。
第 4 步:还原 HTML 数字实体。
originalFilename.replace(/&#([\d]{4});/g, (_, code) => String.fromCharCode(code))对应 Firefox/IE 的☃编码:☃→☃(U+2603)。
上游:content-disposition头在 multipart 插件中的解析
_getFileName的调用入口在 multipart 解析插件 src/plugins/multipart.js#L72-L91。每当解析状态机进入headerEnd(一个 part 的头部结束),插件会先把头部字段名转小写存入part.headers,然后:
const m = headerValue.match( /\bname=("([^"]*)"|([^\(\)<>@,;:\\"\/\[\]\?=\{\}\s\t/]+))/i ); if (headerField === "content-disposition") { if (m) { part.name = m[2] || m[3] || ""; } part.originalFilename = this._getFileName(headerValue); }即:name字段和filename字段在同一个Content-Disposition头中被分别提取——part.name(字段名,如upload)交给name=正则,part.originalFilename(原始文件名)交给_getFileName。originalFilename会随后传递给PersistentFile/VolatileFile,成为上传文件对象上的originalFilename属性(见 src/Formidable.js#L560-L588)。
四、验证体系:期望文件、集成测试与单元测试
1. 期望文件:special-chars-in-filename.js
解析结果是否正确,由 test/fixture/js/special-chars-in-filename.js 定义期望。它用expect(originalFilename, fixtureName)为每个.http文件生成两个期望 part:字段title与文件upload:
const osx_chrome_13_http = expect( " ? % * | \" < > . ? ; ' @ # $ ^ & ( ) - _ = + { } [ ] ` ~.txt", "osx-chrome-13" ); const osx_firefox_3_6_http = expect( " ? % * | \" < > . ☃ ; ' @ # $ ^ & ( ) - _ = + { } [ ] ` ~.txt", "osx-firefox-3.6" ); const lineSeparator = expect(null, "line-separator");可以对照验证前面分析的链路:Chrome 的期望文件名中已经没有: \前缀(反斜杠截断生效)、%22已还原为";Firefox 的期望文件名中☃已还原为☃、裸引号得以完整保留。
值得注意的是 export 清单中注释掉的部分:
// webkit as osx_safari_5_http, // webkit as xp_chrome_12_http, // webkit as xp_safari_5_http, // xp_ie_7_http, // xp_ie_8_http, // lineSeparator,从源码结构看,Safari/Chrome(Windows)、IE 及 line-separator 场景当前未纳入集成测试断言(IE 的裸引号头部、line-separator 的null期望在严格断言下行为不稳定,可能是它们被搁置的原因)。这说明该目录是"完整抓包存档 + 精挑细选激活用例"的组合——.http文件保留了全部原始证据,而 JS 期望只激活行为可控的子集。
2. 集成测试:真实 TCP 回放
test/integration/fixtures.test.js 是驱动这套 fixture 的集成测试,工作流非常直观:
- 用
createServer()在固定端口13534起一个 HTTP 服务,请求到达时用formidable({ uploadDir, hashAlgorithm: "sha1" })解析; - 用
createReadStream读取每个.httpfixture 文件,直接pipe进createConnection(PORT)建立的 socket——把抓包文件原样重放成真实网络请求; - 收集
fileBegin/field事件产生的 part,逐个断言type、name,文件 part 额外断言originalFilename,以及设置了hashAlgorithm: "sha1"时的file.hash。
测试名称统一形如special-chars-in-filename/osx-chrome-13.http,对应第 42 行fixtureGroup/fixture.http的拼接规则。因此,只要修改了_getFileName的实现,重跑该测试即可发现特殊文件名解析的回归。
3. 单元测试:_getFileName的 6 种边界输入
test/unit/formidable.test.js#L22-L68 针对_getFileName提供了更细粒度的单元测试(对IncomingForm、Formidable、formidable三个入口各跑一遍):
| 测试用例 | 输入(filename="..."内) | 期望输出 |
|---|---|---|
| 普通字符 | foo.txt | foo.txt |
| 未转义引号 | my".txt | my".txt |
| 转义引号 | my%22.txt | my".txt |
| 坏引号 + 附加子头 | my".txt后跟; foo="bar" | my".txt(不吞掉子头) |
| 分号 | my;.txt | my;.txt |
| UTF-8 字符(HTML 实体) | my☃.txt | my☃.txt |
其中"坏引号 + 附加子头"用例(第 48-54 行)验证的正是第 1 步正则的($|;\s)边界能力:filename="my".txt"; foo="bar"中,my".txt之后紧跟;,正则在这里正确收尾,既还原了含引号的文件名,又没有把; foo="bar"误并入文件名。
五、从特殊字符到落盘安全:解析之后的命名防线
文件名解析只是第一道工序。originalFilename还原之后,formidable 在把它变成服务器磁盘路径前还有两道防线,理解它们能帮你在自建上传服务时规避同类问题:
防线一:目录穿越防护。若通过filename选项启用自定义重命名,_joinDirectoryName(src/Formidable.js#L640-L655)会把目标路径resolve后与uploadDir比对,若结果不在上传目录之内,则回退到defaultInvalidName,防止../之类路径逃逸。
防线二:落盘文件名重写。默认情况下_getNewName(src/Formidable.js#L672-L684)用createId()生成随机文件名,仅当keepExtensions: true时才把_getExtension计算出的扩展名拼接回去;_getExtension(src/Formidable.js#L611-L638)会过滤掉扩展名中的非法字符。也就是说,原始文件名中的特殊字符几乎不会直接出现在磁盘文件名中——originalFilename只作为元数据保留在文件对象上,供业务层展示或审计。
六、小结
通过test/fixture/http/special-chars-in-filename/这组 2011 年遗留至今的抓包素材,可以看到 formidable 在 multipart 文件名解析上的完整脉络:
- 浏览器端风格不一:Chrome/Safari 用
%22转义引号并原样发送其余符号,Firefox/IE 裸发引号并用☃这类 HTML 实体表达非 ASCII 字符,极端情况下还会出现行分隔符这样的"换行文件名"; - 解析器统一收敛:
_getFileName用一条同时兼容 quoted-string 与 token 的正则 + 反斜杠截断 +%22/HTML 实体还原,把三种风格规范化为可信的originalFilename; - 测试层层把关:
.http文件原样回放进 TCP 连接做集成验证,单元测试 则逐条锁定边界行为; - 落盘另设防线:
keepExtensions、filename回调、_joinDirectoryName共同保证怪异文件名不会演变成路径穿越或非法磁盘文件名。
对于需要处理用户上传的开发者,这套 fixture 与源码就是最好的"特殊字符文件名处理参考实现"——既可用于为自己的解析器构造测试样本,也可作为加固上传服务文件名安全时的检查清单。
- 后端
【免费下载链接】formidable
The most used, flexible, fast and streaming parser for multipart form data. Supports uploading to serverless environments, AWS S3, Azure, GCP or the filesystem. Used in production.
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考