formidable 解析 multipart 文件名中的特殊字符:源码级拆解与浏览器兼容性测试剖析
后端【免费下载链接】formidableThe 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.项目地址https://gitcode.com/gh_mirrors/fo/formidable点击查看免费下载本指南以 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-346009bugs.opera.com).这段备注说明某些浏览器当年测试的 Opera 9.8 / 11.51在用户选择了一个文件名包含特殊字符的文件后拒绝将其提交到服务端而是向用户弹出文件找不到的警告。Opera 的行为差异被记录并反馈给了浏览器厂商。换句话说并非所有浏览器都会把怪名字原封不动地放进 multipart 请求——服务端解析器必须同时面对浏览器如何编码与解析器如何还原两层问题。该目录下随之存放了 8 个.http文件它们是对当年各主流浏览器真实发出的 multipart 请求的逐字节抓包文件对应浏览器请求头特征line-separator.httpMozilla/5.0通用boundary----SEPARATORosx-chrome-13.httpChrome 13.0.782.220 (Mac)WebKit boundaryosx-firefox-3.6.httpFirefox 3.6.22 (Mac)数字 boundaryosx-safari-5.httpSafari 5.0.5 (Mac)WebKit boundaryxp-chrome-12.httpChrome 12.0.742.122 (Win XP)WebKit boundaryxp-ie-7.httpIE 7.0 (Win XP)数字 boundaryxp-ie-8.httpIE 8.0 (Win XP)数字 boundaryxp-safari-5.httpSafari 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; nameupload; 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 实体#9731;osx-firefox-3.6.http 则展示了另一种风格Content-Disposition: form-data; nameupload; filename: \ ? % * | . #9731; ; # $ ^ ( ) - _ { } [ ] ~.txt Content-Type: text/plainFirefox 3.6 与 IE 7/8见 xp-ie-7.http、xp-ie-8.http双引号直接裸写进filename...内部即头中出现了一串 . #9731;特殊字符☃U2603 SNOWMAN以HTML 数字实体#9731;的形式出现在头部IE 的Content-Type使用application/octet-stream而 WebKit/Firefox 使用text/plain。这意味着当年 Firefox/IE 提交的头部在语法上是不规范的——filename值内部出现了未转义的。3. 行分隔符一个连文件名都换行的极端用例line-separator.http 用自定义的----SEPARATOR边界构造了一个更极端的情况Content-Disposition: form-data; nameupload; filename .txt其中filename与.txt之间包含的正是Unicode 行分隔符U2028。这类字符出现在头部会对基于\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.1quoted-string 形式filename...捕获组(.*?)非贪婪取值。关键在于末尾的($|;\s)——若短匹配之后既不是行尾也不是;分隔正则引擎会回溯扩展.*?使其跨过内部多余的引号直到整体满足条件。这正是 Firefox/IE 那种裸引号混在文件名中间的头部能够被完整还原的根本原因token 形式filenamexxx捕获组[^(){}[\],;:?\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 的#9731;编码#9731;→☃U2603。上游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与文件uploadconst 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 的期望文件名中#9731;已还原为☃、裸引号得以完整保留。值得注意的是 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/ChromeWindows、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.txtfoo.txt未转义引号my.txtmy.txt转义引号my%22.txtmy.txt坏引号 附加子头my.txt后跟; foobarmy.txt不吞掉子头分号my;.txtmy;.txtUTF-8 字符HTML 实体my#9731;.txtmy☃.txt其中坏引号 附加子头用例第 48-54 行验证的正是第 1 步正则的($|;\s)边界能力filenamemy.txt; foobar中my.txt之后紧跟;正则在这里正确收尾既还原了含引号的文件名又没有把; foobar误并入文件名。五、从特殊字符到落盘安全解析之后的命名防线文件名解析只是第一道工序。originalFilename还原之后formidable 在把它变成服务器磁盘路径前还有两道防线理解它们能帮你在自建上传服务时规避同类问题防线一目录穿越防护。若通过filename选项启用自定义重命名_joinDirectoryNamesrc/Formidable.js#L640-L655会把目标路径resolve后与uploadDir比对若结果不在上传目录之内则回退到defaultInvalidName防止../之类路径逃逸。防线二落盘文件名重写。默认情况下_getNewNamesrc/Formidable.js#L672-L684用createId()生成随机文件名仅当keepExtensions: true时才把_getExtension计算出的扩展名拼接回去_getExtensionsrc/Formidable.js#L611-L638会过滤掉扩展名中的非法字符。也就是说原始文件名中的特殊字符几乎不会直接出现在磁盘文件名中——originalFilename只作为元数据保留在文件对象上供业务层展示或审计。六、小结通过test/fixture/http/special-chars-in-filename/这组 2011 年遗留至今的抓包素材可以看到 formidable 在 multipart 文件名解析上的完整脉络浏览器端风格不一Chrome/Safari 用%22转义引号并原样发送其余符号Firefox/IE 裸发引号并用#9731;这类 HTML 实体表达非 ASCII 字符极端情况下还会出现行分隔符这样的换行文件名解析器统一收敛_getFileName用一条同时兼容 quoted-string 与 token 的正则 反斜杠截断 %22/HTML 实体还原把三种风格规范化为可信的originalFilename测试层层把关.http文件原样回放进 TCP 连接做集成验证单元测试 则逐条锁定边界行为落盘另设防线keepExtensions、filename回调、_joinDirectoryName共同保证怪异文件名不会演变成路径穿越或非法磁盘文件名。对于需要处理用户上传的开发者这套 fixture 与源码就是最好的特殊字符文件名处理参考实现——既可用于为自己的解析器构造测试样本也可作为加固上传服务文件名安全时的检查清单。赞分享后端【免费下载链接】formidableThe 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.项目地址https://gitcode.com/gh_mirrors/fo/formidable点击查看免费下载相关推荐Obsidian PDF插件中特殊字符文件名问题的技术解析Obsidian PDF插件中特殊字符文件名问题的技术解析 在Obsidian生态系统中PDF插件作为一款增强PDF处理能力的工具近期被发现存在一个知识管理创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考