【Spring Cloud】Spring Cloud Gateway 防 XSS / 防 CSRF 实现

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 时会报"请求体已被订阅"。

三点必须记住:

  1. 网关清洗不是万能药 :正则是黑名单,能被绕过;真正的防线是输出转义 (前端渲染)+ 参数化查询 + CSP。网关这层是统一兜底。
  2. 富文本接口必须进忽略清单:清洗会删掉标签只留文本,公告、表单说明这类字段会被"清干净"。
  3. 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,跨站请求伪造)

是什么 :攻击者构造一个页面/链接,诱导已登录的用户去访问,利用"浏览器会自动为同域请求带上凭证"这一机制,让目标站以为是用户本人在操作。

两个前提(缺一不可):

  1. 目标站靠浏览器自动携带的凭证识别身份(如 Cookie / Session);
  2. 浏览器确实会自动带上。

值得注意:如果鉴权走的是请求头里的 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 通配

两个坑:

  1. 白名单项建议以 / 结尾 。校验用的是"包含"匹配,若白名单写 http://a.com,那么 http://a.com.evil.com 也会命中------代码里给请求值统一补 / 就是为了堵这个洞,所以白名单也要带 /。
  2. 服务间调用(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&lt;DataBuffer&gt; 再转字符串,和直接读 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['\''] = "&#039;".toCharArray();  // 单引号
        TEXT['"'] = "&#34;".toCharArray();    // 双引号
        TEXT['&'] = "&#38;".toCharArray();    // &
        TEXT['<'] = "&#60;".toCharArray();    // <
        TEXT['>'] = "&#62;".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 默认错误页。可以改成直接写响应:

java 复制代码
exchange.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();

问题有二:

  1. 分片覆盖 :body 超过一个缓冲区(默认约 8KB)时 Flux 会发出多个 DataBuffer,回调被多次执行,set 会把前面的内容覆盖掉,只剩最后一片;
  2. 时序不可靠 :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 实体编码(&#106;avascript:)、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 代码 数据库

所以根本解法只有一个方向:数据永远只当数据。三种手段分别守在不同位置:

防线 在哪一层 做什么 拦什么
输出转义 渲染层(前端 / 视图) 要显示数据时,按"它将被放进什么上下文"的规则编码,如 < → &lt; XSS
参数化查询 数据访问层(DAO) SQL 结构与数据分开传,数据永远只当"值" SQL 注入
CSP 浏览器(响应头) 声明"只允许执行哪些来源的脚本" XSS 的最后一道闸:即使转义漏了,脚本也执行不了

A.2 输出转义(更准确的叫法是"输出编码")

关键点:不是把数据洗干净再入库,而是原样存、在"往页面里放的那一刻"按上下文编码。

数据要放进哪里 编码方式
HTML 文本 < → &lt;、> → &gt;、& → &amp;
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。

两点注意:

  1. 一旦加上 'unsafe-inline',内联脚本的防护就等于放弃了(构建产物里若有内联脚本,应改用 nonce / hash 方式,而不是简单放开);
  2. CSP 是兜底:转义正确时它什么都不做,转义漏了它能救命。所以它是"最后一道闸",不能当主防线。

A.5 为什么网关清洗只能算"兜底"

理由 说明
1. 网关不懂上下文 同一份数据可能进 HTML 文本 / HTML 属性 / JS / URL / SQL,转义规则各不相同;网关只能暴力删,必然误伤富文本、也必然漏变形绕过
2. 黑名单永远不完整 &#106;avascript:、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 忽略清单(富文本、上传等)
相关推荐
程序员Sunday3 小时前
XSS 和 CSRF 有什么区别?HttpOnly、SameSite 和 Token 分别防什么?
网络·xss·csrf
Wang's Blog4 小时前
Java 项目实战: 交友项目 - SpringCloud 整合 Dubbo 的需求分析与模块规划
java·spring cloud·交友
流烟默5 小时前
Spring Cloud 微服务 Sentinel 热点参数与授权规则集成指南(附架构建议)
spring cloud·微服务·架构·sentinel
EAIReport8 小时前
SpringBoot 与 SpringCloud 深度解析:核心特点、差异与协同关系
spring boot·后端·spring cloud
Wang's Blog8 小时前
Java 项目实战: 交友项目 - SpringCloud 整合 Dubbo 的方案概述
java·spring cloud·交友
Thomas.Sir1 天前
第23课:Sentinel熔断降级、系统自适应保护、集群限流
spring cloud·sentinel
2501_933923252 天前
SpringCloud组件:Eureka服务注册与发现
java·开发语言·后端·spring cloud·eureka
叶总没有会2 天前
Gateway--网关
spring cloud·微服务·gateway
海马3 天前
Spring Cloud 开发知识整理
spring·spring cloud·wpf