关于Spring6里HTTP multipart/related 文件上传

2024-06-16 06:50:30 浏览数 (2)

系统从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 不支持造成的。

代码语言:log复制
Caused by: jakarta.servlet.ServletException: Unsupported Content-Type [Multipart/Related; boundary=AAABBB; type="text/xml"; start="root-part--123"], expected [multipart/form-data]
    at org.eclipse.jetty.server.Request.getParts(Request.java:2324) ~[jetty-server-11.0.19.jar!/:11.0.19]

3)Spring5 是使用Spring自带的MultipartParser,在解析后传给 servlet controller。系统使用Jetty9作为servlet容器。

4)Spring6 之后之前的 CommonsMultipartResolver 被替换为 StandardServletMultipartResolver。而StandardServletMultipartResolver会依赖容器来对Multipart请求做解析。(容器的实现必然有差别)

代码语言:log复制
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,如下

代码语言:java复制
    public Collection<Part> getParts()

        // if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.valueParameters(contentType, null)))
        if (contentType == null)


    private MultiParts newMultiParts(MultipartConfigElement config, int maxParts) throws IOException
    {
        // MultiPartFormDataCompliance compliance = getHttpChannel().getHttpConfiguration().getMultipartFormDataCompliance();
        MultiPartFormDataCompliance compliance = MultiPartFormDataCompliance.LEGACY;

修改 org.eclipse.jetty.server.MultiPartInputStreamParser 中如下

代码语言:java复制
    protected void parse()
        // if (_contentType == null || !_contentType.startsWith("multipart/form-data"))
        if (_contentType == null)
            return;

绕过条件限制后,发现对普通csv文件通过http Multipart/related上传是可以处理了,controller 可以接收到 MultipartFile 类型的 file 参数了。

其实这个蛮侥幸的,如果Jetty代码压根不支持,估计就得再用其它办法了。

8)后来发现zip格式不支持。Debug后发现是Jetty自己在内部处理时,必须要求临时文件的目录要存在,所以有加了对应逻辑。如下:

代码语言:java复制
public void write(String fileName) throws IOException
{
    if (_file == null)
    {
        _temporary = false;
        // Make sure the file/directory _tmpDir.getAbsolutePath()   fileName existed.
        touchTmpFileForJetty(_tmpDir.getAbsolutePath(), fileName);

这样修改之后通过了QA的测试。

9)为了测试 multipart/related 请求,也颇费周折。

通过curl命令实现了发送 multipart/related 请求。

代码语言:shell复制
boundary="upload_boundary"

body=$(cat <<EOF
--$boundary
Content-Disposition: form-data; name="file"; filename="myuploaded.csv"
Content-Type: text/xml; charset=UTF-8
Content-Transfer-Encoding: binary

$(cat /my-path-to-file/my.csv)
--$boundary--
EOF
)

curl -v -X 'POST' 
    -H 'accept: application/json' 
    -H 'Authorization: YOUR-BASE64-USERIDPWD' 
    -H "Content-Type: multipart/related; boundary=$boundary" 
    -d "$body" 
    'https://Your-Server:Port/service-path'

10) 通过这 curl 命令向spring6 发送没问题。但是向 spring5系统发送后却得到500响应。但是通过java程序发送的multipart/releated请求确没问题。???

后台错误日志:

代码语言:log复制
Caused by: org.apache.commons.fileupload.MultipartStream$MalformedStreamException: Stream ended unexpectedly

11) 为了搞清原因,在本地把 Mitmproxy 跑起来抓包。

从界面上怎么也看不出root cause,直到把请求通过 mitmproxy 导出成curl命令,才发现是换行表示的不同造成的。

通过Java程序发送的能被Spring5处理的请求是rn作为换行。

代码语言:log复制
-d '--upload_boundaryx0dx0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"x0dx0aContent-Type: text;

而curl发送的就是n。

代码语言:log复制
-d '--upload_boundaryx0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"x0aContent-Type: text/xml;

12)为了证实确实是换行符造成的, 把/n转为 /r/n后通过curl命令发送后 Spring5也能处理了。

这个规范 rf7230 上也说有的请求接受者做得更“健壮”可以接受LF结尾的请求。

实际上也就是这些“健壮”破坏了规范。(另外,如果从window系统上用curl命令,应该默认就是CRLF的吧?)

代码语言:shell复制
echo $body > body.txt
cat -e body.txt

unix2dos body.txt
cat -e body.txt

RNBody=$(cat body.txt)
echo $RNBody | cat -e

curl -v -X 'POST' 
    -H 'accept: application/json' 
    -H 'Authorization: YOUR-BASE64-USERIDPWD' 
    -H "Content-Type: multipart/related; boundary=$boundary" 
    -d "$RNBody" 
    'https://Your-Server:Port/service-path'

下面代码演示如何发送zip这样的二进制格式文件。

代码语言:shell复制
boundary="upload_boundary"

# 这里使用系统默认回撤换行。
{
echo "--$boundary"
echo "Content-Disposition: form-data; name="file"; filename="example.zip""
echo "Content-Type: text; charset=UTF-8"
echo "Content-Transfer-Encoding: binary"
echo ""
cat /your/path/to/zipfile
echo ""
echo "--$boundary--"
} > zip_body.txt


curl -X POST --proxy http://127.0.0.1:7070 -k 
-H "Content-Type: multipart/related; boundary=$boundary" 
-H "Authorization: Basic XXXXXX" 
--data-binary @zip_body.txt 
https://Your-Server:Port/service-path


# 这里明确使用 rn
{
echo -ne "--$boundaryrn"
echo -ne "Content-Disposition: form-data; name="file"; filename="example.zip"rn"
echo -ne "Content-Type: text; charset=UTF-8rn"
echo -ne "Content-Transfer-Encoding: binaryrn"
echo -ne "rn"
cat /your/path/to/zipfile
echo -ne "rn"
echo -ne "--$boundary--rn"
} > zip_body_inCRCL.txt

curl -X POST --proxy http://127.0.0.1:7070 -k 
    -H "Content-Type: multipart/related; boundary=$boundary" 
    -H "Authorization: Basic XXXXXX" 
    --data-binary @zip_body_inCRCL.txt 
    https://Your-Server:Port/service-path

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:

  • The MIME Multipart/Related Content-type
  • Form-based File Upload in HTML
  • Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing
  • commons-fileupload2
  • Servlet Spec and Tomcat version
  • https://github.com/spring-projects/spring-framework/wiki/Upgrading-to-Spring-Framework-6.x
  • jetty-examples

0 人点赞