1. 项目概述:为什么我们还在为XSS头疼?

干了这么多年Web开发,每次看到项目里那些五花八门的表单输入框,心里总会咯噔一下。尤其是用户能提交内容、后台能存进数据库、前端又能原样渲染出来的地方,简直就是为“存储型XSS”量身定做的温床。最近在基于RuoYi框架做的一个内容管理项目里,又跟这个问题杠上了。客户要求用户能发带格式的帖子,但又担心有人塞恶意脚本。这场景太典型了,存储型XSS不像反射型那样“一次性”,它一旦被注入到数据库,就像埋了颗地雷,每个访问相关页面的用户都会中招,危害是指数级放大的。

虽然标题里带了RuoYi,但我想说的是,这里面的防御思路和具体实现,跟你用Spring Boot、Spring Cloud还是任何其他Java Web框架都没关系,核心是理解攻击原理和防御的层次。RuoYi本身是个优秀的开源快速开发平台,它提供了基础的安全过滤和权限控制,但在富文本内容处理这种深度定制场景下,我们依然需要自己动手,构建更坚固的防线。这次我就把自己在项目中实践的一套“组合拳”防御方案拆开揉碎了讲清楚,从最基础的过滤,到有点深度的转义策略,再到利用现代浏览器特性的CSP(内容安全策略),最后聊聊在RuoYi里怎么优雅地集成这些方案。无论你是不是用RuoYi,只要你的项目涉及用户内容的存储与展示,这些坑和经验你大概率都能用上。

2. 防御体系设计:从单点过滤到纵深防御

面对存储型XSS,很多开发者的第一反应是:“我在后台过滤一下 <script> 标签不就行了?”这个想法很朴素,但也很危险。攻击者的Payload(攻击载荷)早已进化得千变万化,远不止一个简单的 <script>alert(1)</script> 。他们会利用事件处理器(如 onerror onload )、伪协议(如 javascript: )、SVG标签、甚至CSS表达式等多种方式尝试绕过。因此,一个健壮的防御体系绝不能只依赖某一层或某一种方法,必须建立“纵深防御”的思想。

2.1 核心防御层次解析

我的防御策略主要围绕三个核心层次展开,它们分别在数据生命周期的不同阶段起作用,相互补充,构成一个立体防护网。

第一层:输入验证与过滤(入库前)。 这一层发生在服务器端接收到用户数据,准备存入数据库之前。目标是尽可能早地识别并处理掉潜在的恶意内容。但这里有个关键原则: “过滤”不等于“删除” 。对于纯文本字段,我们可以严格过滤掉所有HTML标签;但对于需要富文本的字段(如文章内容、评论),粗暴地删除所有标签会破坏用户格式。此时,我们需要的是一个“白名单”过滤策略,只允许安全的标签和属性通过。

第二层:输出编码/转义(出库后,渲染前)。 这是防御XSS,尤其是存储型XSS最有效、最根本的一层。无论数据在库里存成什么样,在将其输出到HTML页面时,根据其出现的上下文(Context)进行正确的编码,可以确保数据被浏览器理解为纯文本,而非可执行的代码。这是防御的基石。

第三层:内容安全策略(CSP,浏览器层)。 这是最后一道,也是越来越重要的防线。CSP通过HTTP头告诉浏览器,哪些外部资源(如脚本、样式、图片)可以被加载和执行,从而即使有恶意脚本被注入到页面中,浏览器也会拒绝执行它。它是一种“兜底”策略。

在RuoYi框架中,我们通常会在Controller层接收参数,在Service层处理业务逻辑(包括安全过滤),最后通过模板引擎(如Thymeleaf)或前端渲染。我们的防御措施需要无缝嵌入到这个流程中。

2.2 方案选型与工具考量

针对上述三层,我们需要选择合适的工具或库:

  1. 输入过滤(白名单): 我强烈推荐使用 OWASP Java HTML Sanitizer 。它是OWASP组织维护的项目,专门用于对HTML进行安全清理,政策(Policy)定义非常灵活,可以精确控制允许的标签和属性。相比于一些简单的正则表达式过滤,它更健壮,能有效防御各种绕过技巧。
  2. 输出编码: 这取决于你的渲染方式。
    • 服务端渲染(如Thymeleaf): Thymeleaf模板引擎默认会对所有使用 th:text 输出的变量进行HTML转义,这是非常安全的。 但千万注意 :如果你使用 th:utext (Unescaped Text),就等于关闭了转义,必须确保你传入 th:utext 的内容是绝对安全的(例如,已经过HTML Sanitizer处理过的)。
    • 前后端分离(前端渲染): 如果后端提供JSON API,前端使用Vue、React等框架,那么转义的责任就转移到了前端框架。Vue和React的模板语法( {{ }} {} )默认也会对输出进行转义。同样,当你使用 v-html dangerouslySetInnerHTML 时,你必须确保内容来源安全。
  3. 内容安全策略(CSP): 需要在Web服务器或应用框架的全局过滤器中配置HTTP响应头。在Spring Boot(RuoYi基于此)中,可以方便地通过配置 SecurityFilterChain 或使用 HttpSecurity 来添加CSP头。

重要心得: 不要试图自己写一个复杂的HTML解析和过滤器。这是一个安全领域非常专业的问题,自己写的很容易有遗漏,导致绕过。使用像OWASP Java HTML Sanitizer这样经过广泛审计和测试的权威库,是更可靠的选择。

3. 实操详解:三层防御的具体实现

接下来,我们进入实战环节,看看每一层防御在代码里具体怎么落地。我会以RuoYi框架中一个典型的“新闻发布”功能模块为例,其中 newsContent 字段需要支持富文本。

3.1 第一层:基于OWASP Java HTML Sanitizer的输入过滤

首先,在项目的 pom.xml 中添加依赖。

<dependency>
    <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
    <artifactId>owasp-java-html-sanitizer</artifactId>
    <version>20220608.1</version> <!-- 请使用最新版本 -->
</dependency>

然后,我们创建一个工具类 HtmlSanitizerUtil ,用于定义清理策略和执行清理。

import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;

@Component
public class HtmlSanitizerUtil {

    private PolicyFactory policyFactory;

    @PostConstruct
    public void init() {
        // 1. 创建白名单策略
        this.policyFactory = new HtmlPolicyBuilder()
                // 允许常见的文本格式标签
                .allowElements("p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "strong", "em", "b", "i", "u", "s")
                // 允许链接,但强制添加 rel="noopener noreferrer" 并验证href协议
                .allowUrlProtocols("http", "https")
                .allowAttributes("href").onElements("a")
                .requireRelNofollowOnLinks()
                // 允许图片,但限制为http/https协议,并可以添加alt、title、width、height属性
                .allowAttributes("src", "alt", "title", "width", "height").onElements("img")
                .allowUrlProtocols("http", "https").onElements("img")
                // 允许表格相关标签
                .allowElements("table", "thead", "tbody", "tr", "th", "td")
                .allowAttributes("border", "cellpadding", "cellspacing").onElements("table")
                .allowAttributes("colspan", "rowspan").onElements("th", "td")
                // 允许基本的样式属性(谨慎开启,也可通过CSS类控制)
                .allowStyling() // 允许style属性,但内部属性会被过滤
                // 全局属性,如class, id
                .allowAttributes("class", "id").globally()
                // 处理掉所有其他不允许的标签和属性
                .toFactory();
    }

    /**
     * 对富文本HTML进行安全清理
     * @param dirtyHtml 用户提交的原始HTML
     * @return 清理后的安全HTML
     */
    public String sanitize(String dirtyHtml) {
        if (dirtyHtml == null || dirtyHtml.trim().isEmpty()) {
            return "";
        }
        // 执行清理
        return policyFactory.sanitize(dirtyHtml);
    }
}

在Service层,我们在保存数据前调用这个工具类。

@Service
public class NewsServiceImpl implements INewsService {

    @Autowired
    private HtmlSanitizerUtil htmlSanitizer;

    @Override
    public int insertNews(News news) {
        // 对富文本内容进行安全过滤
        String safeContent = htmlSanitizer.sanitize(news.getContent());
        news.setContent(safeContent);
        // ... 后续的数据库插入操作
        return newsMapper.insertNews(news);
    }
}

关键点与避坑指南:

  • 白名单要“最小化” :只开放业务真正需要的标签和属性。上面的例子是一个相对宽松的富文本策略,如果你的场景只需要加粗、斜体,那么白名单就应该只包含 strong , em 等。
  • 链接和图片的协议限制 .allowUrlProtocols("http", "https") 至关重要,它能防止 javascript: data: 等危险伪协议。
  • 谨慎对待 style class :允许样式可能会引入CSS表达式攻击(现代浏览器已大多修复)或通过样式进行钓鱼的样式。如果可能,尽量通过预定义的CSS类来控制样式,而不是允许用户自定义 style 属性。上面代码中的 .allowStyling() 会启用一个内置的CSS过滤器,但根据业务情况,你可能需要更严格的策略。
  • 处理空值 :工具方法中对空值的判断必不可少。

3.2 第二层:确保输出转义万无一失

这一层更多的是规范和意识问题,技术实现相对简单。

场景一:使用Thymeleaf服务端渲染 在RuoYi的 .html 模板文件中,对于从后端传来的、已经过过滤的 news 对象,我们应该这样输出:

<!-- 安全:th:text 会自动进行HTML转义 -->
<div th:text="${news.title}"></div>

<!-- 危险:th:utext 不会转义,仅用于输出可信的HTML -->
<div th:utext="${news.content}"></div>

注意, news.content 之所以敢用 th:utext ,是因为我们在入库前已经用 HtmlSanitizerUtil 进行了严格的净化,它现在是“可信的HTML”。 绝对不要 将未经处理的用户输入直接传给 th:utext

场景二:前后端分离,前端(Vue)渲染 假设后端API返回的JSON数据如下:

{
  "id": 1,
  "title": "测试新闻",
  "content": "<p>这是一段<strong>安全</strong>的内容。<a href=\"https://example.com\">链接</a></p>"
}

在前端Vue组件中:

<template>
  <div>
    <!-- 安全:双花括号默认转义 -->
    <h1>{{ news.title }}</h1>
    
    <!-- 危险:v-html 会解析HTML,仅用于渲染可信内容 -->
    <div v-html="news.content"></div>
  </div>
</template>

同样的原则:只有确保 news.content 来自后端且已经过安全清理,才能使用 v-html 。对于新闻标题、作者等纯文本字段,一律使用 {{ }} 插值。

核心原则: “默认转义,显式信任” 。所有输出点默认都应进行编码。只有当你百分之百确信内容安全(如经过白名单过滤、或来自完全受控的内部数据源)时,才能使用那些不转义的输出方法( th:utext , v-html , dangerouslySetInnerHTML ),并且最好在变量名或注释中加以标注。

3.3 第三层:部署内容安全策略(CSP)

CSP是最后一道强有力的屏障。我们在Spring Security的配置类中(RuoYi中通常是 SecurityConfig )添加CSP头。

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authz) -> authz
                .anyRequest().authenticated()
            )
            // 添加CSP头
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; " + // 默认只允许同源
                                     "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " + // 允许的脚本源
                                     "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + // 允许的样式源
                                     "img-src 'self' data: https:; " + // 允许的图片源
                                     "font-src 'self' https://fonts.gstatic.com; " + // 允许的字体源
                                     "connect-src 'self';") // 允许的连接(如WebSocket, XHR)
                )
            )
            // ... 其他配置(表单登录、CSRF等)
            .formLogin(withDefaults());
        return http.build();
    }
}

CSP指令详解与调优:

  • default-src 'self' : 兜底策略,所有未明确指定的资源类型都只能从当前域名加载。
  • script-src : 控制脚本来源。 'self' 允许同源脚本。 'unsafe-inline' 允许内联脚本(如 <script>alert()</script> ), 这是一个安全隐患,但很多遗留代码或第三方库需要它 。我们的目标是最终移除它。 'unsafe-eval' 允许 eval() 等动态代码执行。后面的 https://cdn.jsdelivr.net 是例子,允许从该CDN加载Vue、jQuery等库。
  • style-src : 控制样式来源。 'unsafe-inline' 同样是为了兼容内联样式,应逐步替换为外部CSS文件。
  • img-src 'self' data: https: :允许加载同源图片、dataURL格式的图片(如Base64图片)、以及所有HTTPS协议的图片。
  • 如何确定策略? 最实用的方法是:先设置一个严格的策略(如 default-src 'self' ),然后打开浏览器开发者工具的控制台(Console)。浏览器会拦截所有违反CSP的资源加载并打印错误信息。根据这些错误,逐步将必要的源(如你使用的CDN、统计代码域名、字体库等)添加到对应的指令中。

在RuoYi中的集成注意点: RuoYi可能使用了特定的前端库或插件。部署CSP后,务必全面测试系统的所有功能,确保没有因为CSP限制而导致脚本、样式或图片加载失败。这是一个迭代优化的过程。

4. 进阶对抗:处理富文本编辑器与绕过案例

在实际项目中,用户是通过富文本编辑器(如UEditor、WangEditor、TinyMCE)提交内容的。编辑器本身可能会产生一些特定的HTML结构或属性,我们需要调整白名单策略来兼容它们,同时不降低安全性。

4.1 适配常见富文本编辑器

以WangEditor为例,它可能会生成带 class 的段落、使用 data-* 属性等。我们需要扩展之前的 HtmlPolicyBuilder

// 在HtmlSanitizerUtil的init方法中扩充策略
this.policyFactory = new HtmlPolicyBuilder()
        // ... 保留之前的所有allowElements和allowAttributes ...
        // 为WangEditor等编辑器添加更多支持
        .allowElements("blockquote", "pre", "code", "hr")
        .allowAttributes("class").matching(Pattern.compile("w-e-text-container.*")) // 匹配编辑器容器类
        .onElements("div")
        .allowAttributes("data-*").onElements("*") // 谨慎!允许所有data-*属性,风险较低但非零
        .allowAttributes("target").onElements("a").matching(Pattern.compile("_blank")) // 只允许target="_blank"
        // 更精细的样式过滤:可以定义一个自定义的样式过滤器
        .allowStyling(new CssSchema() {
            @Override
            public String validate(String tagName, String attributeName, String cssText) {
                // 这里可以写逻辑,只允许安全的CSS属性,如color, text-align, margin, padding等
                // 这是一个复杂话题,简单起见可以先允许,或直接禁用复杂样式
                if (cssText.contains("expression") || cssText.contains("javascript:")) {
                    return null; // 拒绝危险的CSS
                }
                return cssText; // 允许
            }
        })
        .toFactory();

关键点: data-* 属性通常用于存储自定义数据,风险相对较小,但理论上如果前端JavaScript不当地读取并执行了 data-* 里的内容,也可能引发问题。因此,如果业务不需要,更安全的做法是不允许 data-* ,或者只允许特定的 data- 属性。

4.2 典型XSS Payload绕过分析与防御

了解攻击者的思路,才能更好地防御。下面是一些常见的绕过尝试,以及我们的防御策略如何应对:

攻击Payload示例 攻击意图 我们的防御层如何拦截
<script>alert('XSS')</script> 最基础的脚本注入。 第一层(过滤) <script> 标签不在白名单内,被直接移除。
<img src=x onerror=alert(1)> 利用图片加载错误事件执行脚本。 第一层 onerror 属性不在允许的属性列表中,会被移除。即使 img 标签和 src 属性被保留,没有 onerror 也无法执行。
<svg/onload=alert(1)> 利用SVG标签及其事件。 第一层 svg 标签不在白名单内,整个元素被移除。
<a href="javascript:alert(1)">点击</a> 利用 javascript: 伪协议。 第一层 .allowUrlProtocols("http", "https") 限制了 href 属性的协议, javascript: 会被阻止。
<div style="background:url(javascript:alert(1))"> 利用CSS中的 javascript: 第一层 :通过自定义的 CssSchema 验证器,检测到 javascript: 并拒绝该样式。
<<script>script>alert(1)<</script>/script> 利用不规范的HTML解析进行绕过。 第一层 :OWASP HTML Sanitizer是一个健壮的解析器,能正确解析并识别出恶意部分,不会因为多余的尖括号而被绕过。
假设第一层被意外绕过 ,恶意代码 <img src=1 onerror=alert(1)> 存入了数据库。 攻击者找到了过滤器的漏洞。 第二层(输出编码) :如果前端错误地使用了 th:text {{ }} ,这段代码会被转义成纯文本显示,不会执行。但如果错误地使用了 th:utext v-html ,则脚本会执行。
恶意脚本在页面上被执行了。 攻击成功了一半。 第三层(CSP) :如果我们的CSP设置了 script-src 'self' ,并且这个内联的 alert(1) 并不是来自 self (同源),浏览器会 拒绝执行 这段内联脚本,从而最终阻止攻击。

从这个表格可以看出,三层防御是环环相扣的。任何一层的疏忽都可能被攻击者利用,但只要我们保证至少有一层是坚固的,就能有效防御大多数攻击。CSP在这里扮演了终极“保险丝”的角色。

5. 在RuoYi框架中的工程化实践与问题排查

将安全措施工程化,集成到RuoYi的开发流程中,才能保证其持续有效。

5.1 自定义注解与AOP统一处理

我们可以在RuoYi中创建一个自定义注解 @RichText ,标记那些需要富文本过滤的Controller方法参数,然后通过AOP进行统一处理,避免在每个Service方法里手动调用 sanitize

  1. 定义注解
    @Target(ElementType.PARAMETER) // 用于参数
    @Retention(RetentionPolicy.RUNTIME)
    public @interface RichText {
    }
    
  2. 创建切面
    @Aspect
    @Component
    @Slf4j
    public class RichTextSanitizeAspect {
    
        @Autowired
        private HtmlSanitizerUtil htmlSanitizer;
    
        // 环绕所有Controller方法中带有@RichText注解的参数
        @Around("execution(* com.ruoyi.project..*Controller.*(.., @RichText (*), ..))")
        public Object sanitizeRichText(ProceedingJoinPoint joinPoint) throws Throwable {
            Object[] args = joinPoint.getArgs();
            Signature signature = joinPoint.getSignature();
            MethodSignature methodSignature = (MethodSignature) signature;
            Method method = methodSignature.getMethod();
            Annotation[][] parameterAnnotations = method.getParameterAnnotations();
    
            for (int i = 0; i < args.length; i++) {
                for (Annotation annotation : parameterAnnotations[i]) {
                    if (annotation instanceof RichText) {
                        // 对标记了@RichText的String类型参数进行清理
                        if (args[i] instanceof String) {
                            String original = (String) args[i];
                            String sanitized = htmlSanitizer.sanitize(original);
                            // 可以记录日志,用于审计或调试
                            if (!original.equals(sanitized)) {
                                log.warn("检测到并清理了富文本参数中的潜在不安全内容,方法:{}", method.getName());
                            }
                            args[i] = sanitized;
                        }
                    }
                }
            }
            // 使用清理后的参数继续执行原方法
            return joinPoint.proceed(args);
        }
    }
    
  3. 在Controller中使用
    @PostMapping("/news/save")
    public AjaxResult saveNews(@RichText String content, String title) {
        // 此时传入的content参数已经被切面自动清理过了
        News news = new News();
        news.setTitle(title);
        news.setContent(content); // 这里已经是安全的内容
        return toAjax(newsService.insertNews(news));
    }
    

这样,安全过滤的逻辑就与业务代码解耦了,更加清晰和可维护。

5.2 常见问题排查清单

在实际集成和运行过程中,你可能会遇到以下问题:

问题现象 可能原因 排查步骤与解决方案
用户提交的富文本格式(如字体、颜色)全部丢失。 HTML过滤白名单过于严格,未包含相关标签或属性(如 <font> color 样式)。 1. 检查 HtmlSanitizerUtil 的白名单配置。
2. 根据富文本编辑器生成的HTML,将必要的标签和属性(如 <span style=\"color:red\"> )加入白名单。注意,样式属性需在 .allowStyling() 或自定义 CssSchema 中允许。
页面上显示的是HTML源代码(如显示 <p>内容</p> ),而不是渲染后的效果。 输出时错误地使用了转义输出(如 th:text ),而非原始HTML输出( th:utext )。 1. 检查前端模板或组件,确保对 已过滤的安全HTML内容 使用 th:utext v-html
2. 确认后端传给前端的内容确实是HTML字符串,而不是被二次转义了。
部署CSP后,页面上的部分脚本、样式或图片无法加载。 CSP策略过于严格,阻止了合法资源的加载。 1. 打开浏览器开发者工具(Console),查看具体的CSP报错信息,它会明确指出违反了哪条指令。
2. 根据报错,将必要的源地址(如第三方库CDN、统计域名、字体库)添加到Spring Security配置中对应的CSP指令里(如 script-src style-src img-src )。
AOP切面未生效,参数未被过滤。 1. 切面扫描路径不正确。
2. 注解未正确使用。
3. Spring AOP对Controller层的代理问题。
1. 检查 @Around 注解中的 execution 表达式,确保它能匹配到你的Controller方法。
2. 确认 @RichText 注解是加在方法参数上,且参数类型是 String
3. 确保Controller类已被Spring管理(有 @Controller @RestController 注解)。
4. 对于RuoYi,如果Controller使用了 @RequiresPermissions 等Shiro注解,可能需要调整AOP的优先级或使用不同的代理方式。
过滤后内容中出现奇怪的字符或空格。 OWASP Sanitizer在移除不安全标签时,可能会影响附近的空白字符或文本节点。 1. 这是正常现象,安全过滤的优先级高于格式完美。可以尝试在过滤后对HTML进行“美化”或规范化处理,但需谨慎避免引入新风险。
2. 告知产品与用户,这是安全特性,轻微的格式变化是可接受的。

最后一点个人体会: 安全是一个持续的过程,而不是一劳永逸的功能。这套“过滤+转义+CSP”的组合拳,在项目初期就应当作为基础设施来搭建。每次引入新的富文本编辑器组件或第三方库时,都要重新评估其对CSP策略的影响。定期(比如每季度)回顾和更新OWASP HTML Sanitizer的白名单策略,并尝试收紧CSP规则(比如尝试移除 ‘unsafe-inline’ )。在RuoYi这类快速开发框架中做项目,效率固然重要,但绝不能以牺牲安全为代价。把这些安全实践变成团队开发规范的一部分,代码审查时多看一眼数据流和输出点,就能避免很多潜在的风险。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐