Spring Boot 接口安全加固:防 SQL 注入 + XSS + CSRF 的三道防线

引言

"渗透测试报告出来了,同一个后台系统三个高危:订单列表的排序参数能拖出整库用户表、订单备注里写一段 <script> 后台运营一查看就中招、攻击者还能借运营的登录态静默发起转账审核。" 三个漏洞的入口各不相同,但修复加起来没超过 200 行代码——因为它们正好对应 Web 安全里最经典的三道防线:SQL 注入、XSS、CSRF。

这三个词几乎每个 Java 开发者都听过,线上的真实情况却是:知道用 #{} 但 order by 场景图省事写成 ${};XSS 只在前端做了转义,接口用 curl 直接打就穿;CSRF 则更隐蔽——"我们是前后端分离 + JWT,应该不用防吧?" 这句话在不少团队里流传,但只要你的 token 放在 Cookie 里自动携带,CSRF 的门就一直敞着。

这篇文章按"攻击原理 → 漏洞代码 → 正确配置/代码 → 绕过手法"的结构,把三道防线一次讲透:SQL 注入靠 MyBatis 预编译 + 参数白名单(防御位置在数据访问层);XSS 靠输入校验 + 输出按上下文编码(防御位置在输出渲染层,输入过滤只是兜底);CSRF 靠 Spring Security 的 CSRF token + SameSite Cookie(防御位置在请求意图校验层)。所有代码基于 Spring Boot 2.7/3.x + Spring Security 5.8/6.x,配置采用新版 Lambda DSL,文末附绕过手法对照表和上线验收清单。


一、全景:先分清三种攻击在打什么

很多人把这三个漏洞混为一谈,加固时眉毛胡子一把抓(最典型的就是在全局 Filter 里写一大坨"危险字符正则",SQL/XSS/命令注入一起拦,结果正常业务被误杀一片)。先看清它们的本质区别:

维度SQL 注入XSSCSRF
攻击目标数据库其他用户的浏览器不知情的已登录用户
注入物SQL 语法片段HTML/JS 代码片段一个伪造的跨站请求
信任被滥用应用信任了拼接出的 SQL浏览器信任了页面里的脚本来源网站信任了 Cookie 自动携带的身份
防御核心参数化查询(#{})输出编码 + CSP随机性 token + SameSite
防御层Mapper/DAO输出渲染(模板/JSON 序列化)安全框架过滤器
一句话比喻把用户输入当成了"指令"把用户数据当成了"代码"执行借你的登录态替你"做操作"

纵深防御的位置示意:

攻击者输入
   │
   ▼
[Filter 层]  CSRF token 校验(Spring Security)        ← 第三道:验证请求意图
   │
   ▼
[Controller]  @Valid 参数校验(长度/字符集/白名单)     ← 输入校验:拒绝畸形数据
   │
   ▼
[Service / Mapper]  #{} 预编译,${} 白名单              ← 第一道:SQL 注入
   │
   ▼
数据库
   
数据取出 → [模板/JSON/页面渲染] 按上下文输出编码          ← 第二道:XSS 的根治点
   │
   ▼
受害者浏览器

记住一个贯穿全文的原则:每一层解决自己的问题,不要让一个全局过滤器同时背三道防线的锅。 后面会看到,错位的防御要么误杀正常业务,要么产生虚假的安全感。


二、第一道防线(上):SQL 注入——#{} 预编译到底防住了什么

2.1 漏洞代码长什么样

// Mapper 接口
List<Order> search(@Param("keyword") String keyword,
                   @Param("sortField") String sortField);
<!-- ✗ 反面教材:${} 是纯字符串替换 -->
<select id="search" resultType="Order">
    SELECT id, user_id, amount, status, remark
    FROM t_order
    WHERE remark LIKE '%${keyword}%'
    ORDER BY ${sortField} DESC
    LIMIT 20
</select>

当 sortField=id; SELECT password FROM t_user--(或利用报错注入、时间盲注)时,拼出来的 SQL 完全由攻击者控制。渗透报告里那个"拖库"用的就是 sortField,因为开发知道关键词用了 #{},唯独排序字段觉得"预编译会加引号导致排序失效",手一抖写成了 ${}——真实项目里 80% 的注入点不是忘了写 #{},而是"自以为只能用 ${}"的那几个场景。

2.2 #{} 为什么能防注入

#{} 在 MyBatis 解析后生成的是 JDBC 的 ? 占位符,SQL 结构在数据库端已经固定,参数通过协议单独传输:

// #{keyword} 最终执行的是 PreparedStatement
PreparedStatement ps = conn.prepareStatement(
        "SELECT ... FROM t_order WHERE remark LIKE ? ORDER BY ? DESC LIMIT 20");
ps.setString(1, "%手机%");     // 参数值永远是"数据",不可能被解释成 SQL 关键字
ps.setString(2, "amount");

即使传入 ' OR '1'='1,它也只是一个普通字符串字面值,数据库不会把它解析成 OR 条件。预编译防注入的本质不是"转义了引号",而是让代码和数据走了两个通道——这也是为什么单纯在 Filter 里把单引号替换掉防不住注入(编码绕过、数字型注入根本不需要引号),参数化才是根治。

正确写法:

<!-- ✓ 普通参数一律 #{} -->
<select id="search" resultType="Order">
    SELECT id, user_id, amount, status, remark
    FROM t_order
    WHERE remark LIKE CONCAT('%', #{keyword}, '%')
    <if test="userId != null">
        AND user_id = #{userId}
    </if>
    ORDER BY ${sortField} DESC
    LIMIT 20
</select>

注意 LIKE 的写法:CONCAT('%', #{keyword}, '%') 在数据库侧拼接,#{} 依然是参数;'%${keyword}%' 和在 Java 里 "%" + keyword + "%" 再传入都是注入点。

2.3 Service 层的参数校验(白名单而非黑名单)

sortField 是排序字段,预编译占位符确实会被加上引号导致语法错误,这是 ${} 唯一合法的使用场景之一——但必须配白名单,把"用户输入"收敛成"服务端认可的枚举值":

@Service
public class OrderQueryService {

    // 白名单:字段名 -> 允许排序的列
    private static final Map<String, String> SORT_WHITELIST = Map.of(
            "createTime", "create_time",
            "amount", "amount",
            "id", "id"
    );

    public PageResult<Order> search(OrderQuery query) {
        // 用户传的是别名,映射成真实列;不在白名单内直接拒绝(或回退默认值)
        String column = SORT_WHITELIST.get(query.getSortField());
        if (column == null) {
            throw new BizException(ErrorCode.BAD_REQUEST, "非法的排序字段");
        }
        query.setSortColumn(column);   // 进 Mapper 的永远是白名单里的值
        return orderMapper.search(query);
    }
}
<!-- 此时 ${sortColumn} 安全:值来自服务端 Map,攻击者无法注入 -->
ORDER BY ${sortColumn} DESC

Controller 入口再叠一层 Bean Validation 做长度和格式约束:

@Data
public class OrderQuery {

    @Size(max = 50, message = "关键词过长")
    @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_\\- ]*$",
             message = "关键词含非法字符")
    private String keyword;

    @Pattern(regexp = "createTime|amount|id", message = "非法的排序字段")
    private String sortField = "createTime";
}
@PostMapping("/api/orders/search")
public PageResult<Order> search(@Valid @RequestBody OrderQuery query) {
    return orderQueryService.search(query);
}

白名单(允许什么)永远优于黑名单(禁止什么):SQL 关键字、注释符、编码变换的变体无穷无尽,黑名单只能追着攻击跑;白名单直接把输入空间压缩到几个确定的值,没有绕过空间。


三、第一道防线(下):${} 高危场景与注入绕过手法

3.1 四个绕不开 ${} 的场景及标准解法

场景为什么不能直接 #{}正确做法
ORDER BY 字段占位符加引号,排序语法失效字段白名单映射(2.3 节)
动态表名/列名同上,标识符不能参数化白名单 + 配置化,禁止用户输入直入
IN (...)可以用 #{},但要 foreach 展开`` 生成占位符列表
动态数据库/排序方向表名不可参数化枚举限定 ASC/DESC、库名走配置

IN 与批量操作的正确写法(很多人误以为只能 ${}):

<!-- ✓ foreach 生成的是 (#{item.id}, #{item.amount}), (...) 全参数化 -->
<insert id="batchInsert">
    INSERT INTO t_order (user_id, amount, remark)
    VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.userId}, #{item.amount}, #{item.remark})
    </foreach>
</insert>

<select id="findByIds" resultType="Order">
    SELECT * FROM t_order WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

动态表名(如分表 t_order_202609)的处理原则:表名由服务端规则生成,用户只能给"数据"(如日期),不能给"表名":

public String orderTable(YearMonth month) {
    // 日期本身是强类型,格式化结果完全受控
    return "t_order_" + DateTimeFormatter.ofPattern("yyyyMM").format(month);
}

3.2 常见绕过手法:检验你的防御是否真的有效

绕过手法Payload 示例为什么危险防御
大小写/注释截断Id/**/DESC;SELECT...、UnIoN sElEcT黑名单关键字匹配漏网白名单;预编译
引号绕过(数字型)id=1 UNION SELECT ...(无引号)只转义/过滤单引号完全没用参数化不区分类型通道
编码绕过0x...、CHAR()、URL 双重编码过滤发生在解码前就被绕过框架自动解码后再校验 + #{}
LIKE 通配符滥用%、_ 扫全表不是注入但是拒绝服务业务侧转义 % _,必要时限流
报错/盲注extractvalue、sleep(3)、if(1=1,...)页面无回显也能拖数据参数化 + 关闭生产错误详情回显
二阶注入注册名 a';UPDATE...--,存储后在另一个 ${} 点触发入口校验了,出口拼接了全 Mapper 扫描,所有出口 #{}

特别强调二阶注入:攻击者的数据先被合法存入(写入时参数化没问题),却在另一个"动态查询/导出/报表"功能里被 ${} 拼回 SQL。排查方法不是盯着单个接口,而是对整个工程做静态扫描:

# 工程内全量搜 ${},逐个确认是否有白名单兜底(IDEA 也可直接全局搜)
grep -rn '\${' --include="*.xml" src/main/resources/mapper/

Mapper XML 里出现 ${} 的每一处都要能回答:"这个值的来源是不是服务端白名单?" 答不上来的,就是待修漏洞。

3.3 数据访问层的其他纪律

  1. 应用账号最小权限:业务账号不要给 FILE、DROP、跨库权限,即使注入得手也无法 INTO OUTFILE 写马或删表。
  2. 生产环境屏蔽 SQL 异常详情:MyBatis/JDBC 的原始异常(含表名、列名、SQL 片段)只能进日志,返回前端统一错误码,杜绝报错注入的信息源。
  3. 连接池层不可信:不要指望 WAF 或 Filter 替代参数化,WAF 是外围减速带,#{} 才是发动机里的安全带。

四、第二道防线(上):XSS 的输入侧——校验、过滤与 Wrapper 的正确用法

4.1 先纠正一个普遍误区

"防 XSS 就是在全局过滤器里把 < > 转义掉"——这是最常见的错误认知,它有两个问题:

  1. 污染存储:用户昵称为 AT&T,存库变成 AT&amp;T;这个数据还要给 App、小程序、消息推送、Excel 导出使用,每一层都得"猜"要不要再解码,双重转义后页面显示 AT&amp;amp;T。
  2. 防御位置错误:JSON 接口返回的 Content-Type: application/json 不会被浏览器当 HTML 解析,接口层面转义毫无意义;XSS 是否发生,取决于数据最终在什么上下文被渲染。

正确的输入侧策略是两句话:对输入做"校验"(长度、字符集、白名单),而不是无脑"转义存储";富文本等必须保留 HTML 的场景,用白名单清洗而不是转义。 真正一锤定音的防御在输出侧(第五章)。输入侧 Filter 的价值是给服务端渲染的老系统兜底、统一拦截明显畸形的输入。

4.2 一个覆盖全部入口的 XssFilter(传统表单 + JSON)

如果你们的系统存在 Thymeleaf/JSP 服务端渲染,或需要对输入做统一兜底清洗,可以用 Filter + Wrapper。关键是 HttpServletRequestWrapper 必须覆盖所有取参数的入口,并且 JSON body 只能读一次,必须缓存字节数组:

/**
 * XSS 请求包装:覆盖参数、请求头、JSON body 全部读取入口。
 * 注意:getInputStream 必须基于缓存的字节数组返回,不能多次读取原始流。
 */
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private final byte[] cachedBody;

    public XssHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        // 入口处一次性读完缓存,后续所有读取都基于它
        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        return value == null ? null : escape(value);
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] values = super.getParameterValues(name);
        if (values == null) {
            return null;
        }
        return Arrays.stream(values).map(this::escape).toArray(String[]::new);
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> raw = super.getParameterMap();
        Map<String, String[]> result = new LinkedHashMap<>(raw.size());
        raw.forEach((k, v) -> result.put(k,
                Arrays.stream(v).map(this::escape).toArray(String[]::new)));
        return result;
    }

    @Override
    public String getHeader(String name) {
        String value = super.getHeader(name);
        return value == null ? null : escape(value);
    }

    @Override
    public ServletInputStream getInputStream() {
        // 对 JSON body 做字符串级清洗(仅对 application/json 且需要兜底的接口开启)
        byte[] body = cachedBody;
        String contentType = getContentType();
        if (contentType != null && contentType.contains("application/json")) {
            String json = new String(cachedBody, StandardCharsets.UTF_8);
            // 仅清洗字符串值节点,避免把 JSON 结构字符转义坏(用 Jackson 树遍历更稳,见 4.3)
            json = XssJsonSanitizer.escapeJsonStringValues(json);
            body = json.getBytes(StandardCharsets.UTF_8);
        }
        final ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override public boolean isFinished() { return bais.available() == 0; }
            @Override public boolean isReady() { return true; }
            @Override public void setReadListener(ReadListener listener) { /* 容器异步用 */ }
            @Override public int read() { return bais.read(); }
        };
    }

    /** HTML 特殊字符转义:用 commons-text,不要手写 replace(容易漏 / 转错) */
    private String escape(String raw) {
        return StringEscapeUtils.escapeHtml4(raw);
    }
}

Filter 本体与注册(全量匹配 + 白名单排除,新增接口默认受保护):

public class XssFilter extends OncePerRequestFilter {

    private final AntPathMatcher pathMatcher = new AntPathMatcher();
    private final List<String> excludes;

    public XssFilter(List<String> excludePatterns) {
        this.excludes = excludePatterns;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String path = request.getRequestURI();
        boolean excluded = excludes.stream().anyMatch(p -> pathMatcher.match(p, path));
        // 文件上传(multipart)不要包装:会破坏二进制流;富文本接口走专用清洗器
        boolean multipart = StringUtils.startsWithIgnoreCase(request.getContentType(),
                MediaType.MULTIPART_FORM_DATA_VALUE);

        if (excluded || multipart) {
            chain.doFilter(request, response);
            return;
        }
        chain.doFilter(new XssHttpServletRequestWrapper(request), response);
    }
}
@Configuration
public class WebSecurityFilterConfig {

    @Bean
    public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
        FilterRegistrationBean<XssFilter> reg = new FilterRegistrationBean<>();
        reg.setFilter(new XssFilter(List.of(
                // 富文本内容接口(走 OWASP 白名单清洗,不能 HTML 转义)
                "/api/admin/articles/rich-content",
                // 文件上传
                "/api/files/upload"
        )));
        reg.addUrlPatterns("/*");
        reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);   // 早于业务过滤器
        reg.setName("xssFilter");
        return reg;
    }
}

这里有三个线上踩过的坑(都来自真实教训):

  1. JSON 流不能读两次:Wrapper 里 getInputStream() 直接 return super.getInputStream() 而 body 已被消费,下游 Jackson 会拿到空 body。必须缓存字节数组、始终基于缓存返回,并正确实现 isFinished。
  2. multipart 请求不要包装:对文件上传流做字符串转义会直接损坏二进制内容。
  3. 拦截响应必须统一契约、日志必须脱敏:Filter 里发现恶意输入后,写回项目统一的 JSON 错误结构(而不是抛裸异常出 500);日志记录攻击类型、URI、traceId,参数值做截断/哈希,避免把用户敏感数据打进日志。

4.3 富文本场景:黑名单必死,用白名单清洗

评论/文章正文需要保留 <b>、<img> 这类合法标签时,转义会让功能不可用,手写黑名单又永远绕不完(<svg/onload=>、<a href="javascript:...">、大小写、编码……)。标准解法是 OWASP Java HTML Sanitizer(或 jsoup 的 Safelist),只允许明确安全的标签和属性:

<!-- pom.xml -->
<dependency>
    <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
    <artifactId>owasp-java-html-sanitizer</artifactId>
    <version>20240325.1</version>
</dependency>
public class RichTextSanitizer {

    // 策略即白名单:只放行这些标签/属性,其余全部移除,事件属性、javascript: 协议天然被剥离
    private static final PolicyFactory POLICY = Sanitizers.FORMATTING
            .and(Sanitizers.BLOCKS)
            .and(Sanitizers.LINKS)
            .and(Sanitizers.IMAGES)                 // img 默认只允许 http/https src
            .and(Sanitizers.STYLES)
            .and(Sanitizers.TABLES);

    public static String clean(String dirtyHtml) {
        if (dirtyHtml == null) {
            return null;
        }
        return POLICY.sanitize(dirtyHtml);
    }
}
// 富文本接口在 Service 层显式清洗,不要依赖全局 Filter(已在白名单中排除)
article.setContent(RichTextSanitizer.clean(rawContent));

<script>alert(1)</script>、<img src=x onerror=...>、<a href="javascript:alert(1)"> 经过策略后会被整体移除标签或属性——因为安全策略基于 HTML 解析器的语法树工作,靠语法结构识别而不是正则,编码和变形绕过对它无效。

4.4 输入校验:Bean Validation 做第一道筛子

@Data
public class CommentCreateRequest {

    @NotBlank
    @Size(max = 500, message = "评论最多500字")        // 限制长度,封掉超长 payload
    private String content;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
    private String contactPhone;

    @Size(max = 30)
    @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_·.\\-]*$",
             message = "昵称仅支持中英文、数字及_·.-")
    private String nickname;
}

校验是"拒绝畸形输入",转义是"输出时让数据无法变成代码"——两件事不要互相替代。


五、第二道防线(下):XSS 的根治点是输出编码

5.1 为什么输出编码才是根治

XSS 的定义是"用户数据被浏览器当成了脚本执行"。数据从哪来、有没有过滤都不重要,重要的是输出到 HTML 文档的哪个位置。同一段数据 x onload=alert(1),放在不同上下文需要的编码完全不同:

输出上下文示例必须的编码方式
HTML 标签体${data}HTML 实体编码(< > & " ')
HTML 属性``HTML 属性编码 + 保证属性带引号
JavaScript 内var a = "${data}";JS 字符串编码(\xHH/\uHHHH),优先用 JSON 序列化
URL 参数``URL 编码(URLEncoder)
CSS...${data}...CSS 编码,业务上尽量避免

只做 HTML 实体编码,挡不住注入点在 JS 上下文里的 payload——这就是"输出按上下文编码"的含义。

5.2 各场景的落地写法

服务端模板(Thymeleaf,默认转义,别手动关):

<!-- ✓ th:text 默认对 HTML 上下文做转义,原样输出安全 -->
<div th:text="${comment.content}"></div>

<!-- ✓ th:utext = unescaped,只有内容已通过富文本白名单清洗才允许用 -->
<div th:utext="${article.sanitizedContent}"></div>

<!-- ✗ 内联 JS 里直接塞变量是高危写法,改用 th:inline=javascript 或 data 属性 -->
<script th:inline="javascript">
    var nickname = /*[[${user.nickname}]]*/ "";   // Thymeleaf 会做 JS 上下文编码
</script>

Spring MVC 返回 JSON(绝大多数前后端分离接口):Jackson 本身会正确转义 JSON 字符串,浏览器不会把 application/json 响应当文档渲染——此时 XSS 的责任边界在前端:React 的 {}、Vue 的 {{ }} 默认编码;唯一会穿的是前端用了 v-html / innerHTML / dangerouslySetInnerHTML 渲染未清洗内容。后端的责任是:富文本字段出库前保证已用白名单清洗(4.3),并在接口文档里标注"该字段为 HTML,渲染方必须信任来源"。

手动编码工具(非模板场景,如导出 HTML 报表、拼接邮件模板):

import org.springframework.web.util.HtmlUtils;

// HTML 标签体/属性上下文
String safe = HtmlUtils.htmlEscape(userInput);            // < → &lt;
String attrSafe = HtmlUtils.htmlEscape(value, "UTF-8");

// URL 参数上下文
String url = "/search?keyword=" + URLEncoder.encode(keyword, StandardCharsets.UTF_8);

// JS 上下文:用 JSON 序列化把数据变成合法的 JS 字面量
String jsLiteral = new ObjectMapper().writeValueAsString(userInput); // 含引号/换行/</script> 都安全

5.3 XSS 常见绕过手法与对应防御

绕过手法示例防御
大小写/无引号标签、不依赖正则黑名单,用解析器型清洗器
事件属性``属性白名单,剥离所有 on* 事件
伪协议``、data:text/htmlURL 协议白名单,只允许 http/https/相对路径
编码绕过HTML 实体、\x61lert、UTF-7输出时按上下文编码,解码统一在框架层完成
前端危险 APIv-html、innerHTML、eval代码扫描禁用;必须用时渲染清洗后的内容
DOM 型 XSSlocation.hash 直接写入 DOM前端对 location/document.referrer 等来源同样编码
JSON 内容劫持上传 .html 文件名诱导访问上传目录响应头 X-Content-Type-Options: nosniff + Content-Disposition

5.4 响应头加固:低成本的第二保险

@Configuration
public class SecurityHeaderConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                        // CSP:禁止内联脚本/第三方源,XSS 真执行了也会被浏览器拦下
                        "default-src 'self'; script-src 'self'; object-src 'none'; "
                      + "frame-ancestors 'none'; base-uri 'self'"))
                .contentTypeOptions(HeadersConfigurer.ContentTypeOptionsConfig::disable) // nosniff 默认已开,此处仅示意
                .frameOptions(fo -> fo.deny()));
        return http.build();
    }
}

Content-Security-Policy 是 XSS 的"最后一道安全网":即便输出编码漏了某处,注入的脚本因为违反 CSP(禁止内联、禁止外域脚本源)也无法执行。X-Content-Type-Options: nosniff 防止上传文件被浏览器嗅探成 HTML 执行。响应头不替代编码,但把纵深防御补齐了。


六、第三道防线(上):CSRF 原理与 Spring Security token 机制

6.1 CSRF 攻击是怎么发生的

CSRF(跨站请求伪造)利用的是浏览器发送请求时自动携带目标站点 Cookie这个机制。运营人员登录了后台(Session Cookie 已种下),又访问了攻击者的页面:

<!-- 攻击者网站 evil.com 上的页面 -->
<form action="https://admin.your.com/api/audit/transfer" method="POST">
    <input type="hidden" name="toAccount" value="ATTACKER">
    <input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>

表单提交不受同源策略限制,Cookie 自动带上,服务端只凭 Cookie 识别身份——它无法区分这个请求是用户自愿发起的,还是在别的网站被诱导发起的。攻击成立的三个条件:凭证自动携带(Cookie/Basic Auth)、目标接口有副作用、请求参数能被攻击者预测。

6.2 Spring Security 的防御模型:同步器 token

Spring Security 的思路是:除了 Cookie 里的会话,再要求一个攻击者无法跨站读取的随机 token。Cookie 能自动带,但 token 放在 DOM/响应头里,跨站页面读不到(同源策略),自然伪造不出来。

工作流程:

1. 客户端 GET 页面/接口 → 服务端生成 CSRF token,存入会话(或 Cookie,见6.3)
   并通过页面隐藏字段 / 响应头 XSRF-TOKEN 返回给前端
2. 前端发起 POST/PUT/PATCH/DELETE 时,把 token 放进请求头 X-XSRF-TOKEN 或参数 _csrf
3. CsrfFilter 拦截有副作用的请求,比对请求中的 token 与会话中的是否一致
   一致 → 放行;不一致/缺失 → 403
4. GET/HEAD/OPTIONS/TRACE 不校验(它们必须无副作用,这是前提纪律)

6.3 完整配置(Spring Boot 3.x + Spring Security 6 Lambda DSL)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/public/**").permitAll()
                .anyRequest().authenticated())
            // ① 启用 CSRF(默认即启用,这里显式配置仓库和前后端分离需要的请求头模式)
            .csrf(csrf -> csrf
                // token 存到名为 XSRF-TOKEN 的 Cookie,前端 JS 读取后放到 X-XSRF-TOKEN 请求头
                // withHttpOnlyFalse:允许 JS 读取(双重提交 Cookie 模式,见 6.4)
                .csrfTokenRepository(cookieTokenRepository())
                // ② BREACH 攻击防护:每次响应生成随机化 token,配合 SPA
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
            // ③ SameSite Cookie:现代浏览器的核心防线,下一节细讲
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));

        return http.build();
    }

    private CookieCsrfTokenRepository cookieTokenRepository() {
        CookieCsrfTokenRepository repo = CookieCsrfTokenRepository.withHttpOnlyFalse();
        repo.setCookieCustomizer(cookie -> cookie
                .sameSite("Lax")          // CSRF Cookie 本身也限制跨站
                .secure(true)              // 仅 HTTPS
                .httpOnly(false)           // 前端需要读取
                .path("/"));
        return repo;
    }

    // ④ 会话 Cookie 的 SameSite(Boot 2.6+ 也可直接配置,见下)
    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> cookieProcessor() {
        return factory -> factory.addContextCustomizers(context -> context.setCookieProcessor(
                new Rfc6265CookieProcessor() {{
                    setSameSiteCookies("Lax");
                }}));
    }
}

Spring Boot 2.6+ 更简单的做法是直接在配置文件里约束会话 Cookie:

server:
  servlet:
    session:
      cookie:
        same-site: lax          # ★ 现代浏览器防 CSRF 的关键属性
        http-only: true
        secure: true            # 仅 HTTPS 环境
        name: SESSION

前端配合(Axios 拦截器,从 Cookie 取 token 放到请求头):

import Cookies from 'js-cookie'
import axios from 'axios'

axios.interceptors.request.use(config => {
  const token = Cookies.get('XSRF-TOKEN')
  if (token) {
    config.headers['X-XSRF-TOKEN'] = token
  }
  return config
})

6.4 SameSite Cookie:浏览器原生的 CSRF 防线

SameSite 是浏览器层面的机制,控制 Cookie 在跨站请求中是否自动携带,三种策略:

值行为适用
Strict任何跨站请求都不带 Cookie,从外站链接点进来也要重新登录银行/支付等强安全后台
Lax(Chrome 默认)跨站只在"顶层导航 + 安全方法(GET)"时带,跨站 POST/iframe/AJAX 一律不带绝大多数系统的推荐值
None都带,但必须同时 Secure确实需要的第三方嵌入场景

把会话 Cookie 设为 SameSite=Lax 后,6.1 节那个跨站表单 POST 浏览器根本不会带上会话 Cookie,攻击直接失败——这是比 token 更省心的防线,token 和 SameSite 应该同时上(浏览器兼容性纵深)。注意 Lax 下顶层 GET 导航仍会带 Cookie,所以"GET 接口绝对不能有副作用"是硬纪律,删除/转账接口必须 POST。

6.5 CSRF 绕过手法与防御

绕过手法原理防御
用 GET 实施攻击`` 连表单都不用写操作一律 POST/PUT/DELETE;GET 零副作用
JSON CSRF尝试跨站 fetch 发 JSON简单请求无法自定义 Content-Type: application/json,会触发预检被 CORS 拦截;CORS 不要配置 * + allowCredentials
token 放在 HttpOnly Cookie 里自动提交等于没有 tokentoken 走请求头/表单域,不能只依赖自动携带的 Cookie
Flash/旧浏览器插件历史上可自定义请求头现代浏览器已无此问题;内网老浏览器环境仍需 token
子域 Cookie 注入攻击者控制同站子域写 Cookie子域名安全治理,敏感操作二次验证
登录 CSRF攻击者把自己的 token 种给受害者登录成功后重新生成 session/token
BREACH 压缩攻击从压缩响应里猜 tokenCsrfTokenRequestAttributeHandler 的随机化 token(6.3 已配)

七、第三道防线(下):前后端分离 + JWT 到底要不要防 CSRF

这是被问得最多的问题,答案取决于你的 token 放在哪里、浏览器是否会自动携带:

认证方式浏览器自动携带?CSRF 风险结论
JWT 存 localStorage,JS 手动塞 Authorization 头否无(跨站读不到 localStorage 也无法自动带头)可禁用 CSRF 防护,但要防 XSS(XSS 会偷 token)
JWT/Session 存 Cookie(含 HttpOnly)是有必须 CSRF token + SameSite
同源前端 + Cookie 会话是有完整启用 Spring Security CSRF
纯接口给 App/服务间调用(无浏览器)无浏览器场景无可禁用,靠网关/签名鉴权

第一种场景下可以这样安全地关闭 CSRF(必须确认没有任何凭证依赖 Cookie 自动携带):

@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))  // 无状态 API:凭证走 Authorization 头
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(a -> a.anyRequest().authenticated());
    return http;
}

注意这是一次有意识的风险取舍:你用"XSS 可以偷 localStorage 里的 token"换掉了"CSRF 风险"。所以选这个方案的团队必须把第五章的 XSS 防御和 CSP 做到位;对安全要求高的系统,HttpOnly Cookie + CSRF token 的组合整体风险更低(JS 偷不到 Cookie)。

双重提交 Cookie 模式的原理

Spring Security 的 CookieCsrfTokenRepository 用的就是双重提交 Cookie:token 不需要服务端存会话,Cookie 放一份、请求头放一份,服务端只比对两者是否相同。它成立的前提是攻击者能让浏览器发 Cookie,却读不到 Cookie 内容(同源策略),所以无法在请求头里伪造出相同的值。Cookie 必须 SameSite=Lax、敏感操作配合 Secure;前端用 JS 读取要求 HttpOnly=false——这也是代码里 withHttpOnlyFalse() 的由来。


八、上线验收清单与常见误区

8.1 上线前逐项验收

SQL 注入

  •  工程内全局搜索 ${},每处都有服务端白名单来源,无一处直接来自用户输入
  •  LIKE 用 CONCAT,IN/批量用 <foreach>,动态表名由服务端规则生成
  •  用 sqlmap 或手工 payload 对所有带参数的查询接口(尤其排序、导出、报表)跑一遍
  •  数据库账号最小权限,生产响应不含原始 SQL 异常
  •  代码扫描(如 SonarQube/SpotBugs 规则)纳入 CI,新增 ${} 触发评审

XSS

  •  所有模板输出走默认转义,utext/v-html 类调用逐个人工确认
  •  富文本字段经过 OWASP/jsoup 白名单清洗,有单测覆盖危险 payload
  •  输出场景按上下文编码(HTML/属性/JS/URL 分开处理)
  •  响应头 CSP、X-Content-Type-Options: nosniff、X-Frame-Options 已配置
  •  上传文件不能被浏览器当 HTML 嗅探执行
  •  输入校验长度/字符集已覆盖主要 UGC 字段

CSRF

  •  写操作全部使用 POST/PUT/PATCH/DELETE,GET 接口零副作用(删数据/改状态的 GET 立刻改)
  •  会话 Cookie 已设 SameSite=Lax(或 Strict)、HttpOnly、Secure
  •  Cookie 模式认证的系统 CSRF token 全量启用,前端请求头携带验证通过
  •  无状态 API 已确认凭证不依赖 Cookie 自动携带,CORS 未配置 * + credentials
  •  登录接口防登录 CSRF,登出接口也走 POST(防止被跨站伪造登出)

8.2 三个常见误区

  1. "加个全局 WAF/Filter 拦截 '、union、<script> 就安全了"——黑名单永远落后于变形,且误杀正常数据(O'Reilly、文章里写技术术语的用户)。参数化、输出编码、token 才是根治,过滤器只能做高置信特征的外围告警。
  2. "内网系统不用做这些"——社工钓鱼一封邮件就能让内网员工打开攻击页面,CSRF/存储型 XSS 在内网横向移动中利用率极高;等保和渗透测试不会因为是内网就放过。
  3. "框架默认开了就万事大吉"——Spring Security 默认开 CSRF,但很多团队为了前后端分离图省事直接 .csrf().disable(),又同时用 Cookie 存 token,等于自拆防线;Thymeleaf 默认转义,一个 utext 就能开洞。默认值要理解,关闭要留痕、要评审。

九、常见问题

9.1 我们是纯 JSON 接口,XSS Filter 还有必要加吗?

没必要对 JSON body 做 HTML 转义存储(会污染数据,且 application/json 不被浏览器当文档解析)。你需要的是:① 输入侧做 @Valid 长度/字符校验;② 富文本字段用白名单清洗;③ 和前端约定 v-html/innerHTML 的使用规范;④ CSP 响应头兜底。XssFilter 主要服务于服务端渲染的老系统和 multipart 之外的表单接口,不要为了"看起来安全"在 JSON API 全局转义。

9.2 ${} 是不是绝对不能用?白名单后真的安全吗?

不是。ORDER BY、动态表名/列名、排序方向这些"SQL 标识符"位置无法参数化,只能用 ${},前提是值完全来自服务端受控集合(枚举/Map/强类型格式化),用户输入最多只能选择集合的 key。此时 ${} 展开的内容攻击者无法施加任何影响,安全等价于常量。红线只有一条:${} 的值不能直接或间接来自用户。

9.3 CSRF token 和 JWT 是不是二选一?防重放呢?

不是同一层面的东西:JWT 解决"你是谁"(认证),CSRF token 解决"这个请求是不是你本人在本站主动发起的"(意图)。JWT 放 localStorage + Authorization 头时不需要 CSRF token(浏览器不自动携带凭证);JWT 放 Cookie 时两者都要。另外 CSRF token 不防重放(同一 token 在有效期内可重复提交),防重放/防重复提交要用一次性 nonce、幂等键或时间戳签名,可参考之前的接口签名与幂等方案。

9.4 为什么配了 CSRF,POST 接口全部 403?

按顺序排查:① GET 页面/接口的响应里是否真的下发了 XSRF-TOKEN Cookie(首次请求前 CsrfFilter 还没生成 token,需要先访问带 token 的端点,或服务端启动时预置);② 前端是否从 Cookie 取出并放到了 X-XSRF-TOKEN 请求头(注意 Spring Security 默认头名是 X-CSRF-TOKEN,Cookie 模式才是 X-XSRF-TOKEN,别搞混);③ 请求是否被 CORS/网关把自定义头拦掉;④ multipart/form-data 表单要把 token 放在表单字段 _csrf 或 query 参数中(过滤器读取时机早于 multipart 解析)。

9.5 SameSite=Lax 就够了,为什么还要 CSRF token?

纵深防御和兼容性:① 老版本浏览器(Chrome 80 之前、部分内嵌 WebView)不识别 SameSite,默认行为是 None;② Lax 对顶层 GET 导航放行,一旦有开发把写操作写成 GET 就有缺口;③ SameSite 是浏览器属性,用户浏览器环境不完全受控。token 是应用层校验,不依赖浏览器。两者成本都很低,没有理由只选一个。

9.6 渗透测试/攻防演练前,怎么快速自检?

  • SQL:拿 '、")、1=1、union select、sleep(3)` 对每个入参(含 path、header、排序字段)打一遍,看响应差异和耗时;sqlmap 跑高风险接口。
  • XSS:在所有输入点埋 <script>alert(1)</script>、"><img src=x onerror=alert(1)>、<svg/onload=alert(1)> 三种变体,回显页面/富文本渲染处看是否执行;浏览器 CSP 违规报告也是抓手。
  • CSRF:用另一个端口/域名写个最小 HTML 表单 POST 到写接口,已登录状态下提交,成功执行就是漏洞;再验证 SameSite 与 token 后应返回 403/401。

十、总结

三道防线速查卡

【SQL 注入】数据访问层
  根治:全部 #{}(PreparedStatement,代码与数据分通道)
  ${} 仅限标识符位置:ORDER BY / 动态表名 / ASC|DESC
       → 值必须来自服务端白名单(Map/枚举/强类型)
  LIKE → CONCAT('%', #{x}, '%');IN/批量 → foreach 占位符
  配套:最小权限账号、生产不回显SQL异常、CI 扫描 ${}、二阶注入全链路排查

【XSS】输出渲染层(输入校验为辅)
  输入:@Valid 长度/字符集白名单;不要全局转义存储污染数据
  富文本:OWASP HTML Sanitizer / jsoup Safelist 标签白名单(解析器型,非正则)
  输出:按上下文编码 —— HTML实体 / 属性带引号 / JS用JSON序列化 / URL编码
  模板:th:text 默认转义;utext/v-html/innerHTML 逐个人工确认
  兜底:CSP(禁内联脚本+外域源)、nosniff、X-Frame-Options

【CSRF】请求意图校验层
  根源:Cookie 自动携带,网站分不清"自愿"与"被诱导"
  Spring Security:CsrfFilter + CookieCsrfTokenRepository
       前端 XSRF-TOKEN Cookie → X-XSRF-TOKEN 请求头
  Cookie:SameSite=Lax(支付后台可 Strict)+ HttpOnly + Secure
  纪律:GET 零副作用;CORS 禁止 * + credentials;登出也用 POST
  豁免:纯无状态 API + Authorization 头(凭证不自动携带)可关 CSRF,
        但 XSS 防御必须到位(localStorage 会被 XSS 窃取)

给团队的建议

阶段动作
编码Mapper 强制 #{},${} 必须 CR 注释白名单来源;模板默认转义不手动关
接口设计写操作全 POST+;敏感操作二次确认;统一错误响应不回显堆栈/SQL
配置SameSite=Lax + HttpOnly + Secure 写进基线配置;CSP 全站响应头
CI静态扫描 ${}、utext/v-html、.csrf(disable) 三处关键模式
测试单测覆盖富文本清洗 payload;渗透前按 9.6 自检三件套过一遍
架构富文本清洗器、CSRF 配置、安全响应头做成公共 starter,业务线默认继承

一句话

SQL 注入、XSS、CSRF 之所以要分成三道防线,是因为它们滥用的信任完全不同:注入是数据库把用户数据当成了指令,根治于 Mapper 层的 #{} 预编译,${} 只允许出现在 ORDER BY/表名这类标识符位置且值必须来自服务端白名单,LIKE 用 CONCAT、IN 用 foreach,再配合最小权限和 CI 扫描,黑名单正则永远只是外围;XSS 是浏览器把用户数据当成了脚本,根治点不在输入而在输出——输入侧用 @Valid 做长度与字符集校验、富文本用 OWASP/jsoup 的白名单清洗(解析器型策略免疫变形绕过),输出时严格按 HTML 体、属性、JS、URL 四个上下文分别编码,再用 CSP 和 nosniff 兜底,切忌全局转义存储污染数据;CSRF 是攻击者借浏览器自动携带 Cookie 的机制冒用你的身份,防御上 Spring Security 的同步器 token(XSRF-TOKEN Cookie 换 X-XSRF-TOKEN 请求头,攻击者跨站读不到)与 SameSite=Lax Cookie 必须同时上线,GET 接口零副作用、CORS 不开星号是配套纪律,只有凭证完全走 Authorization 头、浏览器不自动携带的纯无状态 API 才有资格关闭 CSRF——而那意味着你必须把 XSS 防得更死。安全加固从来不是堆过滤器,而是让每一层只解决自己那一层的问题:数据访问层管参数化、渲染层管编码、框架层管意图校验,再用响应头、最小权限和 CI 扫描把纵深补齐。

互动话题:你们的系统在渗透测试里最常被报的是哪一类?有没有遇到过"为了前后端分离直接 csrf disable,结果 token 又放在 Cookie 里"的裸奔组合,或者 ${} 藏在报表/导出功能里的二阶注入?评论区聊聊你的实战经历。


参考资料


标题:Spring Boot 接口安全加固:防 SQL 注入 + XSS + CSRF 的三道防线
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/29/1790518032455.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消