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 注入 | XSS | CSRF |
|---|---|---|---|
| 攻击目标 | 数据库 | 其他用户的浏览器 | 不知情的已登录用户 |
| 注入物 | 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 数据访问层的其他纪律
- 应用账号最小权限:业务账号不要给
FILE、DROP、跨库权限,即使注入得手也无法INTO OUTFILE写马或删表。 - 生产环境屏蔽 SQL 异常详情:MyBatis/JDBC 的原始异常(含表名、列名、SQL 片段)只能进日志,返回前端统一错误码,杜绝报错注入的信息源。
- 连接池层不可信:不要指望 WAF 或 Filter 替代参数化,WAF 是外围减速带,
#{}才是发动机里的安全带。
四、第二道防线(上):XSS 的输入侧——校验、过滤与 Wrapper 的正确用法
4.1 先纠正一个普遍误区
"防 XSS 就是在全局过滤器里把 < > 转义掉"——这是最常见的错误认知,它有两个问题:
- 污染存储:用户昵称为
AT&T,存库变成AT&T;这个数据还要给 App、小程序、消息推送、Excel 导出使用,每一层都得"猜"要不要再解码,双重转义后页面显示AT&amp;T。 - 防御位置错误: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;
}
}
这里有三个线上踩过的坑(都来自真实教训):
- JSON 流不能读两次:Wrapper 里
getInputStream()直接return super.getInputStream()而 body 已被消费,下游 Jackson 会拿到空 body。必须缓存字节数组、始终基于缓存返回,并正确实现isFinished。 - multipart 请求不要包装:对文件上传流做字符串转义会直接损坏二进制内容。
- 拦截响应必须统一契约、日志必须脱敏: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); // < → <
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/html | URL 协议白名单,只允许 http/https/相对路径 |
| 编码绕过 | HTML 实体、\x61lert、UTF-7 | 输出时按上下文编码,解码统一在框架层完成 |
| 前端危险 API | v-html、innerHTML、eval | 代码扫描禁用;必须用时渲染清洗后的内容 |
| DOM 型 XSS | location.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 里自动提交 | 等于没有 token | token 走请求头/表单域,不能只依赖自动携带的 Cookie |
| Flash/旧浏览器插件 | 历史上可自定义请求头 | 现代浏览器已无此问题;内网老浏览器环境仍需 token |
| 子域 Cookie 注入 | 攻击者控制同站子域写 Cookie | 子域名安全治理,敏感操作二次验证 |
| 登录 CSRF | 攻击者把自己的 token 种给受害者 | 登录成功后重新生成 session/token |
| BREACH 压缩攻击 | 从压缩响应里猜 token | CsrfTokenRequestAttributeHandler 的随机化 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 三个常见误区
- "加个全局 WAF/Filter 拦截
'、union、<script>就安全了"——黑名单永远落后于变形,且误杀正常数据(O'Reilly、文章里写技术术语的用户)。参数化、输出编码、token 才是根治,过滤器只能做高置信特征的外围告警。 - "内网系统不用做这些"——社工钓鱼一封邮件就能让内网员工打开攻击页面,CSRF/存储型 XSS 在内网横向移动中利用率极高;等保和渗透测试不会因为是内网就放过。
- "框架默认开了就万事大吉"——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 里"的裸奔组合,或者
${}藏在报表/导出功能里的二阶注入?评论区聊聊你的实战经历。
参考资料
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Cross Site Scripting Prevention Cheat Sheet(按上下文输出编码)
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet(Synchronizer / SameSite)
- Spring Security 官方文档:CSRF Protection(6.x Lambda DSL)
- OWASP Java HTML Sanitizer(GitHub)
- MyBatis 官方文档:字符串替换与参数(
${}与#{}) - MDN:Set-Cookie 的 SameSite 选项
- Content Security Policy (CSP) 参考 - MDN
标题:Spring Boot 接口安全加固:防 SQL 注入 + XSS + CSRF 的三道防线
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/29/1790518032455.html
公众号:服务端技术精选
- 引言
- 一、全景:先分清三种攻击在打什么
- 二、第一道防线(上):SQL 注入——#{} 预编译到底防住了什么
- 2.1 漏洞代码长什么样
- 2.2 #{} 为什么能防注入
- 2.3 Service 层的参数校验(白名单而非黑名单)
- 三、第一道防线(下):${} 高危场景与注入绕过手法
- 3.1 四个绕不开 ${} 的场景及标准解法
- 3.2 常见绕过手法:检验你的防御是否真的有效
- 3.3 数据访问层的其他纪律
- 四、第二道防线(上):XSS 的输入侧——校验、过滤与 Wrapper 的正确用法
- 4.1 先纠正一个普遍误区
- 4.2 一个覆盖全部入口的 XssFilter(传统表单 + JSON)
- 4.3 富文本场景:黑名单必死,用白名单清洗
- 4.4 输入校验:Bean Validation 做第一道筛子
- 五、第二道防线(下):XSS 的根治点是输出编码
- 5.1 为什么输出编码才是根治
- 5.2 各场景的落地写法
- 5.3 XSS 常见绕过手法与对应防御
- 5.4 响应头加固:低成本的第二保险
- 六、第三道防线(上):CSRF 原理与 Spring Security token 机制
- 6.1 CSRF 攻击是怎么发生的
- 6.2 Spring Security 的防御模型:同步器 token
- 6.3 完整配置(Spring Boot 3.x + Spring Security 6 Lambda DSL)
- 6.4 SameSite Cookie:浏览器原生的 CSRF 防线
- 6.5 CSRF 绕过手法与防御
- 七、第三道防线(下):前后端分离 + JWT 到底要不要防 CSRF
- 双重提交 Cookie 模式的原理
- 八、上线验收清单与常见误区
- 8.1 上线前逐项验收
- 8.2 三个常见误区
- 九、常见问题
- 9.1 我们是纯 JSON 接口,XSS Filter 还有必要加吗?
- 9.2 ${} 是不是绝对不能用?白名单后真的安全吗?
- 9.3 CSRF token 和 JWT 是不是二选一?防重放呢?
- 9.4 为什么配了 CSRF,POST 接口全部 403?
- 9.5 SameSite=Lax 就够了,为什么还要 CSRF token?
- 9.6 渗透测试/攻防演练前,怎么快速自检?
- 十、总结
- 三道防线速查卡
- 给团队的建议
- 一句话
- 参考资料
评论