前阵子接了个改造任务,一个跑了好多年的JSP老项目要往信创环境迁移。业务方提的需求倒是不复杂:在网页上点一个按钮,把本地文件夹一次性传上去,别让我一个个选文件。听起来就是典型的“文件夹上传”,但真正动手之后我发现,这个功能在信创环境下的坑,比想象中要多不少。如果你也在做类似的Java Web迁移,或者正在JSP项目里处理目录批量上传,这篇文章应该能帮你少走点弯路。
1. 为什么文件夹上传在信创环境里会变成一个专门的课题
1.1 老式JSP开发者的上传习惯,在目录场景下失灵了
早年的JSP项目做上传,最常用的套路是 <input type="file"> 加 commons-fileupload,前端再用隐藏iframe模拟异步提交,后端用 DiskFileItemFactory 解析。单个文件、多个文件都能应付,唯独“文件夹”这种对象不是 file input 的标准能力,压根无法直接选中。
后来HTML5普及,webkitdirectory 属性可以让 input 选择整个目录,但很多老项目的代码库还停留在十年前,没人去动这部分。真正到了信创环境改造时,业务方拿着新需求上门,才发现旧代码连“目录选择”这层基础能力都没有。
还有一个更麻烦的旧方案依赖:早些年有些系统用Flash组件实现多选和文件夹选择,或者用ActiveX插件调本地接口。这些技术在信创环境下基本不可用,浏览器默认禁用、安全策略拦截、操作系统不兼容,现实逼着你只能往标准HTML5能力上靠。
1.2 信创环境不只是换了个操作系统,而是换了整条技术链
很多团队一听到“信创环境”就以为只是把服务器从Windows换成Linux,把浏览器从IE换成国产浏览器,其实远不止这些。文件夹上传这条链路,涉及的是前端浏览器、后端中间件、文件系统三个层面。
传统环境里,开发机用Windows,浏览器用Chrome,中间件用Tomcat,问题排查路径比较熟悉。信创环境通常是这样一套组合:
| 环节 | 传统环境 | 信创环境常见组合 |
|---|---|---|
| 操作系统 | Windows | 麒麟、统信UOS |
| 客户端浏览器 | Chrome、Edge | 奇安信、360安全浏览器、红莲花、系统自带浏览器 |
| 中间件 | Tomcat | 东方通TongWeb、金蝶天燕 |
| 后端技术 | Servlet、Spring MVC | 不变,还是Java |
| 页面渲染 | JSP | 不变,还是JSP |
这中间的差异不是语法层面的,而是“浏览器对标准的支持程度”和“中间件对Servlet规范的完整度”。目录上传依赖的 webkitdirectory 属性,恰好处于浏览器能力边界;后端解析multipart又依赖Servlet 3.0+的 Part 接口,恰好也是老中间件容易出问题的地方。
1.3 所以目录上传在信创环境下的真正难点是什么
一句话总结:前端不能依赖IE时代的插件,后端不能依赖裸写 getParameter 惯性思维,中间件配置也不能照搬Tomcat经验。文件夹上传需要自己把目录遍历、相对路径传递、服务端落盘三层打通,任何一层漏了,功能都是残废的。
我当时给项目定的目标是:浏览器选择目录后,能把整个目录层级原样保存到服务器,支持中文文件名,上传过程有进度反馈,用户能直观看到传了哪些文件。下面的方案就是围绕这套目标展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 文件夹上传方案选型:哪些能跑,哪些是坑
2.1 方案一:H5原生目录上传,最贴合场景
现代浏览器支持给 input[type=file] 加 webkitdirectory 属性,用户选择文件夹后,input.files 里不是文件夹对象,而是文件夹下所有文件的平铺列表。每个文件对象带一个 webkitRelativePath 属性,比如选择 项目资料 文件夹,里面有 docs/readme.txt,文件的 webkitRelativePath 就是 项目资料/docs/readme.txt。
这个方案不需要任何插件,后端只需要正常接收multipart文件,再把相对路径存下来。优点是依赖少、可控性强,理论上只要是Chromium内核浏览器就能跑。缺点是空文件夹无法上传,因为浏览器只返回文件对象,不返回目录条目;另外如果目录文件数量极大,前端遍历也会有性能压力。
2.2 方案二:成熟上传组件二次封装,容易栽在版本兼容上
网上有不少上传组件,比如Web Uploader、Plupload,印象里它们都号称支持文件夹上传。实际上这类组件走的是“大而全”路子:Flash、HTML5、Silverlight全都要兼容,信创浏览器默认禁用Flash后,组件会自动降级到HTML5模式,这时它的行为本质上还是 webkitdirectory。
问题出在组件本身:老版本组件年久失修,内置的 webkitdirectory 探测逻辑可能失效;有些组件强依赖jQuery版本,和JSP老项目里的旧jQuery冲突;还有的组件上传时丢掉了 webkitRelativePath,只发文件名,服务器端拿到的就是一堆平铺文件。我见过不止一次“选定文件夹传上去全散成一堆”的情况,最后追到组件源码才发现它压根没传路径。
所以如果你不是有大量历史代码非要复用组件,我建议直接放弃组件,原生写代码反而更干净。
2.3 方案三:压缩包上传、服务端解压,作为兜底
如果浏览器版本太老,连 webkitdirectory 都没有,那就只能退一步:让用户先把本地文件夹打包成ZIP,再用普通文件上传把ZIP传上来,服务端解压后还原目录结构。这个方案绝对兼容所有支持普通文件上传的浏览器。
代价是交互变重,普通用户不一定知道怎么压缩,也不一定愿意用第三方工具。而且大压缩包解压时极易把服务器内存打满,还要应对Zip Slip这类解压路径穿越漏洞。我在扩展部分会专门展开,这里不赘述。
2.4 我的选型结论
我最终选的是“H5原生目录上传 + 后端Servlet自收自存”,不引第三方组件,不上前端压缩。理由有二:
第一,信创环境里浏览器内核其实高度统一。市面上主流的信创浏览器大多基于Chromium内核,版本也普遍较新,对 webkitdirectory 的支持基本没问题,没必要为了少数老浏览器牺牲主路径的稳定性。
第二,JSP老项目最怕引入复杂依赖。第三方组件通常自带样式、自带回调、自带资源文件,部署时多一堆静态资源,出问题反而难排查。原生写法就是 input + FormData + XMLHttpRequest,逻辑全在自己手里,中间件怎么换都能定位。
下面是方案对比,方便你做决策:
| 对比维度 | H5原生目录上传 | 第三方组件 | ZIP上传+服务端解压 |
|---|---|---|---|
| 目录结构保留 | 可以 | 看组件是否传relativePath | 可以 |
| 浏览器要求 | Chromium内核 | 老组件可能失效 | 普通文件上传即可 |
| 服务端改造量 | 低 | 低到中 | 中(需要解压) |
| 前端开发量 | 中 | 低 | 低 |
| 大目录性能 | 中(逐文件或小并发) | 看实现 | 高(但解压耗资源) |
| 踩坑风险 | 低 | 中 | 中 |
| 推荐程度 | 推荐主力 | 不推荐 | 兜底 |
3. 前端核心实现:把文件夹变成可上传的文件列表
3.1 目录选择输入框的正确写法与能力探测
HTML代码很简单:
html复制<input type="file" id="dirPicker" webkitdirectory directory multiple />
注意 directory 是标准属性名,webkitdirectory 是Chrome系浏览器早期实现时用的带前缀版本,Firefox对 webkitdirectory 也做了兼容。为了稳妥,两个都写上。真正运行环境如果基于Chromium内核,两种写法都能触发“选择文件夹”而不是“选择文件”。
能力探测要放在绑定事件之前:
javascript复制const picker = document.getElementById('dirPicker');
if (!('webkitdirectory' in picker) && !('directory' in picker)) {
alert('当前浏览器不支持文件夹选择,请使用较新的信创浏览器,或改用ZIP上传。');
}
这里有个细节很多人会踩:如果给 input 设置了 display:none,再用自定义按钮触发 picker.click(),部分信创浏览器会不弹文件选择框。我的做法是把input定位到屏幕外,而不是隐藏:
css复制#dirPicker {
position: absolute;
left: -9999px;
top: 0;
opacity: 0;
}
这样点击自定义按钮触发时,兼容性是最好的。
3.2 遍历文件列表并还原目录结构
用户选完目录之后,picker.files 返回的是一个平铺的 FileList,里面包含目录下所有文件,不像操作系统API那样给你提供目录树。想要还原目录层级,必须依赖 file.webkitRelativePath。
示例代码:
javascript复制function listFiles(input) {
const result = [];
for (const file of input.files) {
result.push({
file: file,
relPath: file.webkitRelativePath || file.name
});
}
return result;
}
假设用户选择的目录叫 项目资料,里面的结构是:
code复制项目资料/
├── docs/
│ └── 说明文档.txt
└── images/
└── logo.png
那么 listFiles 返回的两个文件的 relPath 分别是:
code复制项目资料/docs/说明文档.txt
项目资料/images/logo.png
后端拿到这个相对路径,直接作为落盘路径的一部分,目录结构就能原样保存下来。需要特别提醒的是,空文件夹在HTML5目录上传方案里是传不了的,因为浏览器压根不会把空目录暴露给前端脚本。如果业务强依赖空占位目录,只能走ZIP方案,或者在后端约定好“遇到某个特殊标记就创建目录”。
3.3 用FormData逐文件上传,并控制并发数量
文件夹里的文件数量可能很大,比如几百个甚至上万个。如果一次性把所有文件 append 进同一个 FormData,浏览器会先把所有文件读进内存,几GB数据直接卡死。我实测传一个包含两万多个小文件的目录时,页面直接无响应,最后只能杀进程。
正确的做法是:每个文件一个独立请求,同时控制并发数。这样进度好算,单个文件失败不影响整体,内存占用也可控。
我给出一个可运行的示例:
html复制<script>
const picker = document.getElementById('dirPicker');
const btn = document.getElementById('uploadBtn');
const statusBox = document.getElementById('status');
function listFiles(input) {
const result = [];
for (const file of input.files) {
result.push({
file: file,
relPath: file.webkitRelativePath || file.name
});
}
return result;
}
function uploadOne(item) {
return new Promise((resolve, reject) => {
const fd = new FormData();
fd.append('file', item.file, item.file.name);
fd.append('relativePath', item.relPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '${pageContext.request.contextPath}/upload/dir', true);
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
statusBox.textContent = '上传中:' + item.relPath + ' ' + pct + '%';
}
};
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error('HTTP ' + xhr.status));
}
};
xhr.onerror = function () {
reject(new Error('网络异常'));
};
xhr.send(fd);
});
}
async function uploadAll(items) {
const concurrency = 3; // 并发数,信创浏览器实测3~5比较稳
let idx = 0;
let success = 0;
const total = items.length;
async function worker() {
while (idx < total) {
const current = idx++;
try {
await uploadOne(items[current]);
success++;
} catch (err) {
console.error('上传失败:', items[current].relPath, err);
}
}
}
const workers = [];
for (let i = 0; i < Math.min(concurrency, total); i++) {
workers.push(worker());
}
await Promise.all(workers);
statusBox.textContent = '上传完成,成功 ' + success + ' 个,失败 ' + (total - success) + ' 个。';
}
btn.addEventListener('click', function () {
const items = listFiles(picker);
if (!items.length) {
statusBox.textContent = '请先选择目录。';
return;
}
btn.disabled = true;
uploadAll(items);
});
</script>
并发数为什么设成3?不是空口说的。信创环境下,很多浏览器对同一域名的并发连接数有限制,超过阈值会排队等待;设太高反而容易触发服务器连接池瓶颈。3到5是实测比较稳的值,你的服务器性能好可以往上加到8,但没必要。
3.4 前端边界处理经验:中文名、大文件、特殊符号
文件夹上传里最容易翻车的不是业务逻辑,而是文件名本身。中文文件名,带空格、井号、括号的文件名,在FormData里原样传,后端接收后再做编码处理,不要在JS里手动 encodeURIComponent,否则后端拿到的就是你转码后的字符,还原时还要多一层处理。
另外,超大文件放在文件夹里也是隐患。单文件超过2GB时,浏览器和中间件都可能出问题,一是内存占用,二是请求体大小限制。你可以在遍历文件列表时按大小过滤,超过设定值的文件直接跳过,然后在页面上提示用户单独处理。
遍历阶段还有一个隐性性能问题:文件夹里有几万个文件时,主线程遍历会卡住页面。如果目录特别大,可以把 listFiles 放在 requestIdleCallback 里分批处理,或者至少给用户一个“正在扫描文件”的提示,别让页面看着像死了。
4. JSP后端接收与落盘:Servlet处理multipart数据的正确姿势
4.1 为什么不能用传统的getParameter拿文件
很多老代码处理上传用的是 commons-fileupload,然后在 parseRequest 之后通过 item.getFieldName 区分表单字段和文件字段。这个思路没错,但现在完全可以更省事。
Servlet 3.0以后,容器原生支持multipart/form-data解析。只要在Servlet上声明 @MultipartConfig,就能直接通过 req.getPart("file") 拿到文件部分,通过 req.getParameter("relativePath") 拿到普通表单字段。不需要额外引入第三方库,代码量也更少。
信创环境下,东方通TongWeb 7以上、金蝶天燕等主流中间件,都支持Servlet 3.0及以上规范。但要注意,如果项目部署在比较老的中间件版本上,还是得确认一下规范支持情况,不行就用 commons-fileupload 兜底。
4.2 接收文件并保存目录结构:完整Servlet示例
后端核心代码我放在这里:
java复制package com.example.upload;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
@WebServlet("/upload/dir")
@MultipartConfig(
maxFileSize = 1024L * 1024 * 1024,
maxRequestSize = 8L * 1024 * 1024 * 1024,
fileSizeThreshold = 1024 * 1024
)
public class DirUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
String rootDir = getServletContext().getInitParameter("uploadRoot");
if (rootDir == null || rootDir.trim().isEmpty()) {
rootDir = System.getProperty("java.io.tmpdir") + "/webupload";
}
Path root = Paths.get(rootDir).toAbsolutePath().normalize();
Files.createDirectories(root);
Part filePart = req.getPart("file");
String relativePath = req.getParameter("relativePath");
if (filePart == null || relativePath == null || relativePath.trim().isEmpty()) {
resp.setStatus(400);
resp.getWriter().write("missing file or relativePath");
return;
}
String cleanPath = relativePath.replace('\\', '/');
while (cleanPath.startsWith("/")) {
cleanPath = cleanPath.substring(1);
}
Path dest = root.resolve(cleanPath).normalize();
if (!dest.startsWith(root)) {
resp.setStatus(400);
resp.getWriter().write("invalid path: " + relativePath);
return;
}
Files.createDirectories(dest.getParent());
try (InputStream in = filePart.getInputStream()) {
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
}
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"success\":true,\"path\":\"" + dest + "\"}");
}
}
几个关键点解释一下:
第一,为什么用外部配置的 uploadRoot,而不是 getServletContext().getRealPath("/")?因为JSP项目打成war包部署后,getRealPath 指到中间件解压目录,这个目录有可能是只读的,重启后还可能被清掉。我在实际项目中见过不止一次把上传文件写到web目录下,系统重启全丢的情况。存放目录必须做成外部配置。
第二,dest.startsWith(root) 这个校验是必须的。如果前端传进来的 relativePath 是 ../../etc/passwd 这种恶意路径,不做校验就能直接写到服务器任意目录,这是典型的路径穿越攻击。即使是内部系统,也必须在服务端无条件做。
第三,用 Files.copy 而不是 filePart.write(dest.toString())。Part.write 在不同中间件里的行为有差异,有的是把临时文件rename过去,有的会当成相对路径处理,容易踩坑。用InputStream拷贝的行为最一致,可控性最高。
4.3 中文文件名、重复文件、并发请求的处理
如果同一目录下有两个同名文件,默认 REPLACE_EXISTING 会直接覆盖。业务上是否允许,看你自己的需求。我建议至少先检查文件是否存在,存在的话在文件名后加时间戳或序号,不要静默覆盖,否则误操作追都追不回来。
中文文件名在 Files.copy 这层没有编码问题,Java字符串本来就是Unicode,文件系统只要支持UTF-8就能正常保存。真正容易出问题的是中间件解析请求时没有设置 req.setCharacterEncoding("UTF-8")。如果不做这一步,getParameter("relativePath") 拿到的中文路径可能变成乱码,目录自然会乱。所以这个操作必须在所有请求处理的最前面。
前端用了并发方式,后端也要关注连接池和线程池。大文件上传会长时间占用连接,如果并发数高,中间件默认线程池容易被占满。适当调大最大线程数、调长连接超时,上传体验会好很多。
4.4 在东方通TongWeb等中间件上的部署提醒
TongWeb的管理控制台里,需要检查几个配置项:请求体大小限制、线程池大小、连接超时。如果默认限制太严,上传大文件会直接报413或连接断开。我在改造时遇到过一次:Tomcat下跑得好好的,换到TongWeb后超过50MB就失败,查了半天才发现是中间件默认请求体大小限制比Tomcat小得多。
还有一点,如果你用了Nginx做反向代理,Nginx默认 client_max_body_size 才1MB,这个不调,请求根本到不了中间件。建议在upload路径下单独放开限制:
nginx复制location /upload/ {
client_max_body_size 0;
proxy_request_buffering off;
proxy_pass http://backend;
}
client_max_body_size 0 表示不限大小,proxy_request_buffering off 是大体量上传时推荐的,否则Nginx会先把请求体缓冲到磁盘,拖慢上传速度。
5. 实测记录与兼容性调优
5.1 不同信创浏览器下的目录上传能力
我在测试环境实际跑过几款浏览器,结论是:只要基于Chromium内核,目录上传基本都能用,问题差异主要在界面交互和上传进度上。
| 浏览器 | 内核 | 目录选择能力 | webkitRelativePath | 实测备注 |
|---|---|---|---|---|
| 奇安信浏览器 | Chromium | 支持 | 支持 | 选择目录、多选文件都正常 |
| 360安全浏览器 | Chromium | 支持 | 支持 | 兼容模式下降级到老内核时不支持 |
| 红莲花浏览器 | Firefox/Chromium | 支持(Firefox下用directory) | 支持 | Firefox里能选目录,属性相对路径兼容 |
| 统信UOS自带浏览器 | Chromium | 支持 | 支持 | 与Chrome行为基本一致 |
| 老版本国产浏览器 | WebKit旧版 | 不支持 | 不支持 | 只能走ZIP兜底 |
这里要特别提醒:很多信创浏览器有“兼容模式”,会模拟老IE内核,这个模式下 webkitdirectory 一定不可用。需要确保上传页面运行在极速模式或者Chromium模式下。
5.2 信创环境里最容易翻车的三个细节
第一个细节是文件描述符限制。Linux系统默认每个进程能打开的文件数量有限,如果文件夹里有成千上万个小文件,前端并发3个请求,每个请求服务端打开一个输入流、一个输出流,再算上日志、数据库连接,文件描述符很容易被打满,表现就是上传到一半突然大量失败,系统日志里报 Too many open files。遇到这种情况,除了调大 ulimit,也要考虑减少并发数。
第二个细节是上传目录所在文件系统的编码和挂载选项。正常情况下Linux都使用UTF-8,如果挂载外部存储或者Windows共享目录,中文文件名有可能乱码。我建议服务器上传目录单独规划,不要放在系统盘根目录,也不要放在共享挂载目录上。
第三个细节是上传后的磁盘权限。Web中间件运行用户如果对上传目录没有写权限,上传会直接报 Permission denied。这个看起来很简单,但在信创环境下经常被忽略,因为中间件安装方式不一样,默认运行用户五花八门。
5.3 调试文件夹上传的正确思路
调试时,我习惯先打开浏览器F12的Network面板,选完目录点击上传后,逐个看请求的Payload部分。正常情况下,每个请求应该是一个 file 字段加一个 relativePath 字段。如果 relativePath 没传或者传的是空字符串,问题一定在前端;如果传了但服务器目录还是平铺的,问题一定在后端。
后端排查看日志,重点看 relativePath 解析出来的中文是否正常。我建议在Servlet里把接收到的相对路径打一条日志,哪怕最后不保留,排查阶段也方便确认是哪个环节变了形。
另外,如果上传过程中请求报413,先看Nginx的 client_max_body_size,再看中间件的请求体限制,最后再看 @MultipartConfig 里的 maxFileSize。这个顺序基本不会错。
6. 问题排查链路:从点不到文件夹到上传后目录变平
6.1 场景A:点击自定义按钮,死活不弹文件夹选择框
这个现象我排查过一次。页面里用 <button> 触发隐藏的 input[type=file],结果在信创浏览器里点了没反应,换成 <label> 关联反而好了。
问题出在 display:none 的input上。部分浏览器认为不可见元素不该接收点击事件,即使是通过JS调用 click() 也会被忽略。解决方式就是前面说的:不要 display:none,用绝对定位移到屏幕外,opacity:0。这样input实际上还是可见的,只是用户看不到。
还有一些情况是信创浏览器的安全策略拦截了 input.click() 的自动触发。这种时候建议把按钮改成 <label for="dirPicker">,让浏览器原生行为去打开文件选择框,绕开JS手动触发,兼容性最好。
6.2 场景B:文件夹上传成功,服务器上目录结构全丢了
用户反馈比较常见:“我传的是一个文件夹,怎么后端变成了一堆散文件?” 我排查过几次,通常有两个原因。
第一个是前端根本没有发送 relativePath。有人直接 fd.append('files', file),后端遍历Part后只拿文件名保存,目录层级自然没了。解决方案就是前端显式把 webkitRelativePath 放到一个字段里,后端用 getParameter("relativePath") 拿。
第二个是后端 Part.getSubmittedFileName() 拿到的文件名被中间件处理过,只保留了最后一个 / 之后的basename,导致即使前端把路径塞在了文件名里也不生效。所以我不建议用“把相对路径作为文件名传过去”这种取巧方式,老老实实单独传一个字段最稳。
6.3 场景C:上传几万个小文件时页面卡死或服务器内存暴涨
这个属于典型的性能问题。直接一次性把所有文件 append 进一个FormData,浏览器要先在内存里构造整个请求体,几万个小文件叠加起来可能有几百MB甚至几GB,页面必然卡死。
服务器端更惨,中间件一次性接收整个大请求,内存里全是multipart分段,还没开始写盘就OOM了。解决办法就是前面代码里的“单文件请求 + 并发控制”,相当于把一个大请求拆成无数个小请求,单个请求最大也就单个文件大小,内存压力小很多。
还有一个隐藏因素:如果目录里单个文件特别大,比如几百GB,单文件请求也扛不住。这种情况只能做文件切片上传,或者和业务确认有没有必要支持这么大的文件。
6.4 从现象到根因的排查方法论
我自己总结的经验是:文件夹上传问题最先要区分是前端还是后端,不要瞎猜。
先打开F12看网络请求。如果请求都没发出,问题在前端选择或JS逻辑;如果请求发出了但服务器没落盘,问题在中间件或后端代码;如果落盘了但目录不对,重点查相对路径传递。
看请求时,注意Payload里的字段名和值。前端设计一个固定习惯:普通表单字段用 relativePath,文件字段用 file,后端也只用这两个名字,不要写一套猜一套。这种约定能省掉大量沟通成本。
7. 扩展思考:如果发布环境还是老浏览器怎么办
7.1 降级方案:ZIP上传加服务端解压
如果信创环境的浏览器确实老到连 webkitdirectory 都不支持,那目录上传这件事就别硬扛了,直接降级成ZIP上传。让用户先把文件夹在本地压缩成ZIP,再通过普通文件上传组件传上来,后端用Java解压。
解压时最要注意的是Zip Slip漏洞,攻击者可以在ZIP包内构造 ../../xxx 这样的条目路径,如果不做校验,解压过程就能把文件写到任意目录。服务端必须对每个条目做路径规范化检查:
java复制Path destDir = Paths.get(uploadRoot).toAbsolutePath().normalize();
try (ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path outPath = destDir.resolve(entry.getName()).normalize();
if (!outPath.startsWith(destDir)) {
throw new IOException("非法解压路径: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(outPath);
continue;
}
Files.createDirectories(outPath.getParent());
Files.copy(zis, outPath, StandardCopyOption.REPLACE_EXISTING);
}
}
还要留意ZIP包解压后的总大小。压缩炸弹的概念在真实环境里很常见,一个几十MB的压缩包解压出来可能是几个GB。建议在解压前先设置文件数量和总大小阈值,超过直接拒绝。
7.2 JSZip在前端压缩文件夹,看着美好但不推荐
有人可能会想:“既然浏览器不支持目录选择,能不能让用户选择多个文件,然后前端用JSZip压缩成ZIP再上传?”这个方法理论上可行,但有个致命短板:用户选择多个文件时,只能在文件选择对话框里逐个勾选文件,文件夹层级信息已经丢了。即便你用 webkitRelativePath 拿到了路径,这个前提依然是浏览器支持目录选择,否则拿不到层级。
JSZip处理几万个文件时,内存占用也非常夸张,浏览器很容易崩溃。所以前端压缩更适合几十个文件、明确需要保留目录结构的小场景,不适合作为信创环境文件夹上传的主要方案。
7.3 我的最终建议
做了这一圈之后,我的建议很明确:优先保证前端运行在Chromium内核浏览器上,用H5目录上传;同时预留ZIP上传作为兜底,当检测到浏览器不支持 webkitdirectory 时,自动给用户切换提示并引导到ZIP上传页面。两条路都走通,信创环境里九成以上的情况都能覆盖。
功能做完之后,我的体会是:老项目往新环境迁移,最大的成本不是语法升级,而是浏览器、中间件、文件系统这些“暗处的差异”。文件夹上传看起来只是一个input,背后却是前端目录遍历、后端安全落盘、中间件限制调整三件事的叠加。如果你正在做类似改造,我建议先做一个目录上传能力探测页,把浏览器是否支持 webkitdirectory、Servlet版本、上传大小限制一次性打印出来,比盲目开写代码高效得多。
