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(
"", 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毫秒以内,对整体响应时间的影响几乎可以忽略不计。但安全性带来的收益却是实实在在的,它让每一条进入系统的数据都经过了标准化的安全检查,从源头上堵住了大量潜在漏洞。
