在JSP/Servlet这套技术栈里,文件上传算是出镜率极高又特别招坑的功能。表面上看就是一个<input type="file">加一个提交按钮的事,实际落地时却能牵扯出表单编码格式、Servlet版本差异、临时文件清理、中文文件名乱码、服务端校验缺失等一堆问题。这篇文章我会把JSP文件上传从原理到实战完整拆一遍,同时把我在实际项目中踩过的坑、总结的防护手段一起放进来,无论你是刚接触JSP的新手,还是被上传功能折磨过的老开发,都能找到能直接拿去用的东西。
1. JSP文件上传的核心思路拆解
1.1 为什么一个文件上传会让新手懵圈
很多人第一次写文件上传时,习惯性照搬普通表单提交,结果后台用request.getParameter("file")一取值,发现是null,然后就开始怀疑人生。问题不出在代码逻辑,而是出在HTTP协议层面的表单编码方式。
普通表单默认的编码类型是application/x-www-form-urlencoded,浏览器会把字段名和值做URL编码后塞进请求体,服务端解析这种格式非常直接,所以getParameter能取到值。但文件上传不一样,文件内容是二进制数据,没法做简单的URL编码,必须改用multipart/form-data编码。这种格式下,请求体会被拆分成多个部分,每个部分用一段随机生成的boundary字符串分隔,每个部分里既有Content-Disposition头信息,也有文件内容本身。
所以整个JSP文件上传的核心思路就是:前端表单指定enctype="multipart/form-data",后端不能再指望传统的getParameter拿到文件,而是要去解析这种特殊格式的请求体。这也是理解后面所有代码的前提,凡是讲不清楚这一点的教程,多半会让人越看越乱。
1.2 服务端解析multipart请求的两种路线
既然getParameter拿不到东西,服务端就得引入专门的解析逻辑。目前在Java Web领域,主流方案就是两条路:
- Servlet 3.0及以上提供的Part API:这是官方标准方案,不需要额外引入第三方依赖,前提是你的容器(Tomcat 7+、Jetty 9+这类)支持Servlet 3.0。代码里通过
request.getPart("name")拿到文件部分,配合@MultipartConfig注解来配置大小限制等参数。 - Apache Commons FileUpload:在Servlet 3.0还没普及的年代,这是事实上的标准。即使现在,很多老项目、以及需要更精细控制上传行为的场景,依然在用这套组件。它的原理是先创建
DiskFileItemFactory,再用ServletFileUpload去解析请求,最终拿到一批FileItem。
选择哪条路,取决于项目的Servlet版本和团队习惯。如果是新项目,我推荐优先考虑Servlet 3.0的Part API,少一个依赖就少一个维护点;但如果你要兼容老容器,或者需要处理复杂的表单混合提交(文件+普通字段+多个文件),Commons FileUpload更灵活。两种方案我在后面都会给完整代码,互相印证着看,理解会更深。
2. 编码前的准备工作:环境与依赖
2.1 确认容器版本和Servlet API
动手之前先看一眼项目跑在什么容器上,这决定了你能不能用Part API。比如Tomcat 6是Servlet 2.5,不支持Part,老老实实上Commons FileUpload;Tomcat 7及以上都支持Servlet 3.0,但getSubmittedFileName()这个方法在 Tomcat 7.0.42 之前是没有的,如果用的老版本Tomcat,需要自己从Content-Disposition头里解析文件名。
如果你在IntelliJ IDEA里新建JSP项目,通常会遇到两个小问题:一是新建项目时默认的Servlet版本可能比较低,需要检查web.xml的schema版本或者直接在创建项目时选择更高版本;二是如果IDEA的Color Scheme里看不到JSP文件的高亮选项,多半是没装Java EE相关的插件,或者文件类型没被正确关联到JSP。这些环境问题虽然不直接卡在文件上传代码上,但排查起来特别影响心情,提前处理好能省不少事。
2.2 依赖怎么引入
如果你走Part API路线,不需要额外加任何依赖,但建议确认一下javax.servlet-api或者jakarta.servlet-api的作用域是provided,因为容器会提供这套API,打进去反而容易冲突。
如果走Commons FileUpload路线,需要引入两个包:
<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.15.1</version> </dependency>注意,Commons FileUpload 1.x 依赖commons-io,版本不能乱配。另外还要提一句:从2023年开始Commons FileUpload发布了2.x,包名从org.apache.commons.fileupload换成了org.apache.commons.fileupload2,API也有变动,老项目升级时要谨慎评估。如果是新项目,我建议直接用官方2.x,毕竟1.x已经停止维护了。
2.3 前端表单:一句话点破关键
JSP页面里的表单必须设置编码类型,这一步错了后面全白搭:
<form action="${pageContext.request.contextPath}/upload" method="post" enctype="multipart/form-data"> <div> <label for="file">选择文件:</label> <input type="file" name="file" id="file"> </div> <div> <label for="desc">文件描述:</label> <input type="text" name="desc" id="desc"> </div> <button type="submit">上传</button> </form>action里建议用${pageContext.request.contextPath}拼接项目上下文路径,这样不会因为部署路径变化导致404。method必须是post,因为multipart/form-data的请求体通常比较大,get有URL长度限制。表单里可以同时混合普通文本字段和文件字段,后面解析时要区分对待。
3. 方案一实操:Servlet 3.0 + Part API实现上传
3.1 @MultipartConfig参数的含义
Part API的核心是一个注解:@MultipartConfig。它在Servlet类上声明,用来告诉容器当前Servlet要接收multipart请求,并且可以配置几个关键限制:
@MultipartConfig( location = "/tmp", fileSizeThreshold = 1024 * 1024 * 2, maxFileSize = 1024 * 1024 * 10, maxRequestSize = 1024 * 1024 * 20 )fileSizeThreshold:文件达到多大时开始写入磁盘临时文件,小于这个值直接存内存。单位是字节,默认0,即全部落盘。建议设成2MB左右,因为内存太宝贵。maxFileSize:单个文件最大字节数,超过会抛IllegalStateException。我这里限制10MB。maxRequestSize:整个multipart请求体的最大字节数。如果表单里还带普通字段,或者一次传多个文件,请求体总量可能大于单个文件,所以这个值通常比maxFileSize大。这里我设的是20MB。location:临时文件的写入目录,可以配置,但实际开发中我更推荐让容器自己管理临时目录,这个参数通常配合Part.write时指定相对路径才用得上。
注意,还有一个隐藏参数是Tomcat的maxSwallowSize,它默认是2MB,当服务端因为文件超限拒绝接收时,Tomcat会尝试把剩余请求体吞掉,如果剩余部分过大,会直接把连接掐掉,响应里就看不到明确的报错信息。这种情况要把maxSwallowSize调大,或在连接器上配置maxPostSize="-1"取消对POST请求体的限制。
3.2 后端Servlet完整代码
下面是单文件上传的完整实现,我用注释把关键点标出来:
package com.example.servlet; 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.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.UUID; @WebServlet("/upload") @MultipartConfig( fileSizeThreshold = 1024 * 1024 * 2, maxFileSize = 1024 * 1024 * 10, maxRequestSize = 1024 * 1024 * 20 ) public class UploadServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); String uploadDir = getServletContext().getRealPath("/WEB-INF/uploads"); File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } // 普通表单字段 String desc = request.getParameter("desc"); System.out.println("文件描述:" + desc); // 获取上传的文件部分,参数对应表单里file控件的name Part part = request.getPart("file"); if (part == null || part.getSize() == 0) { response.getWriter().write("未选择文件"); return; } // 从Content-Disposition头解析原始文件名 String submittedFileName = part.getSubmittedFileName(); String ext = ""; if (submittedFileName != null && submittedFileName.contains(".")) { ext = submittedFileName.substring(submittedFileName.lastIndexOf(".")); } // 处理文件名,使用UUID避免重名和路径穿越 String storedName = UUID.randomUUID().toString().replace("-", "") + ext; try (InputStream is = part.getInputStream()) { Files.copy(is, Paths.get(dir.getAbsolutePath(), storedName), StandardCopyOption.REPLACE_EXISTING); } response.getWriter().write("上传成功,保存文件名:" + storedName); } }这里我没有用part.write(path),而是用Files.copy从part.getInputStream()流式拷贝。原因有两个:一是part.write在某些容器实现里要求路径基于@MultipartConfig的location,直接用绝对路径偶尔会出诡异问题;二是通过流拷贝,我可以更好地控制写入位置和异常处理。
3.3 几个必须注意的细节
第一个细节是中文文件名乱码。前端传来的文件名是UTF-8编码的,但如果POST请求没有声明字符集,Tomcat默认会用ISO-8859-1去解码,结果就是文件名变成乱码。解决办法是我代码里的request.setCharacterEncoding("UTF-8"),而且必须放在任何读取请求参数之前调用。如果你用的Tomcat 8+,默认URIEncoding就是UTF-8,但请求体编码还是得显式设置。
第二个细节是getSubmittedFileName()的返回值。这个方法的文档说得很清楚:返回浏览器指定的原始文件名,并非服务端存储的文件名。如果返回null(某些容器或老版本),就要从Content-Disposition头自己解析,后面问题篇我会给解析代码。
第三个细节是上传目录的选择。我的习惯是放在WEB-INF下面,而不是随便建一个uploads目录。因为WEB-INF下的资源不能通过URL直接访问,能防止用户上传一个JSP或恶意脚本后直接通过浏览器访问执行,这是文件上传安全的第一道防线。
4. 方案二实操:Commons FileUpload实现上传
4.1 组件核心机制
Commons FileUpload的处理流程可以拆成三步:先创建DiskFileItemFactory来定义阈值与临时目录,再创建ServletFileUpload并设置大小限制,最后调用parseRequest解析请求得到文件项列表。它跟Part API最大的区别在于:Part API把解析逻辑封装在容器内部,对开发者来说是个黑盒;而Commons FileUpload把整个解析过程放在应用层,你能拿到每一个FileItem,控制粒度更细。
DiskFileItemFactory的setSizeThreshold决定文件在内存中缓存的上限,超过这个值就会落盘到临时目录;setRepository可以指定临时目录,不设置时默认是System.getProperty("java.io.tmpdir"),运维时要注意这个目录不能设成只读。
4.2 完整代码实现
下面是一个兼容老版本Tomcat的Commons FileUpload写法:
package com.example.servlet; import org.apache.commons.fileupload.FileItem; import org.apache.commons.fileupload.FileUploadBase; import org.apache.commons.fileupload.disk.DiskFileItemFactory; import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.apache.commons.io.FilenameUtils; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.IOException; import java.util.List; import java.util.UUID; @WebServlet("/uploadCommons") public class UploadCommonsServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); if (!ServletFileUpload.isMultipartContent(request)) { response.getWriter().write("表单编码类型必须是multipart/form-data"); return; } DiskFileItemFactory factory = new DiskFileItemFactory(); factory.setSizeThreshold(1024 * 1024 * 2); File tempDir = new File(System.getProperty("java.io.tmpdir")); factory.setRepository(tempDir); ServletFileUpload upload = new ServletFileUpload(factory); upload.setFileSizeMax(1024 * 1024 * 10); // 单个文件限制10MB upload.setSizeMax(1024 * 1024 * 20); // 整个请求限制20MB upload.setHeaderEncoding("UTF-8"); // 解决文件名乱码 String uploadDir = getServletContext().getRealPath("/WEB-INF/uploads"); File saveDir = new File(uploadDir); if (!saveDir.exists()) { saveDir.mkdirs(); } try { List<FileItem> items = upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { // 普通表单字段 String fieldName = item.getFieldName(); String fieldValue = item.getString("UTF-8"); System.out.println(fieldName + "=" + fieldValue); } else { // 文件字段 String fileName = FilenameUtils.getName(item.getName()); String ext = FilenameUtils.getExtension(fileName); String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; File storedFile = new File(saveDir, storedName); item.write(storedFile); response.getWriter().write("上传成功:" + storedName + "<br/>"); } } } catch (FileUploadBase.FileSizeLimitExceededException e) { response.getWriter().write("文件超过了10MB限制"); } catch (FileUploadBase.SizeLimitExceededException e) { response.getWriter().write("请求体超过了20MB限制"); } catch (Exception e) { response.getWriter().write("上传失败:" + e.getMessage()); } } }这里有两个细节值得强调。第一,FilenameUtils.getName(item.getName())非常关键,因为浏览器端传过来的item.getName()往往是完整路径,比如C:\Users\admin\Desktop\photo.jpg,直接拿来当存储文件名,在Windows上会得到带盘符的非法文件名。第二,FileUploadBase下的两个异常类型要分开捕获,不然用户根本搞不清是单个文件超限还是整个请求超限。
4.3 两种方案怎么选
对比一圈会发现,Part API代码更简洁、更现代,适合新项目;Commons FileUpload功能更全面、兼容性更好,适合老项目或者复杂场景(比如一次上传多个文件且需要逐项校验)。它们不是对立关系,很多老项目升级时可以先用Commons FileUpload打个底,再逐步迁移到Part API。
另外提醒一句,无论选哪种方案,前端JS校验都只是用户体验层面的过滤。我在实际测试中就遇到过用Postman绕过前端直接提交的情况,后端的isMultipartContent判断、文件类型校验、大小限制这些绝不能省。前端校验是方便用户尽早发现错误,后端校验才是真正的安全底线。
5. 上传安全:这些坑不能只靠前端
5.1 文件类型校验怎么做才可靠
文件上传是所有Web应用最常见的攻击入口之一,真正的风险点在于:如果服务端不校验上传文件的实际内容,攻击者完全可以上传一个伪装成图片的可执行脚本,再想办法让服务器去执行它。因此开发上传功能时,安全措施不是加分项,而是必做项。
先说基础校验。request.getPart("file").getContentType()或item.getContentType()返回的MIME类型来自客户端请求头,完全不可信,可以伪造。我们需要的是服务端自己去判断。最常用的做法是读取文件头几个字节的魔数(magic number)来判断真实类型,比如JPEG文件以FF D8 FF开头、PNG文件以89 50 4E 47开头、GIF以47 49 46 38开头。魔数校验无法覆盖所有场景,但配合扩展名白名单,已经能挡住绝大多数伪造攻击。
我在项目里的习惯是:先做扩展名白名单(只允许jpg、png、gif、pdf、zip这类),再做魔数校验,最后把实际校验结果记录到日志。扩展名列表放在配置里而不是写死在代码中,这样后续调整不需要重新编译。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf"); private boolean checkMagicNumber(String ext, byte[] header) { if ("jpg".equalsIgnoreCase(ext) || "jpeg".equalsIgnoreCase(ext)) { return header.length >= 3 && (header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8 && (header[2] & 0xFF) == 0xFF; } if ("png".equalsIgnoreCase(ext)) { return header.length >= 8 && (header[0] & 0xFF) == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G'; } if ("gif".equalsIgnoreCase(ext)) { return header.length >= 4 && header[0] == 'G' && header[1] == 'I' && header[2] == 'F' && header[3] == '8'; } return false; }读取文件头部时,不要一次性把整个文件读进内存,用InputStream读取前8个字节就够了,大文件也不会有内存压力。
5.2 文件名处理与存储规划
文件名是最容易被忽略又最容易出事的地方。原始文件名里的../、..\在拼接路径时可能造成路径穿越,攻击者能通过精心构造的名字把文件写到任意目录。所以我在前面的代码里一律不使用原始文件名作为存储名,而是用UUID生成随机文件名,同时用FilenameUtils.getName把路径部分剥掉。这样既避免了路径穿越,又解决了重名覆盖问题,一举两得。
存储位置规划上,有两件事要做:
- 文件尽量不要落在Web应用的部署目录,尤其不要放在Tomcat的
webapps目录里。因为部署目录在应用重新部署时会整个清掉,文件会丢;而且如果上传目录在Web根内,JSP、脚本等文件就能被URL直接访问执行,这是大忌。我会把上传目录配置在服务器磁盘的独立目录,比如/data/uploads,通过配置文件注入,不写死在代码里。 - 如果必须存在应用内,就放在
WEB-INF/uploads下面,让外部无法直接通过URL访问到,再提供一个带权限控制的下载Servlet来向外输出文件。
5.3 上传文件的XSS风险与修复
文件上传还有一个容易被忽略的安全问题:XSS。假如应用允许用户上传HTML、SVG这类文件,而这些文件存储在同一个域名下,攻击者会上传一个内嵌恶意脚本的HTML/SVG文件,然后把链接发给其他用户,用户一打开,脚本就在同源策略允许的范围内执行,偷Cookie、改页面都干得出来。这就是热词里“文件上传XSS修复”的典型场景。
修复思路有几个层面:
- 不允许上传HTML/SVG这类文本型文件,这是最省事也最稳妥的办法。如果业务要求必须支持,见下一条。
- 下载而非直接预览。在响应头里加
Content-Disposition: attachment; filename="...",强制浏览器下载文件而不是在页面中渲染,这样脚本不会执行。 - 给所有动态输出文件的响应加
X-Content-Type-Options: nosniff头,防止浏览器对响应内容做MIME类型猜测,降低类型混淆攻击的风险。 - 对外部上传的文件做内容清洗再存储,但清洗本身就容易出错,我一般不做这条,而是剂量化处理。
实际项目里,我通常默认不允许上传HTML/SVG,对图片类文件优先输出时限定为缩略图格式。这样用户看到的是处理后的图片而不是原始SVG,既防了XSS又省了存储空间,一举两得。
6. 常见问题排查与实测心得
6.1 高频报错与解决速查
我在技术支持里遇到的JSP文件上传问题,绝大多数可以归到下面几张表里:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
request.getParameter()返回null | 表单没设置enctype="multipart/form-data",或后端没按multipart解析 | 确认表单编码类型;用getPart或Commons解析 |
getSubmittedFileName()返回null | Tomcat版本低于7.0.42,或容器不支持 | 从Content-Disposition头手动解析文件名 |
上传大文件报IllegalStateException | 超过了@MultipartConfig的maxFileSize或maxRequestSize | 调整注解参数或Commons的大小限制配置 |
| 中文文件名乱码 | 请求体编码没设置或乱配对 | request.setCharacterEncoding("UTF-8");Commons设置setHeaderEncoding("UTF-8") |
| 上传后文件找不到/丢失 | 保存到了临时目录,或项目重新部署被清空 | 改成独立存储目录,用配置绝对路径 |
connection reset/请求被掐断 | Tomcat的maxSwallowSize太小,无法吞掉被拒请求的剩余内容 | 调大maxSwallowSize或把maxPostSize设为-1 |
| 文件传上去了但浏览器无法访问 | 文件在WEB-INF下,外部URL无法直接访问 | 写一个下载Servlet,通过代码输出文件流 |
遇到问题时,我强烈建议先看Tomcat的catalina日志,很多容器层面的异常(比如maxSwallowSize相关的IOException)在应用层根本看不到,日志里却写得很清楚。
6.2 排查思路与我的习惯做法
每次排查上传问题,我会按照“前端表单->请求是否到达->容器是否拦截->应用是否解析->文件是否落盘”的顺序来推进,而不是一头扎进代码里猜。
先打开浏览器开发者工具看提交的Request Header和Payload,确认Content-Type里确实有boundary,说明表单没问题。再在Servlet入口打一个日志,确认请求到达时request.getContentType()是什么,如果这里就是空,问题在前端或拦截器。接着检查是否被过滤器或安全框架拦截,有些框架默认会对multipart请求做预解析,处理不当就会把Part流读坏。最后才看具体的解析和保存逻辑。
我自己习惯在开发环境做两件小配置来加速排查:一是把org.apache.tomcat.util.http.fileupload和org.apache.commons.fileupload的日志级别调到DEBUG,可以看到临时文件什么时候创建和清理;二是在上传Servlet里统一加一个finally块,记录本次上传的耗时、文件大小和最终路径,方便事后追溯。别小看这些日志,线上出问题时,它们是唯一能还原现场的线索。
6.3 最后再分享一个实用小习惯
我在写文件上传功能时,一定会同时预留一个“删除文件”的后台接口,并且对上传的文件做生命周期管理。开发阶段可能不觉得,真到了线上,用户传错文件要删、恶意文件要清理、磁盘空间要回收,没有运维接口就只能手动登录服务器,又慢又容易漏。这个接口要注意鉴权和范围限制,不能让用户删除别人的文件。另外一个很好的习惯是给上传记录建一张表,存原始文件名、存储名、大小、上传人、上传时间、下载次数,即使不只为了审计,排查线上问题也方便很多。
JSP文件上传看似是个小功能,但把它做扎实,需要的细节比想象中多得多。从HTTP编码原理到容器参数,从路径防穿越到XSS修复,每一环都值得认真打磨。你按这套思路走下来,再结合自己项目的实际情况调整目录和大小限制,基本不会在核心逻辑上翻车。