Java后端接口的安全防护,最基础也最容易被忽视的两个漏洞就是XSS跨站脚本攻击和SQL注入。很多开发者习惯在业务代码里手动做过滤,或者依赖MyBatis的#{}语法就以为万事大吉,实际上这种做法零散且不可控。真正落地的方案,应当是在请求进入Controller之前就完成统一的清洗和拦截,而SpringBoot自带的过滤器机制恰好提供了这个切入点。

XSS攻击的本质是恶意用户将脚本代码通过表单、URL参数等途径注入到系统中,当这些数据被回显到页面时,脚本在浏览器端执行,从而窃取Cookie、劫持会话或篡改页面内容。SQL注入则是攻击者在输入参数中拼接SQL语句片段,欺骗数据库执行非预期的查询,轻则数据泄露,重则整表被删。两者的共同点在于:它们都利用输入数据作为攻击载体,而防御的核心策略就是过滤和转义。

在SpringBoot项目中,我们可以通过自定义Filter来实现对HttpServletRequest的包装,在getParameter、getInputStream等获取参数的方法上做一层代理,提前对请求数据进行清洗。这样做的好处是,无论后续是哪个Controller、哪个Service接收数据,拿到的都已经是经过安全处理的干净数据,从架构层面杜绝了遗漏过滤的可能性。

统一拦截的整体设计思路

整套方案的核心组件包括三个部分:一个自定义的HttpServletRequestWrapper包装类,用于重写获取参数的方法并在其中嵌入过滤逻辑;一个XSS和SQL注入的工具类,负责具体的关键字匹配和转义处理;一个注册到过滤器链的Filter实现,让请求在到达DispatcherServlet之前就被包装处理。

这里需要特别注意一个细节:普通的getParameter方法只能处理application/x-www-form-urlencoded类型的表单数据,对于JSON格式的请求体,数据是通过getInputStream流式读取的。如果只重写getParameter而忽略getInputStream,那么POST提交的JSON参数将完全绕过过滤。因此包装类必须同时覆盖这两个数据获取通道,确保无论哪种Content-Type都能被拦截清洗。

XSS过滤的核心实现

XSS过滤的常规做法是对HTML特殊字符进行转义,例如将小于号转义为<,大于号转义为>,双引号转义为"等。但仅靠字符转义还不够,攻击者可能使用JavaScript伪协议、事件属性甚至编码绕过。更稳妥的方式是结合正则表达式,对已知的攻击模式进行匹配和剔除。

下面这段代码展示了XSS过滤工具类的核心逻辑,它通过正则匹配script标签、onload等事件属性、javascript伪协议等常见攻击向量,同时保留正常的文本内容。

import java.util.regex.Pattern;

public class XssFilterUtil {

    // 匹配script标签及其内容
    private static final Pattern SCRIPT_PATTERN = Pattern.compile(
        "]*>[\\s\\S]*?", Pattern.CASE_INSENSITIVE);
    
    // 匹配标签上的事件属性,如onclick、onerror等
    private static final Pattern EVENT_PATTERN = Pattern.compile(
        "\\s+on\\w+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)", Pattern.CASE_INSENSITIVE);
    
    // 匹配javascript:伪协议
    private static final Pattern JS_PROTOCOL_PATTERN = Pattern.compile(
        "javascript\\s*:", Pattern.CASE_INSENSITIVE);
    
    // 匹配expression表达式(IE特有)
    private static final Pattern EXPRESSION_PATTERN = Pattern.compile(
        "expression\\s*\\(", Pattern.CASE_INSENSITIVE);

    public static String cleanXss(String value) {
        if (value == null || value.isEmpty()) {
            return value;
        }
        String cleaned = SCRIPT_PATTERN.matcher(value).replaceAll("");
        cleaned = EVENT_PATTERN.matcher(cleaned).replaceAll("");
        cleaned = JS_PROTOCOL_PATTERN.matcher(cleaned).replaceAll("");
        cleaned = EXPRESSION_PATTERN.matcher(cleaned).replaceAll("");
        
        // 转义核心HTML字符
        cleaned = cleaned.replace("&", "&")
                         .replace("<", "<")
                         .replace(">", ">")
                         .replace("\"", """)
                         .replace("'", "'");
        return cleaned;
    }
}

这段代码先通过正则删除明显的攻击代码片段,再对剩余的特殊字符做转义处理,双重保险。需要注意的是,转义顺序要把&符号放在最前面,否则先转义其他字符会产生新的&符号,导致二次转义问题。

SQL注入过滤的关键字策略

SQL注入的防御,参数化查询是根本手段,但在统一拦截层做一层关键字过滤可以起到纵深防御的作用。这里的思路不是去解析SQL语法,而是识别请求参数中可能包含的SQL关键字和特殊字符,对高危内容进行拦截或转义。

常见的SQL注入特征包括:单引号用于闭合字符串、分号用于结束语句、双减号用于注释、UNION SELECT用于联合查询、DROP TABLE等危险操作关键字。下面这个工具类通过正则匹配这些模式,并返回清洗后的字符串。

import java.util.regex.Pattern;

public class SqlInjectionFilterUtil {

    // SQL注入常见关键字
    private static final String[] SQL_KEYWORDS = {
        "select", "insert", "update", "delete", "drop", "truncate",
        "union", "exec", "execute", "declare", "create", "alter",
        "xp_cmdshell", "information_schema", "sleep", "benchmark"
    };

    private static final Pattern SQL_PATTERN = Pattern.compile(
        "('|%27)|(--)|(#)|(\\/\\*)|(\\*\\\\)|(;)|(\\b(" + String.join("|", SQL_KEYWORDS) + ")\\b)",
        Pattern.CASE_INSENSITIVE);

    public static String cleanSqlInjection(String value) {
        if (value == null || value.isEmpty()) {
            return value;
        }
        // 将单引号转义为两个单引号,这是SQL标准转义方式
        String cleaned = value.replace("'", "''");
        // 移除SQL注释符号
        cleaned = cleaned.replace("--", "")
                         .replace("#", "")
                         .replace("/*", "")
                         .replace("*/", "");
        return cleaned;
    }

    public static boolean containsSqlInjection(String value) {
        if (value == null || value.isEmpty()) {
            return false;
        }
        return SQL_PATTERN.matcher(value).find();
    }
}

这里有一个设计选择值得讨论:对于检测到SQL注入特征的数据,是直接拒绝请求还是做转义处理?直接拒绝更安全,但可能误伤正常业务,比如用户在文本框中输入了包含select单词的合法内容。比较折中的方案是:对明显的恶意特征(如单引号配合UNION关键字)做日志记录并返回友好提示,对单独出现的SQL关键字仅做转义处理,让参数化查询兜底。

请求包装类的完整实现

有了过滤工具类,接下来需要重写HttpServletRequestWrapper,让所有获取参数的方法都先经过过滤。这是整个方案中最关键的一步,因为它决定了过滤的覆盖范围是否全面。

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.HashMap;
import java.util.Map;

public class SecurityRequestWrapper extends HttpServletRequestWrapper {

    public SecurityRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        return filterValue(value);
    }

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

    @Override
    public Map getParameterMap() {
        Map parameterMap = super.getParameterMap();
        Map filteredMap = new HashMap<>();
        for (Map.Entry entry : parameterMap.entrySet()) {
            String[] values = entry.getValue();
            String[] filteredValues = new String[values.length];
            for (int i = 0; i < values.length; i++) {
                filteredValues[i] = filterValue(values[i]);
            }
            filteredMap.put(entry.getKey(), filteredValues);
        }
        return filteredMap;
    }

    @Override
    public String getHeader(String name) {
        String value = super.getHeader(name);
        return filterValue(value);
    }

    private String filterValue(String value) {
        if (value == null) {
            return null;
        }
        // 先做XSS清洗,再做SQL注入转义
        String cleaned = XssFilterUtil.cleanXss(value);
        cleaned = SqlInjectionFilterUtil.cleanSqlInjection(cleaned);
        return cleaned;
    }
}

这段代码覆盖了getParameter、getParameterValues、getParameterMap和getHeader四个方法,确保通过任何方式获取的单个参数或参数集合都经过过滤。注意过滤顺序是先XSS后SQL注入,因为XSS清洗会改变字符串结构,放在前面可以避免SQL注入检测被XSS转义后的内容干扰。

处理JSON请求体的InputStream包装

前面提到,对于Content-Type为application/json的请求,SpringMVC是通过HttpMessageConverter读取InputStream来解析参数的,不会调用getParameter方法。因此必须额外包装getInputStream和getReader方法,在读取流数据时进行过滤。

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

public class JsonSecurityRequestWrapper extends SecurityRequestWrapper {

    private byte[] body;

    public JsonSecurityRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        // 读取原始请求体
        InputStream inputStream = request.getInputStream();
        body = inputStream.readAllBytes();
        // 对JSON字符串进行过滤
        String jsonStr = new String(body, StandardCharsets.UTF_8);
        String filteredJson = filterJsonValue(jsonStr);
        body = filteredJson.getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

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

            @Override
            public void setReadListener(ReadListener listener) {
            }

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    private String filterJsonValue(String json) {
        if (json == null || json.isEmpty()) {
            return json;
        }
        String cleaned = XssFilterUtil.cleanXss(json);
        cleaned = SqlInjectionFilterUtil.cleanSqlInjection(cleaned);
        return cleaned;
    }
}

这里有一个容易踩坑的地方:HttpServletRequest的getInputStream和getReader只能调用一次,如果先读取了流做过滤,后续Spring框架就无法再次读取。解决方案是在构造器中一次性读取全部字节并缓存,然后每次调用getInputStream时返回新的ByteArrayInputStream,这样既完成了过滤,又不影响框架后续的数据绑定。

注册过滤器到SpringBoot过滤器链

包装类准备就绪后,最后一步是通过FilterRegistrationBean将自定义过滤器注册到Spring容器中。这里需要判断请求的Content-Type,对于JSON请求使用JsonSecurityRequestWrapper,对于普通表单请求使用SecurityRequestWrapper。

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Configuration
public class SecurityFilterConfig {

    @Bean
    public FilterRegistrationBean securityFilterRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean<>();
        registration.setFilter(new SecurityFilter());
        registration.addUrlPatterns("/*");
        registration.setName("securityFilter");
        registration.setOrder(1);
        return registration;
    }

    public static class SecurityFilter implements Filter {

        @Override
        public void doFilter(ServletRequest request, ServletResponse response, 
                             FilterChain chain) throws IOException, ServletException {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            String contentType = httpRequest.getContentType();
            
            HttpServletRequest wrapper;
            if (contentType != null && contentType.contains("application/json")) {
                wrapper = new JsonSecurityRequestWrapper(httpRequest);
            } else {
                wrapper = new SecurityRequestWrapper(httpRequest);
            }
            chain.doFilter(wrapper, response);
        }
    }
}

过滤器的order设置为1,确保它在SpringSecurity等其他过滤器之前执行,这样即使后续有安全框架对请求做二次处理,拿到的也是已经清洗过的数据。urlPatterns设置为/*表示拦截所有请求,如果项目中包含静态资源,可以额外配置excludePatterns排除图片、CSS等不需要过滤的路径,减少不必要的性能开销。

白名单机制与可配置化

全量过滤虽然安全,但某些业务场景确实需要用户输入特殊字符,比如富文本编辑器需要保留部分HTML标签,或者搜索功能需要支持SQL通配符。硬编码的过滤规则无法满足这种差异化需求,因此有必要引入白名单机制。

可以在配置文件中定义哪些URL路径不需要过滤,或者哪些参数字段允许特定字符通过。过滤器在初始化时读取这些配置,在执行过滤前先判断当前请求是否在白名单中。这种做法让安全策略从“一刀切”变成了“可精细化管理”,在安全性和业务灵活性之间取得了平衡。

日志记录与攻击预警

统一拦截层还有一个重要的附加价值:它天然是一个集中的安全监控点。在过滤工具类检测到明显的攻击特征时,可以记录详细的日志信息,包括攻击来源IP、请求路径、原始参数内容、触发时间等。这些日志不仅用于事后审计,还可以对接告警系统,当某个IP在短时间内触发大量拦截规则时,自动将其加入黑名单或触发人工审查。

日志记录时需要注意脱敏,避免将攻击Payload原样写入日志文件造成二次风险,可以对敏感部分做Base64编码或截断处理。同时日志级别要区分对待:普通的关键字命中记录为WARN级别,明显的恶意攻击记录为ERROR级别,方便运维人员快速筛选。

性能考量与优化建议

每次请求都对所有参数执行正则匹配和字符串替换,确实会带来一定的性能损耗。对于高并发系统,可以从几个方面进行优化:第一,对过滤后的结果做缓存,同一个参数值如果已经过滤过就不再重复处理;第二,将正则Pattern对象定义为静态常量,避免每次调用都重新编译;第三,对于明确不需要过滤的静态资源路径,在过滤器层面直接放行,不进入包装逻辑。

实测表明,在普通业务场景下,这套过滤方案对单次请求的额外耗时在1毫秒以内,对整体响应时间的影响几乎可以忽略不计。但安全性带来的收益却是实实在在的,它让每一条进入系统的数据都经过了标准化的安全检查,从源头上堵住了大量潜在漏洞。