LogoSiWei's Blog

从若依框架抄一个 XSS 过滤器

PSW 2026-06-24 47 阅读 20 分钟

项目里需要做 XSS 防护,自己从头写一个过滤器也不是不行,但想到若依(RuoYi)作为国内最常用的 Spring Boot 脚手架之一,肯定有现成的实现。翻了下源码,确实有,而且设计得挺清晰——配置层、过滤器层、请求包装层、过滤引擎层各司其职。直接移植过来了,这里记录一下源码分析。

1. FilterConfig — 配置入口

@Configuration
public class FilterConfig
{
    @Value("${xss.excludes}")
    private String excludes;

    @Value("${xss.urlPatterns}")
    private String urlPatterns;

    @SuppressWarnings({ "rawtypes", "unchecked" })
    @Bean
    @ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
    public FilterRegistrationBean xssFilterRegistration()
    {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setDispatcherTypes(DispatcherType.REQUEST);
        registration.setFilter(new XssFilter());
        registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
        registration.setName("xssFilter");
        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
        Map<String, String> initParameters = new HashMap<String, String>();
        initParameters.put("excludes", excludes);
        registration.setInitParameters(initParameters);
        return registration;
    }
}

几个设计要点:

  • @ConditionalOnProperty(value = "xss.enabled", havingValue = "true") — XSS 过滤可通过配置开关,不需要时直接关掉
  • FilterRegistrationBean.HIGHEST_PRECEDENCE — 最高优先级,确保 XSS 过滤在其他过滤器之前执行
  • URL 模式从配置文件注入,例如 /system/*,/monitor/*,/tool/*
  • 排除 URL 列表也通过配置文件注入,例如 /system/notice

urlPatternsexcludes 都支持逗号分隔的多值配置,用 StringUtils.split 解析。

2. XssFilter — 过滤器本身

public class XssFilter implements Filter
{
    public List<String> excludes = new ArrayList<>();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException
    {
        String tempExcludes = filterConfig.getInitParameter("excludes");
        if (StringUtils.isNotEmpty(tempExcludes))
        {
            String[] urls = tempExcludes.split(",");
            for (String url : urls)
            {
                excludes.add(url);
            }
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException
    {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        if (handleExcludeURL(req, resp))
        {
            chain.doFilter(request, response);
            return;
        }
        XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
        chain.doFilter(xssRequest, response);
    }

    private boolean handleExcludeURL(HttpServletRequest request, HttpServletResponse response)
    {
        String url = request.getServletPath();
        String method = request.getMethod();
        // GET DELETE 不过滤
        if (method == null || HttpMethod.GET.matches(method) || HttpMethod.DELETE.matches(method))
        {
            return true;
        }
        return StringUtils.matches(url, excludes);
    }
}

核心逻辑在 doFilter

  1. 检查当前请求是否在排除列表中
  2. GET 和 DELETE 请求直接放行(这两种方法不应该携带需要过滤的请求体)
  3. 不在排除列表的请求,用 XssHttpServletRequestWrapper 包装后继续执行过滤器链

这里有个细节:排除逻辑同时检查了 HTTP 方法 URL 匹配,两个条件任一命中就跳过过滤。

3. XssHttpServletRequestWrapper — 请求包装

public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper
{
    public XssHttpServletRequestWrapper(HttpServletRequest request)
    {
        super(request);
    }

    @Override
    public String[] getParameterValues(String name)
    {
        String[] values = super.getParameterValues(name);
        if (values != null)
        {
            int length = values.length;
            String[] escapesValues = new String[length];
            for (int i = 0; i < length; i++)
            {
                escapesValues[i] = EscapeUtil.clean(values[i]).trim();
            }
            return escapesValues;
        }
        return super.getParameterValues(name);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException
    {
        // 非 JSON 类型,直接返回原输入流
        if (!isJsonRequest())
        {
            return super.getInputStream();
        }

        String json = IOUtils.toString(super.getInputStream(), "utf-8");
        if (StringUtils.isEmpty(json))
        {
            return super.getInputStream();
        }

        // XSS 过滤
        json = EscapeUtil.clean(json).trim();
        byte[] jsonBytes = json.getBytes("utf-8");
        final ByteArrayInputStream bis = new ByteArrayInputStream(jsonBytes);
        return new ServletInputStream()
        {
            @Override
            public boolean isFinished() { return true; }

            @Override
            public boolean isReady() { return true; }

            @Override
            public int available() throws IOException { return jsonBytes.length; }

            @Override
            public void setReadListener(ReadListener readListener) {}

            @Override
            public int read() throws IOException { return bis.read(); }
        };
    }

    public boolean isJsonRequest()
    {
        String header = super.getHeader(HttpHeaders.CONTENT_TYPE);
        return StringUtils.startsWithIgnoreCase(header, MediaType.APPLICATION_JSON_VALUE);
    }
}

这个类继承 HttpServletRequestWrapper,覆盖了两个入口:

  • getParameterValues:过滤 URL 参数和表单参数(键值对形式的参数)
  • getInputStream:过滤 JSON 请求体。先判断 Content-Type 是否为 JSON,是的话把整个 body 读出来过滤后再塞回去

两处过滤都调了 EscapeUtil.clean(),核心过滤逻辑都在那一个方法里。

4. EscapeUtil — 转义工具

public class EscapeUtil
{
    public static final String RE_HTML_MARK = "(<[^<]*?>)|(<[\\s]*?/[^<]*?>)|(<[^<]*?/[\\s]*?>)";

    private static final char[][] TEXT = new char[64][];

    static
    {
        for (int i = 0; i < 64; i++)
        {
            TEXT[i] = new char[] { (char) i };
        }

        TEXT['\''] = "&#039;".toCharArray();  // 单引号
        TEXT['"'] = "&#34;".toCharArray();    // 双引号
        TEXT['&'] = "&#38;".toCharArray();    // &符
        TEXT['<'] = "&#60;".toCharArray();    // 小于号
        TEXT['>'] = "&#62;".toCharArray();    // 大于号
    }

    public static String clean(String content)
    {
        return new HTMLFilter().filter(content);
    }
}

XSS 过滤实际只用到 clean 这一个方法,它委托给 HTMLFilter.filter()

EscapeUtil 还有 escape(URL 编码)和 unescape(解码)方法,但在 XSS 场景下用不到。

5. HTMLFilter — 核心过滤引擎

这是最大的一坨代码,也是 XSS 防护的核心。完整的 filter 流程:

public String filter(final String input)
{
    reset();                          // 清空标签计数
    String s = input;
    s = escapeComments(s);            // 1. 转义注释中的特殊字符
    s = balanceHTML(s);               // 2. 平衡/转义不成对的尖括号
    s = checkTags(s);                 // 3. 白名单标签和属性过滤
    s = processRemoveBlanks(s);       // 4. 移除空标签
    return s;
}

第一步 checkTags 是核心中的核心——它用正则匹配出每个 HTML 标签,然后按白名单规则处理:

  • 不在白名单的标签 → 直接移除
  • 在白名单的标签 → 属性按白名单过滤,协议属性(srchref)额外检查协议白名单(只允许 httphttpsmailto

默认白名单很精简,只允许这几个标签和对应属性:

标签 允许的属性
a href, target
img src, width, height, alt
b, strong, i, em

第二步 processRemoveBlanks 清理空标签,比如 <b></b><b /> 这类没有任何内容的标签直接干掉。

第三步 balanceHTML 处理不成对的尖括号——默认模式 alwaysMakeTags = false,把不成对的 < > 直接转义成 &lt; &gt;,防止绕过。

整体数据流

请求进来
  → FilterConfig 判断 xss.enabled 是否开启
  → XssFilter.doFilter() 判断是否排除 URL / GET&DELETE 请求
  → XssHttpServletRequestWrapper 包装请求
    → getParameterValues() 过滤 URL/表单参数
    → getInputStream() 过滤 JSON 请求体
      → EscapeUtil.clean()
        → HTMLFilter.filter() 白名单过滤

说实话移植过程中最花时间的是理解 HTMLFilter 的正则那坨——这玩意是从 PHP 的 XSS 过滤库翻译过来的,逻辑是对的但代码风格一看就是 PHP 转 Java,挺多冗余。不过能用就行,没必要自己重写一个。

XSS 过滤器在 application.yml 中的配置示例:

xss:
  enabled: true
  urlPatterns: /system/*,/monitor/*,/tool/*
  excludes: /system/notice
目录

评论

© 2026 SiWei's Blog. All rights reserved.