Spring Cloud Gateway 防 XSS / 防 CSRF 实现
目标:在网关层统一做 XSS 清洗与 CSRF 来源校验,业务服务不必各自实现。
摘要
| 攻击 | 破坏什么 | 网关手段 | 过滤器 |
|---|---|---|---|
| XSS(跨站脚本) | 恶意脚本在别人的浏览器里执行:盗取凭证、冒充用户操作、篡改页面 | 统一清洗请求中的脚本片段:GET 清 query、POST/PUT 清 JSON/表单 body | XssRequestGlobalFilter(order = -89) |
| CSRF(跨站请求伪造) | 借用户已登录的身份,发起用户不知情的请求:改密码、转账、加管理员 | 校验 Host / Origin / Referer 是否在白名单内 |
CsrfRequestGlobalFilter(order = -88) |
两者都依赖 Spring Cloud Gateway 的 GlobalFilter 实现,配套一个必须先执行 的 CacheBodyGlobalFilter(order = HIGHEST_PRECEDENCE),它把请求体缓存成"可重复读",否则 XSS 过滤器重写 body 时会报"请求体已被订阅"。
三点必须记住:
- 网关清洗不是万能药 :正则是黑名单,能被绕过;真正的防线是输出转义 (前端渲染)+ 参数化查询 + CSP。网关这层是统一兜底。
- 富文本接口必须进忽略清单:清洗会删掉标签只留文本,公告、表单说明这类字段会被"清干净"。
- CSRF 白名单为空 = 不校验 ,但此时若请求既无
Host又无Referer,仍会被拒。上线前务必把各站点的Host、以及服务间调用的 Host 都加进白名单。
一、两种攻击是什么,破坏什么
1.1 XSS(Cross Site Scripting,跨站脚本攻击)
是什么 :把可执行的脚本注入到"会被别人看到的"数据里,当其他用户浏览到这段数据时,脚本在他们的浏览器 中以当前站点的身份执行。
| 类型 | 注入位置 | 触发方式 |
|---|---|---|
| 存储型 | 入库(评论、公告、表单字段) | 任何查看该数据的人都中招,危害最大 |
| 反射型 | URL 参数 / 表单 | 需要诱导受害者点击构造好的链接 |
| DOM 型 | 前端 JS 自身拼接 | 服务端甚至看不到恶意内容 |
破坏什么:
- 盗取登录凭证 :读
document.cookie、localStorage里的 token,直接接管账号; - 冒充用户操作:在用户不知情的情况下发起转账、改密码、加管理员(借用户的 Cookie/token 过鉴权);
- 篡改页面内容:伪造转账账号、伪造通知,做钓鱼;
- 键盘记录 / 挂马 / 蠕虫:植入记录器、跳转到恶意站,甚至自动转发链接扩散;
- 突破边界:配合 CSRF 或接口越权,从"一个 XSS 点"扩大到"整个系统"。
本类系统的注入点(前后端分离 + 网关):
- URL 查询参数(GET);
- 请求体里的文本字段(
application/json)、表单字段(application/x-www-form-urlencoded); - 文件名、文件内容(后者不在网关处理范围,需要存储与展示端各自转义)。
1.2 CSRF(Cross Site Request Forgery,跨站请求伪造)
是什么 :攻击者构造一个页面/链接,诱导已登录的用户去访问,利用"浏览器会自动为同域请求带上凭证"这一机制,让目标站以为是用户本人在操作。
两个前提(缺一不可):
- 目标站靠浏览器自动携带的凭证识别身份(如 Cookie / Session);
- 浏览器确实会自动带上。
值得注意:如果鉴权走的是请求头里的
Bearer token(而不是 Cookie),攻击者的站点默认拿不到这个头(浏览器不会自动加),CSRF 的天然风险就低很多。本方案依然做Host / Origin / Referer白名单校验,属于加固:防的是"被第三方站点/脚本冒用来源访问"、"借用用户浏览器环境发起的越权调用"以及"Host 头欺骗"。
破坏什么:
- 以用户身份执行有副作用的操作:改密码、改手机号、转账、下单、授权他人;
- 越权数据变更:把用户自己的数据改坏、把权限改大;
- 配合 XSS / 弱鉴权,放大为账号接管。
1.3 常规防护手段对照
| 攻击 | 常见防护 | 本方案采用 |
|---|---|---|
| XSS | 输入过滤、输出转义、CSP、HttpOnly Cookie、富文本白名单 | 网关统一输入清洗(正则黑名单 + HTML 标签过滤) |
| CSRF | CSRF Token、SameSite Cookie、校验 Origin/Referer、二次确认、CORS 白名单 |
网关统一校验 Host/Origin/Referer 白名单 |
二、整体设计
2.1 处理链路
#mermaid-svg-f2KA3XFs5559K5zs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-f2KA3XFs5559K5zs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-f2KA3XFs5559K5zs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-f2KA3XFs5559K5zs .error-icon{fill:#552222;}#mermaid-svg-f2KA3XFs5559K5zs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-f2KA3XFs5559K5zs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-f2KA3XFs5559K5zs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-f2KA3XFs5559K5zs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-f2KA3XFs5559K5zs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-f2KA3XFs5559K5zs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-f2KA3XFs5559K5zs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-f2KA3XFs5559K5zs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-f2KA3XFs5559K5zs .marker.cross{stroke:#333333;}#mermaid-svg-f2KA3XFs5559K5zs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-f2KA3XFs5559K5zs p{margin:0;}#mermaid-svg-f2KA3XFs5559K5zs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-f2KA3XFs5559K5zs .cluster-label text{fill:#333;}#mermaid-svg-f2KA3XFs5559K5zs .cluster-label span{color:#333;}#mermaid-svg-f2KA3XFs5559K5zs .cluster-label span p{background-color:transparent;}#mermaid-svg-f2KA3XFs5559K5zs .label text,#mermaid-svg-f2KA3XFs5559K5zs span{fill:#333;color:#333;}#mermaid-svg-f2KA3XFs5559K5zs .node rect,#mermaid-svg-f2KA3XFs5559K5zs .node circle,#mermaid-svg-f2KA3XFs5559K5zs .node ellipse,#mermaid-svg-f2KA3XFs5559K5zs .node polygon,#mermaid-svg-f2KA3XFs5559K5zs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-f2KA3XFs5559K5zs .rough-node .label text,#mermaid-svg-f2KA3XFs5559K5zs .node .label text,#mermaid-svg-f2KA3XFs5559K5zs .image-shape .label,#mermaid-svg-f2KA3XFs5559K5zs .icon-shape .label{text-anchor:middle;}#mermaid-svg-f2KA3XFs5559K5zs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-f2KA3XFs5559K5zs .rough-node .label,#mermaid-svg-f2KA3XFs5559K5zs .node .label,#mermaid-svg-f2KA3XFs5559K5zs .image-shape .label,#mermaid-svg-f2KA3XFs5559K5zs .icon-shape .label{text-align:center;}#mermaid-svg-f2KA3XFs5559K5zs .node.clickable{cursor:pointer;}#mermaid-svg-f2KA3XFs5559K5zs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-f2KA3XFs5559K5zs .arrowheadPath{fill:#333333;}#mermaid-svg-f2KA3XFs5559K5zs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-f2KA3XFs5559K5zs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-f2KA3XFs5559K5zs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f2KA3XFs5559K5zs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-f2KA3XFs5559K5zs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f2KA3XFs5559K5zs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-f2KA3XFs5559K5zs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-f2KA3XFs5559K5zs .cluster text{fill:#333;}#mermaid-svg-f2KA3XFs5559K5zs .cluster span{color:#333;}#mermaid-svg-f2KA3XFs5559K5zs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-f2KA3XFs5559K5zs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-f2KA3XFs5559K5zs rect.text{fill:none;stroke-width:0;}#mermaid-svg-f2KA3XFs5559K5zs .icon-shape,#mermaid-svg-f2KA3XFs5559K5zs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f2KA3XFs5559K5zs .icon-shape p,#mermaid-svg-f2KA3XFs5559K5zs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-f2KA3XFs5559K5zs .icon-shape .label rect,#mermaid-svg-f2KA3XFs5559K5zs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f2KA3XFs5559K5zs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-f2KA3XFs5559K5zs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-f2KA3XFs5559K5zs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
客户端请求
CacheBodyGlobalFilter
order 最高:缓存 body
XSS 忽略清单命中?
CSRF 忽略清单命中?
XssRequestGlobalFilter
order -89:清洗 query / body
鉴权 / 限流 / 路由
CsrfRequestGlobalFilter
order -88:校验来源白名单
业务服务
2.2 过滤器顺序(关键)
Spring Cloud Gateway 的 GlobalFilter 按 getOrder() 从小到大执行,值越小越先执行。
| 过滤器 | Order | 为什么必须是这个位置 |
|---|---|---|
CacheBodyGlobalFilter |
Ordered.HIGHEST_PRECEDENCE(Integer.MIN_VALUE) |
必须最先:请求体只能被订阅一次,先在它这里缓存,后续过滤器才能重复读取 |
XssRequestGlobalFilter |
-89 |
在 body 缓存之后;在鉴权/路由之前(尽早拦掉恶意内容) |
CsrfRequestGlobalFilter |
-88 |
紧跟 XSS 之后即可,二者无强依赖 |
| 其它过滤器(认证、限流、日志、埋点...) | 按需(一般 < -88) |
认证类通常更靠前 |
2.3 用到的技术手段
| 手段 | 说明 |
|---|---|
GlobalFilter + Ordered |
全局过滤器,无需在路由里逐个配置 |
ServerHttpRequestDecorator |
装饰器模式,重写 getBody() / getHeaders(),把清洗后的内容交给下游 |
DataBufferUtils.join |
把分片的 Flux<DataBuffer> 合并成一个缓冲区(Reactor 场景下安全读取 body 的正确方式) |
| 正则黑名单 + HTML 标签过滤 | 两层清洗:先删危险片段,再按标签白名单剥离 HTML |
| 忽略清单(正则 / Ant 通配) | 让上传、富文本等接口豁免 |
@ConfigurationProperties + @RefreshScope |
白名单可放配置中心,改完免重启生效 |
三、整合步骤
Step 1:确认依赖
只要网关工程已有 spring-cloud-starter-gateway 即可,无额外依赖 (代码里用到的 ServerHttpRequestDecorator、DataBufferUtils 都由 Gateway 带入)。可选 Lombok。
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
Step 2:加入工具类(3 个)
com/example/gateway/utils/
├── XssCleanRuleUtils.java # XSS 清洗规则(正则黑名单 + HTML 过滤)
├── EscapeUtil.java # 转义/反转义 + clean()(内部调用 HTMLFilter)
└── HTMLFilter.java # 基于标签白名单的 HTML 过滤器
XssCleanRuleUtils 与 EscapeUtil 见 §4.3、§4.4。HTMLFilter 代码较长,直接用成熟开源实现即可 (例如 JFinal 的 HTMLFilter,或 Apache 系文本过滤工具),约定:提供 public String filter(String html),效果是"按白名单保留允许的标签、其余标签连同属性一并去掉"。
若不想引入这个复杂度,可先只用
XssCleanRuleUtils的正则清洗(去掉EscapeUtil.clean调用),属于"够用但更弱"的方案。
Step 3:加入配置类
只需一个类(替代原来分散的 SecurityProperties + PermitProperties),见 §4.5。
Step 4:加入三个过滤器
com/example/gateway/filter/
├── CacheBodyGlobalFilter.java # 让 body 可重复读(必须先执行)
├── XssRequestGlobalFilter.java # XSS 清洗
└── CsrfRequestGlobalFilter.java # CSRF 来源校验
代码见 §4.1、§4.2、§4.6。三个类都标了 @Component,启动类所在包能扫到即可,不需要额外注册。
Step 5:写配置
application.yml(或放到 Nacos,配合 @RefreshScope 免重启生效):
yaml
gateway:
security:
ignore:
# 不做 XSS 清洗的地址,支持正则(命中即跳过)
xss-urls:
- /demo/file/upload.* # 文件上传
- /demo/notice/save # 富文本字段接口,清洗会把标签清掉
# CSRF 白名单:Host / Origin / Referer,建议写成带斜杠的前缀
csrf-hosts:
- http://localhost:8080/
- http://192.168.1.100:8080/
- http://api-user:8080/ # 服务间调用(网关内部的 Host,务必加上)
- http://*.example.com/ # 支持 Ant 通配
两个坑:
- 白名单项建议以
/结尾 。校验用的是"包含"匹配,若白名单写http://a.com,那么http://a.com.evil.com也会命中------代码里给请求值统一补/就是为了堵这个洞,所以白名单也要带/。- 服务间调用(feign / 内部域名)的 Host 也要白名单,否则会被自己的 CSRF 过滤器拦掉。
Step 6:验证
见 §六。
Step 7:按业务微调
- 有富文本、模板、数学公式(含
<、>)的接口 → 加进xss-urls; - 新增部署域名 / 反向代理域名 / 内部服务名 → 加进
csrf-hosts; - 确认下游服务收到的参数是清洗后的值(在业务侧打日志核对,别只看网关日志)。
四、完整代码
4.1 CacheBodyGlobalFilter:让请求体可重复读
java
package com.example.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 缓存请求体,解决 body 只能被读取一次的问题,为后续的 XSS 过滤器重写 body 做准备。
* <p>
* 说明:不是把 body 放到 exchange 的 attribute 里,因为从 attribute 取出后
* 仍要转成 Flux<DataBuffer> 再转字符串,和直接读 body 没区别。
*/
@Component
public class CacheBodyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
HttpMethod method = exchange.getRequest().getMethod();
String contentType = exchange.getRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
boolean needCache = (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))
&& (MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType)
|| MediaType.APPLICATION_JSON_VALUE.equals(contentType)
|| MediaType.APPLICATION_JSON_UTF8_VALUE.equals(contentType));
if (!needCache) {
return chain.filter(exchange);
}
// 把分片的 body 合并成一个 DataBuffer,再取出字节数组
return DataBufferUtils.join(exchange.getRequest().getBody())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
// 释放堆外内存
DataBufferUtils.release(dataBuffer);
ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public Flux<DataBuffer> getBody() {
// 每次被订阅都重新包装一份,实现"可重复读"
return Flux.defer(() -> {
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
DataBufferUtils.retain(buffer);
return Mono.just(buffer);
});
}
};
return chain.filter(exchange.mutate().request(mutatedRequest).build());
});
}
@Override
public int getOrder() {
// 必须最先执行
return Ordered.HIGHEST_PRECEDENCE;
}
}
4.2 XssRequestGlobalFilter:清洗 query 与 body
java
package com.example.gateway.filter;
import com.example.gateway.config.GatewaySecurityProperties;
import com.example.gateway.utils.XssCleanRuleUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
/**
* 防 XSS 全局过滤器:清洗 GET 的查询参数、POST/PUT 的 JSON 及表单请求体。
* <p>
* 实现参考了 Gateway 自带的两个过滤器:
*
* @see org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory
* @see org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory
*/
@Slf4j
@Component
public class XssRequestGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private GatewaySecurityProperties securityProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
HttpMethod method = request.getMethod();
String contentType = request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
String path = request.getPath().value();
// 忽略清单命中,直接放行
if (securityProperties.getIgnore().containsXssUrl(path)) {
return chain.filter(exchange);
}
// ---------- GET:清洗查询参数 ----------
if (HttpMethod.GET.equals(method)) {
return cleanQuery(exchange, chain);
}
// ---------- POST / PUT:清洗请求体 ----------
boolean needCleanBody = (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))
&& (MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType)
|| MediaType.APPLICATION_JSON_VALUE.equals(contentType)
|| MediaType.APPLICATION_JSON_UTF8_VALUE.equals(contentType));
if (!needCleanBody) {
// 例如 multipart/form-data 的文件上传,不修改请求体
return chain.filter(exchange);
}
// 请求体已被 CacheBodyGlobalFilter 缓存为可重复读,这里可以安全地 join
return DataBufferUtils.join(request.getBody())
.map(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
return StandardCharsets.UTF_8.decode(ByteBuffer.wrap(bytes)).toString();
})
.defaultIfEmpty("")
.flatMap(body -> {
if (!StringUtils.hasText(body)) {
// 空 body 不处理,直接放行(交给下游判断参数是否合法)
return chain.filter(exchange);
}
String cleaned = XssCleanRuleUtils.xssClean(body);
return chain.filter(exchange.mutate()
.request(buildBodyRequest(exchange, cleaned))
.build());
});
}
/** 清洗 GET 的查询参数 */
private Mono<Void> cleanQuery(ServerWebExchange exchange, GatewayFilterChain chain) {
URI uri = exchange.getRequest().getURI();
String rawQuery = uri.getRawQuery();
if (!StringUtils.hasText(rawQuery)) {
return chain.filter(exchange);
}
String cleaned = XssCleanRuleUtils.xssClean(rawQuery);
try {
URI newUri = UriComponentsBuilder.fromUri(uri)
.replaceQuery(cleaned)
.build(true)
.toUri();
ServerHttpRequest newRequest = exchange.getRequest().mutate().uri(newUri).build();
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (Exception e) {
// 清洗后 query 不再是合法的 URI(例如残留了 %),拒绝而不是原样放过
log.error("GET 请求清理 XSS 失败, uri={}", uri.getRawPath(), e);
throw new IllegalStateException("请求参数不合法");
}
}
/** 用清洗后的内容构造新的请求体 */
private ServerHttpRequest buildBodyRequest(ServerWebExchange exchange, String cleanedBody) {
byte[] newBytes = cleanedBody.getBytes(StandardCharsets.UTF_8);
return new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders());
// 内容变了,必须重新计算 Content-Length(用字节长度,不是字符长度)
headers.remove(HttpHeaders.CONTENT_LENGTH);
headers.setContentLength(newBytes.length);
return headers;
}
@Override
public Flux<DataBuffer> getBody() {
return Flux.defer(() -> {
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(newBytes);
return Mono.just(buffer);
});
}
};
}
@Override
public int getOrder() {
return -89;
}
}
4.3 XssCleanRuleUtils:清洗规则
java
package com.example.gateway.utils;
import java.util.regex.Pattern;
/**
* XSS 过滤工具。
* <p>
* 两层清洗:
* 1)正则黑名单:删掉脚本片段、事件属性、伪协议等危险内容;
* 2)HTML 标签白名单过滤(EscapeUtil.clean):按白名单剥离标签,保留文本。
*/
public class XssCleanRuleUtils {
/** 危险片段黑名单(可按需增删) */
private static final Pattern[] SCRIPT_PATTERNS = {
Pattern.compile("<script>(.*?)</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("src[\r\n]*=[\r\n]*\\'(.*?)\\'", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("<script(.*?)>", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("eval\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("expression\\((.*?)\\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE),
Pattern.compile("vbscript:", Pattern.CASE_INSENSITIVE),
Pattern.compile("onload(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("onclick(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("onerror(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL)
};
public static String xssClean(String value) {
if (value == null) {
return null;
}
// 去掉空字符与换行,避免 \0 / 换行绕过正则
value = value.replaceAll("\0|\n|\r", "");
for (Pattern pattern : SCRIPT_PATTERNS) {
value = pattern.matcher(value).replaceAll("");
}
// 增强过滤:按白名单剥离 HTML 标签
value = EscapeUtil.clean(value);
return value == null ? null : value.trim();
}
}
4.4 EscapeUtil:转义与 HTML 清洗
java
package com.example.gateway.utils;
/**
* 转义 / 反转义工具。
*/
public class EscapeUtil {
private static final char[][] TEXT = new char[64][];
static {
for (int i = 0; i < 64; i++) {
TEXT[i] = new char[]{(char) i};
}
// 需要转义的特殊字符
TEXT['\''] = "'".toCharArray(); // 单引号
TEXT['"'] = """.toCharArray(); // 双引号
TEXT['&'] = "&".toCharArray(); // &
TEXT['<'] = "<".toCharArray(); // <
TEXT['>'] = ">".toCharArray(); // >
}
/** 清除所有 HTML 标签,但保留标签内的文本 */
public static String clean(String content) {
if (content == null) {
return null;
}
return new HTMLFilter().filter(content);
}
/** 转义文本中的 HTML 字符 */
public static String escape(String text) {
return encode(text);
}
/** 还原被转义的 HTML 字符 */
public static String unescape(String content) {
return decode(content);
}
/** Escape 编码:非 ASCII 用 %uXXXX,其余用 %XX */
private static String encode(String text) {
if (text == null || text.isEmpty()) {
return "";
}
StringBuilder tmp = new StringBuilder(text.length() * 6);
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c < 256) {
tmp.append("%");
if (c < 16) {
tmp.append("0");
}
tmp.append(Integer.toString(c, 16));
} else {
tmp.append("%u");
if (c <= 0xfff) {
tmp.append("0");
}
tmp.append(Integer.toString(c, 16));
}
}
return tmp.toString();
}
/** Escape 解码 */
public static String decode(String content) {
if (content == null || content.isEmpty()) {
return content;
}
StringBuilder tmp = new StringBuilder(content.length());
int lastPos = 0, pos;
while (lastPos < content.length()) {
pos = content.indexOf("%", lastPos);
if (pos == lastPos) {
if (content.charAt(pos + 1) == 'u') {
tmp.append((char) Integer.parseInt(content.substring(pos + 2, pos + 6), 16));
lastPos = pos + 6;
} else {
tmp.append((char) Integer.parseInt(content.substring(pos + 1, pos + 3), 16));
lastPos = pos + 3;
}
} else if (pos == -1) {
tmp.append(content.substring(lastPos));
lastPos = content.length();
} else {
tmp.append(content.substring(lastPos, pos));
lastPos = pos;
}
}
return tmp.toString();
}
}
HTMLFilter:提供 public String filter(String html),基于允许标签白名单 剥离 HTML(标签内文本保留)。这类实现有很多成熟版本(如 JFinal 的 HTMLFilter),建议直接引入,不要自己写。
4.5 GatewaySecurityProperties:忽略清单配置
java
package com.example.gateway.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import java.util.regex.Pattern;
/**
* 网关安全配置:XSS 忽略清单、CSRF 来源白名单。
* 放在配置中心时可配合 @RefreshScope 免重启生效。
*/
@Setter
@Getter
@Component
@RefreshScope
@ConfigurationProperties(prefix = "gateway.security")
public class GatewaySecurityProperties {
private Ignore ignore = new Ignore();
@Setter
@Getter
public static class Ignore {
private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
/** 不做 XSS 清洗的地址,支持正则 */
private String[] xssUrls = {};
/** CSRF 白名单(Host / Origin / Referer 前缀),为空时不校验 CSRF */
private String[] csrfHosts = {};
/** reqUrl 是否命中 XSS 忽略清单 */
public boolean containsXssUrl(String reqUrl) {
if (!StringUtils.hasText(reqUrl)) {
return false;
}
for (String pattern : xssUrls) {
if (Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL)
.matcher(reqUrl).find()) {
return true;
}
}
return false;
}
/** reqHost 是否命中 CSRF 白名单(前缀包含 或 Ant 通配) */
public boolean containsCsrfHosts(String reqHost) {
// 未配置白名单视为不启用 CSRF 校验
if (csrfHosts == null || csrfHosts.length == 0) {
return true;
}
for (String item : csrfHosts) {
if (item != null && (reqHost.contains(item) || ANT_PATH_MATCHER.match(item, reqHost))) {
return true;
}
}
return false;
}
}
}
4.6 CsrfRequestGlobalFilter:来源白名单校验
java
package com.example.gateway.filter;
import com.example.gateway.config.GatewaySecurityProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.stream.Collectors;
/**
* 防 CSRF 全局过滤器:校验请求的 Host / Origin / Referer 是否在白名单内。
*/
@Slf4j
@Component
public class CsrfRequestGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private GatewaySecurityProperties securityProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
HttpHeaders headers = exchange.getRequest().getHeaders();
// Host / Origin 不带斜杠,这里统一补上:
// 避免白名单 "http://a.com" 被 "http://a.com.evil.com" 前缀命中
List<String> hosts = appendSlash(headers.get(HttpHeaders.HOST));
List<String> origins = appendSlash(headers.get(HttpHeaders.ORIGIN));
List<String> referers = headers.get(HttpHeaders.REFERER);
// 既没有 Host 也没有 Referer,无法判断来源
if (CollectionUtils.isEmpty(hosts) && CollectionUtils.isEmpty(referers)) {
return reject(exchange, "Host 与 Referer 均为空");
}
if (!isAllAllowed(hosts) || !isAllAllowed(referers) || !isAllAllowed(origins)) {
return reject(exchange, "请求来源不在白名单内");
}
return chain.filter(exchange);
}
/** 全部值都要在白名单内,有一个不在就拒绝 */
private boolean isAllAllowed(List<String> values) {
if (CollectionUtils.isEmpty(values)) {
return true;
}
for (String value : values) {
if (!securityProperties.getIgnore().containsCsrfHosts(value)) {
return false;
}
}
return true;
}
private List<String> appendSlash(List<String> values) {
if (CollectionUtils.isEmpty(values)) {
return values;
}
return values.stream()
.map(item -> item.endsWith("/") ? item : item + "/")
.collect(Collectors.toList());
}
private Mono<Void> reject(ServerWebExchange exchange, String reason) {
// 去掉跨域响应头,避免浏览器对错误响应继续放行
exchange.getResponse().getHeaders().remove(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
exchange.getResponse().getHeaders().remove(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS);
// 细节只记日志,不回显给调用方,避免泄露白名单/内网信息
log.warn("CSRF 校验未通过: {}", reason);
throw new IllegalStateException("跨站点请求伪造校验未通过");
}
@Override
public int getOrder() {
return -88;
}
}
若你的网关没有统一的异常处理器,
throw会返回 Spring 默认错误页。可以改成直接写响应:
javaexchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); exchange.getResponse().getHeaders().add("Content-Type", "application/json;charset=UTF-8"); byte[] body = "{\"code\":403,\"msg\":\"跨站点请求伪造校验未通过\"}".getBytes(StandardCharsets.UTF_8); return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body)));
五、注意事项与已知坑
5.1 必须知道的三个坑
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| 1 | 富文本/模板接口被清洗 | 公告、表单说明、公式里的标签和 <、> 被删掉 |
加进 xss-urls 忽略清单 |
| 2 | CSRF 白名单漏了服务间调用 | 内部 feign 调用被自己的网关拦截 | 把内部服务名(如 http://api-user:8080/)加入白名单 |
| 3 | 白名单项没带斜杠 | http://a.com.evil.com 绕过校验 |
白名单与请求值都统一补 /(本方案代码已处理请求值,白名单也要写 /) |
5.2 读 body 一定要用 DataBufferUtils.join
不要用下面这种"订阅一次就拿结果"的写法:
java
// ❌ 有隐患:body 分片时只保留最后一片,会导致 body 被截断
AtomicReference<String> ref = new AtomicReference<>();
request.getBody().subscribe(buffer -> {
ref.set(StandardCharsets.UTF_8.decode(buffer.asByteBuffer()).toString());
});
String body = ref.get();
问题有二:
- 分片覆盖 :body 超过一个缓冲区(默认约 8KB)时
Flux会发出多个DataBuffer,回调被多次执行,set会把前面的内容覆盖掉,只剩最后一片; - 时序不可靠 :
subscribe是异步的,能立刻拿到值只在"当前线程直接产出数据"时才成立,换个场景就可能拿到null→ body 被判为空。
正确做法是 DataBufferUtils.join(...) 合并后再转字符串(见 §4.1、§4.2)。
5.3 Content-Length 必须用字节长度
清洗后的长度变了,必须重算 Content-Length,而且要按 UTF-8 字节数 算,不能按字符串 length():
java
// ✅ 正确:字节长度
headers.setContentLength(cleaned.getBytes(StandardCharsets.UTF_8).length);
// ❌ 错误:字符长度,中文场景会小于字节数 → 下游按 Content-Length 读会截断 body
headers.setContentLength(cleaned.length());
5.4 空 body 的 POST 不要直接判 400
"POST 必须带参数"这种校验属于业务规则 ,不该由 XSS 过滤器决定。早先的实现会在 body 为空时直接返回 400,会把合法的无参提交动作(如"确认"按钮)也拒掉,而这类请求本该交给下游判断。本方案改为空 body 直接放行。
5.5 正则黑名单能被绕过,别只依赖它
javascript:、onerror= 这类黑名单很容易被变形绕过,例如:HTML 实体编码(javascript:)、data: URL、SVG/MathML 事件、只在老浏览器生效的伪协议等。所以:
- 网关清洗只是统一兜底 ,真正的防线是输出转义 :Vue 的插值
{``{ }}默认转义,风险点在v-html------凡是v-html的地方必须确保内容已净化或来自可信源; - 存库用参数化查询(防 SQL 注入)、接口做参数校验(长度/类型/白名单);
- 前端配置 CSP(限制内联脚本)、Cookie 加
HttpOnly+SameSite。
5.6 错误信息不要回显细节
校验失败时不要把具体 Host / Origin / 白名单内容拼进返回消息,否则等于把内网地址与配置泄露给攻击者。统一返回固定文案,细节写日志(本方案已按此处理)。
5.7 CSRF 默认行为要明确
csrfHosts 为空时 containsCsrfHosts 返回 true(不校验),但 §4.6 里"Host 与 Referer 均为空 → 拒绝"的规则仍然生效。也就是说:
- 不配白名单 ≠ 完全不拦:HTTP/1.0 客户端、无 Host 的请求会被拒;
- 想彻底关闭这个过滤器:要么让它排到不生效的位置,要么在配置里显式打开"放行开关"。
上线前务必确认哪个行为是你想要的。
5.8 顺序与性能
CacheBodyGlobalFilter的 order 必须是HIGHEST_PRECEDENCE,否则 XSS 过滤器会读到"已被订阅的 body"而报错;- 所有 POST/PUT 的 JSON/表单请求都会被完整读入内存 一次。若存在超大 JSON 请求体,注意网关堆内存与
spring.codec.max-in-memory-size;文件上传(multipart/form-data)不在处理范围内。
六、验证
6.1 XSS 清洗
bash
# 1) GET 参数注入
curl -i "http://localhost:9999/api-demo/demo/query?name=%3Cscript%3Ealert(1)%3C%2Fscript%3E&keyword=abc"
# 期望:请求能通(或下游校验拒绝),下游收到的 name 已不含 <script>
# 2) JSON body 注入
curl -i -X POST "http://localhost:9999/api-demo/demo/save" \
-H "Content-Type: application/json" \
-H "Host: localhost:9999" -H "Referer: http://localhost:8080/" \
-d '{"title":"<script>alert(1)</script>hello","remark":"<img src=x onerror=alert(1)>"}'
# 期望:下游收到的 title 只剩 hello,remark 里的 img/事件属性已被清掉
# 3) 分段 body(验证 join 是否生效):构造 > 8KB 的 JSON,前段与后段各放一个 <script>
# 期望:两处都被清洗,且下游收到的 JSON 完整(不截断)
验证要点:在下游业务服务打日志核对收到的参数,只看网关日志无法确认 body 是否被正确透传。
6.2 CSRF 校验
bash
# 1) 伪造 Origin → 期望被拒
curl -i -X POST "http://localhost:9999/api-demo/demo/save" \
-H "Host: localhost:9999" \
-H "Origin: http://evil.example.com/" \
-H "Content-Type: application/json" -d '{"a":1}'
# 2) 白名单内的 Origin → 期望放行
curl -i -X POST "http://localhost:9999/api-demo/demo/save" \
-H "Host: localhost:9999" \
-H "Origin: http://localhost:8080/" \
-H "Content-Type: application/json" -d '{"a":1}'
# 3) 不带 Host 与 Referer → 期望被拒(说明"不配白名单也会拦")
curl -i -X POST "http://localhost:9999/api-demo/demo/save" \
-H "Content-Type: application/json" -d '{"a":1}'
# 4) 前缀绕过检查:Host 形如 localhost:9999.evil.com 应被拒
curl -i -X POST "http://localhost:9999/api-demo/demo/save" \
-H "Host: localhost:9999.evil.com" -H "Content-Type: application/json" -d '{"a":1}'
6.3 忽略清单
bash
# 上传接口(在 xss-urls 里)不应被清洗,文件内容与文件名原样透传,且不报 400
curl -i -X POST "http://localhost:9999/api-demo/demo/file/upload" -F "file=@test.txt"
七、纵深防御:网关这层只是其中一环
| 层次 | 手段 | 拦什么 |
|---|---|---|
| 网关(本文档) | XSS 统一清洗、CSRF 来源白名单、认证鉴权、限流 | 统一入口的粗粒度恶意输入与来源 |
| 应用服务 | 参数校验(长度/类型/枚举)、SQL 参数化、业务鉴权、操作二次确认、CSRF Token(若有 Cookie 会话) | 精细业务规则与越权 |
| 前端 | 渲染转义(v-html 需谨慎)、CSP、不在 URL 里放敏感信息 |
呈现层的最终兜底 |
| 运维/基础设施 | WAF、全站 HTTPS、Cookie HttpOnly+SameSite、日志告警 |
边界防护与事后发现 |
一句话总结:网关负责"拦在门口",应用负责"按规则放行",前端负责"渲染时不再执行"------任何一层单独做都不够。
附录 A:输出转义 / 参数化查询 / CSP 是什么
摘要与第七章提到"真正的防线是输出转义 (前端渲染)+ 参数化查询 + CSP",这里展开说明这三个词的含义、为什么它们才是主防线,以及在当前技术栈里怎么落地。
A.1 共同的原理:不要让"数据"变成"代码"
XSS 和 SQL 注入本质是同一类问题,只是"代码"的宿主不同:
| 攻击 | 用户输入变成了 | 在哪里执行 |
|---|---|---|
| XSS | HTML / JS 代码 | 浏览器 |
| SQL 注入 | SQL 代码 | 数据库 |
所以根本解法只有一个方向:数据永远只当数据。三种手段分别守在不同位置:
| 防线 | 在哪一层 | 做什么 | 拦什么 |
|---|---|---|---|
| 输出转义 | 渲染层(前端 / 视图) | 要显示数据时,按"它将被放进什么上下文"的规则编码,如 < → < |
XSS |
| 参数化查询 | 数据访问层(DAO) | SQL 结构与数据分开传,数据永远只当"值" | SQL 注入 |
| CSP | 浏览器(响应头) | 声明"只允许执行哪些来源的脚本" | XSS 的最后一道闸:即使转义漏了,脚本也执行不了 |
A.2 输出转义(更准确的叫法是"输出编码")
关键点:不是把数据洗干净再入库,而是原样存、在"往页面里放的那一刻"按上下文编码。
| 数据要放进哪里 | 编码方式 |
|---|---|
| HTML 文本 | < → <、> → >、& → & |
| HTML 属性 | 还要转 " 和 ',否则能闭合属性、注入事件(onerror=) |
<script> 内的 JS 变量 |
转成 JSON 字符串(JSON.stringify,并处理 </script>) |
| URL 参数 | encodeURIComponent |
| CSS | 原则上不允许用户数据进入 CSS |
在当前前端栈(Vue)里:
vue
<!-- ✅ 安全:插值默认做 HTML 转义,<script> 会原样显示成文本 -->
<div>{{ userInput }}</div>
<!-- ✅ 安全:属性绑定同样是转义的 -->
<div :title="userInput">...</div>
<!-- ❌ 危险:v-html 不做转义,等价于 innerHTML -->
<div v-html="userInput"></div>
落地动作 :全局搜 v-html(以及原生 innerHTML、document.write、eval),逐个确认数据来源是否可信。确实要渲染 HTML 的(公告、富文本),必须服务端按白名单净化后再渲染。
为什么它比"网关清洗"更靠谱 :网关不知道这份数据将来会被放进哪里 (HTML 文本?属性?JS?SQL?),而各种上下文的转义规则完全不同------它只能"暴力删掉看起来危险的片段",于是必然误伤(富文本被清空、公式里的 < 被删),也必然漏(变形绕过)。输出点才知道上下文,所以能准确编码、且不误伤原数据。
A.3 参数化查询
同一个思维:把 SQL 的"结构"与"数据"分开送。
java
// ✅ 参数化:SQL 先被编译成带占位符的语句,数据只作为参数值传入
String sql = "select * from os_user where account = ? and status_ = ?";
jdbcTemplate.query(sql, "admin", "actived");
java
// ❌ 拼接:输入 ' or '1'='1 就能改变 SQL 语义
String sql = "select * from os_user where account = '" + account + "'";
MyBatis 里要特别注意这两个符号:
xml
<!-- ✅ #{} 是预编译占位符,安全 -->
<select id="find">
select * from os_user where account = #{account}
</select>
<!-- ❌ ${} 是字符串拼接,等同于 SQL 注入入口 -->
<select id="find">
select * from os_user where account = '${account}'
</select>
<!-- ⚠️ 表名 / 列名 / order by 无法用 #{},只能用 ${}:此时必须对参数做白名单校验 -->
<select id="find">
select * from os_user order by ${orderColumn}
</select>
落地动作 :搜 Mapper XML 里的 ${、以及 createStatement、"select ..." + 这类拼接;order by ${x} 的地方补枚举白名单。
提醒:参数化查询防的是 SQL 注入,不防 XSS。从库里取出来的数据照样可能含脚本(历史数据、别的系统写入的数据),渲染时仍然要转义------两者不能互相替代。
A.4 CSP(Content-Security-Policy,内容安全策略)
它是一个响应头 ,由服务端下发,告诉浏览器"这个页面只允许加载/执行哪些来源的资源";浏览器会直接拒绝违反策略的脚本执行。
nginx
# Nginx:加在 server 或 location 中
add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'self'
" always;
| 指令 | 挡住什么 |
|---|---|
script-src 'self' |
内联 <script>alert(1)</script>、onerror= 之类内联事件、外域恶意域名脚本 |
object-src 'none' |
插件类注入载体(Flash 等) |
base-uri 'self' |
<base> 标签劫持(篡改相对路径的加载源) |
frame-ancestors 'self' |
页面被第三方 iframe 嵌入(点击劫持方向) |
上线姿势(不要一上来就硬开,容易白屏):
nginx
# 第一步:只上报不拦截,先观察有哪些正常资源会被拦
add_header Content-Security-Policy-Report-Only "
default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
" always;
观察一到两周、把误报的来源补进白名单后,再替换成正式的 Content-Security-Policy。
两点注意:
- 一旦加上
'unsafe-inline',内联脚本的防护就等于放弃了(构建产物里若有内联脚本,应改用 nonce / hash 方式,而不是简单放开); - CSP 是兜底:转义正确时它什么都不做,转义漏了它能救命。所以它是"最后一道闸",不能当主防线。
A.5 为什么网关清洗只能算"兜底"
| 理由 | 说明 |
|---|---|
| 1. 网关不懂上下文 | 同一份数据可能进 HTML 文本 / HTML 属性 / JS / URL / SQL,转义规则各不相同;网关只能暴力删,必然误伤富文本、也必然漏变形绕过 |
| 2. 黑名单永远不完整 | javascript:、data: URL、SVG 事件等层出不穷,穷举不可行 |
| 3. 覆盖不到"输出时刻" | 存储型 XSS 的触发点在"读取并渲染"那一刻。数据可能来自其他系统、历史数据或直接 SQL 导入,根本没经过你的网关清洗;只要渲染不转义,照样中招 |
三者的层次关系:
前端输出转义 + 参数化查询 ← 主防线(准确、不误伤原数据)
CSP ← 最后一道闸(转义漏了也不会执行)
网关 XSS 清洗 ← 统一兜底(减少进入系统的脏数据,别指望它拦住全部)
A.6 落地检查清单
| # | 动作 | 具体位置 |
|---|---|---|
| 1 | 排查前端危险渲染点 | 搜 v-html、innerHTML、document.write、eval |
| 2 | 排查 SQL 拼接 | 搜 Mapper XML 的 ${、createStatement、"select ... " + |
| 3 | 加 CSP 响应头 | 先 Content-Security-Policy-Report-Only 观察,再正式开启(Nginx / 网关过滤器 / 安全框架统一配置) |
| 4 | 加固 Cookie | HttpOnly + SameSite=Lax/Strict,降低 XSS 拿到凭证后的危害 |
| 5 | 保留网关清洗 | 作为兜底,同时维护好 xss-urls 忽略清单(富文本、上传等) |