Resolve HTTP Multipart/related request on Spring6
系统从Spring5升级到Spring6, 除了要进行把javax的api迁移到Jakarta、升级Servlet容器到支持你所选的Jakarta的版本、升级Spring Security对应的API等这些常规操作,还可能遇到一些trick的问题。这里聊一下因为老系统没有限制客户端发送 multipart/related 这种请求而在升级后造成的问题及解决,虽然感觉这种场景 99.999% 的项目都不会遇到。
以流水帐的方式过一下。
升级前Spring5+Jetty9, 升级后Spring6+Jetty11
1)系统上线几天后,有客户说upload csv文件不成功。
2)最后从SumoLogic日志中发现原因是ContentType格式不对,又进一步确认是Spring5的系统支持 multipart/related 而Spring6 不支持造成的。
1 | Caused by: jakarta.servlet.ServletException: Unsupported Content-Type [Multipart/Related; boundary=AAABBB; type="text/xml"; start="root-part--123"], expected [multipart/form-data] |
3)Spring5 是使用Spring自带的MultipartParser,在解析后传给 servlet controller。系统使用Jetty9作为servlet容器。
4)Spring6 之后之前的 CommonsMultipartResolver 被替换为 StandardServletMultipartResolver。而StandardServletMultipartResolver会依赖容器来对Multipart请求做解析。(容器的实现必然有差别)
1 | Several outdated Servlet-based integrations have been dropped: e.g. Apache Commons FileUpload (org.springframework.web.multipart.commons.CommonsMultipartResolver), and Apache Tiles as well as FreeMarker JSP support in the corresponding org.springframework.web.servlet.view subpackages. We recommend org.springframework.web.multipart.support.StandardServletMultipartResolver |
5)其实不管Jetty 9还是Jetty 11其实都是不支持multipart/related的,之前没有问题是因为CommonsMultipartResolver支持。这样controller直接收到Multipart file这个数据。
6)搭建环境重现、Debug问题。
这里推荐IntelliJ插件jump-to-line
还有个调试技巧是利用IntelliJ的 条件断点及 Evaluate and Log 进行一些变量值的动态修改。
7)修改 org.eclipse.jetty.server.Request中 如下代码
1 | public Collection<Part> getParts() |
修改 org.eclipse.jetty.server.MultiPartInputStreamParser 中如下代码
1 | protected void parse() |
绕过条件限制后,发现对普通csv文件通过http Multipart/related上传是可以处理了,controller 可以接收到 MultipartFile 类型的 file 参数了。
其实这个蛮侥幸的,如果Jetty代码压根不支持,估计就得再用其它办法了。
8)后来发现zip格式不支持。Debug后发现是Jetty自己在内部处理时,必须要求临时文件的目录要存在,所以有加了对应逻辑。如下:
1 | public void write(String fileName) throws IOException |
这样修改之后通过了QA的测试。
9)为了测试 multipart/related 请求,也颇费周折。
通过curl命令实现了发送 multipart/related 请求。
1 | boundary="upload_boundary" |
- 通过这 curl 命令向spring6 发送没问题。但是向 spring5系统发送后却得到500响应。但是通过java程序发送的multipart/releated请求确没问题。???
后台错误日志:
Caused by: org.apache.commons.fileupload.MultipartStream$MalformedStreamException: Stream ended unexpectedly
11) 为了搞清原因,在本地把 Mitmproxy 跑起来抓包。
从界面上怎么也看不出root cause,直到把请求通过 mitmproxy 导出成curl命令,才发现是换行表示的不同造成的。
通过Java程序发送的能被Spring5处理的请求是\r\n作为换行。
-d '--upload_boundary\x0d\x0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"\x0d\x0aContent-Type: text;
而curl发送的就是\n。
-d '--upload_boundary\x0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"\x0aContent-Type: text/xml;
12)为了证实确实是换行符造成的, 把/n转为 /r/n后通过curl命令发送后 Spring5也能处理了。
这个规范 rf7230 上也说有的请求接受者做得更“健壮”可以接受LF结尾的请求。
实际上也就是这些“健壮”破坏了规范。(另外,如果从window系统上用curl命令,应该默认就是CRLF的吧?)
1 | echo $body > body.txt |
下面代码演示如何发送zip这样的二进制格式文件。
1 | boundary="upload_boundary" |
13)中间也尝试通过filter在中间使用 Commons FileUpload 2
但是遇到 Stream ended unexpectedly 的问题。另外,在Tomcat做容器的POC中,也是遇到类似的问题。当时debug时发现似乎是跟回车换行有关。当时也都是通过curl命令验证的。但因为自定义Jetty的方案已经可以work,所以就没再继续看。现在回头看很当时遇到的问题很可能跟Spring5遇到的一样。也许发送前对回撤换行处理一下,或许也可以解决。
14)如果通过 Commons FileUpload 2 + Filer 的方式可以解决,那这个方案就是最好的。最不好的方法其实就是这种定制Jetty代码,对以后的升级维护都是潜在的极大风险。
15)想起那句话,重要的是系统要限制能做什么。 为了这个patch前后花费的人天挺多的。。。新版本还delay了好久。
References: