开门见山说一下,任何一个用Zuul做微服务网关的团队,多半都遇到过这么个诡异问题:下游服务直连的时候一切正常,一挂到网关后面,Authorization头就像被一双无形的手抹掉了一样,怎么都拿不到。如果你也被这问题卡到过,那这篇就把zuul网关sensitiveHeaders设置这件事彻底聊透,内容包括默认值到底是什么、底层怎么判断、不同配置方式的效果、实战里的坑,以及一套靠谱的排查链路。
这篇适合正在维护Zuul 1.x老项目的人,也适合那些刚接手网关、被各种请求头透传问题搞得头皮发麻的新同学。读完你可以直接对着自己的application.yml改配置,也能知道下次再遇到“Header不见了”该从哪里入手查。
1. 一个诡异的转发问题:请求头在下游凭空消失
先说个典型场景。某项目里网关用Zuul,下游是一堆Spring Boot服务。有一个服务需要从Authorization头里解析用户信息,开发同学在自己电脑上直连服务调试,一切正常;一旦把请求打到网关,服务日志里打印出来的Header列表里死活没有Authorization。
当时第一反应是网关的过滤器写错了,把请求头弄丢了;第二反应是鉴权中间件把请求拦了。结果都不是。最后翻到Zuul的配置类才发现,这个行为是Zuul默认的设计——它会在转发时主动过滤掉一部分敏感请求头。
被过滤的三件套,就是大家常说的sensitiveHeaders默认清单:
private Set<String> sensitiveHeaders = new LinkedHashSet<>(Arrays.asList( "Cookie", "Set-Cookie", "Authorization" ));也就是说,只要原始请求里带着Cookie、Set-Cookie或者Authorization,到达Zuul之后会被默认策略剥掉,再转发给下游。这有点像快递中转站,其他包裹都能正常转运,唯独这三样东西默认被扣下来。至于为什么扣,原因其实不复杂:网关是流量的总入口,如果把用户凭证不加区分地广播给所有后端服务,任何一个服务被攻破,攻击者就能拿到所有请求的用户身份信息。所以Zuul宁可“宁可错杀,不可放过”,默认先不给你透传。
但问题也来了:真实业务往往又需要透传。最常见的场景就是网关后面有多个服务,每个服务都要从同一个Authorization里解析登录态;或者某个服务需要读取客户端传来的Cookie来识别匿名用户。这时候你就得手动动sensitiveHeaders了。这篇博文要解决的,就是“怎么动、动哪里、动了之后会有什么后果”这些问题。
2. 底层逻辑:Zuul如何判断一个Header是否敏感
2.1 默认敏感清单藏在ZuulProperties里
Zuul的配置核心是ZuulProperties这个类,sensitiveHeaders是它的一个字段。启动时,Spring Boot会把配置文件里的zuul.sensitive-headers绑定到这个字段上。
YAML里的写法长这样:
zuul: sensitive-headers: Cookie,Set-Cookie,Authorization注意,这里用的是Set而不是List,所以顺序不重要,去重是自动的。也正因为是集合判断,每次转发请求时判断Header是否敏感,时间复杂度是O(1),性能上不用担心。
2.2 请求头到底在哪里被过滤
Zuul转发请求不是直接在过滤器里手写HttpServletRequest的Header搬运,而是统一交给ProxyRequestHelper处理。无论是走Ribbon的服务发现路由,还是走url直连的简单路由,最终构建下游请求头都是同一套逻辑。
简化后的处理逻辑大概是:
// 伪代码,只突出核心判断 for (String headerName : request.getHeaderNames()) { if (isSensitiveHeader(headerName, route, globalSensitiveHeaders)) { continue; // 命中敏感清单,直接跳过 } headers.put(headerName, request.getHeaders(headerName)); }也就是说,转发时Zuul会遍历原始请求的所有Header,逐个和“敏感清单”比对,如果在清单里,这个Header就不进入下游请求。这里有个有意思的细节:比对过程对Header名做了统一规范化处理,大小写都会被归一到同一种形式。所以别想用authorization小写来绕过,没用的。
2.3 全局配置和路由级配置的覆盖规则
sensitiveHeaders的配置分成两级:全局配置和路由级配置。
zuul: sensitive-headers: Cookie,Set-Cookie,Authorization # 全局 routes: demo: path: /demo/** serviceId: demo-service sensitive-headers: Cookie,Set-Cookie # 该路由单独指定判断优先级是这样的:如果当前路由自己配置了sensitive-headers,那么完全使用路由级的值,全局配置对这个路由不再生效;如果当前路由没配置,才回落到全局配置。
这里是最容易踩坑的地方。很多人以为路由级配置是在“全局基础上额外增加”,比如全局过滤三个头,路由配一个Authorization,那就是过滤Cookie、Set-Cookie、Authorization三个头。但实际情况是:一旦路由级写了Authorization,这个路由就只过滤Authorization,Cookie和Set-Cookie反而被放行了。
要理解这个机制,可以把路由级配置想象成“覆盖开关”,而不是“叠加清单”。你写什么就是什么,没写就继承全局。想让某个路由什么都不过滤,就把它配成空集合:
zuul: routes: demo: path: /demo/** serviceId: demo-service sensitive-headers:这种配置意味着当前路由透传所有原始请求头。
2.4 sensitive-headers和ignored-headers不是一回事
排查问题的时候,经常有人把sensitive-headers和ignored-headers搞混。我见过不止一次,眼巴巴看着配置没问题,但Header还是丢了,最后发现是ignored-headers里写了那个Header。
两者的区别,可以用一句话讲清楚:sensitive-headers是“转发时不带给下游”,请求本身在网关层面还是看得到、读得到的;ignored-headers是“网关在构建转发Headers时直接不采集”,相当于这个Header在Zuul的转发链路里彻底消失。
ignored-headers的默认值不同版本不太一样,早期版本默认会忽略一些Http传输层头,比如connection、content-length之类。这类头本身就该由HTTP客户端重新生成,你硬要透传也没意义。
另外要提一嘴X-Forwarded-*这一组头。X-Forwarded-For、X-Forwarded-Host、X-Forwarded-Proto这些是Zuul自己生成并加到下游请求里的,跟sensitive-headers没关系。所以有时候你会在下游看到这组头,但它们不是原始请求里带的,而是网关“造”出来的。排查Header问题时,别把这组头也算进去。
下面用一个表格把这几个概念理一下:
| 配置项 | 作用范围 | 默认值 | 典型效果 |
|---|---|---|---|
| zuul.sensitive-headers | 全局所有路由 | Cookie, Set-Cookie, Authorization | 转发时剥离凭证类请求头 |
| zuul.routes. .sensitive-headers | 单个路由 | 无,继承全局 | 单独放行或收紧某个路由 |
| zuul.ignored-headers | 全局所有路由 | 多为空 | 某些头彻底不进入转发逻辑 |
| X-Forwarded-* | 网关自动生成 | 不适用 | 记录原始IP、Host、协议等链路信息 |
3. 三种主流配置姿势及效果对比
3.1 全局配置:一句话影响所有路由
如果你希望所有经过网关的请求都不透传某几个头,就在全局配置里一次性声明。
zuul: sensitive-headers: Cookie,Set-Cookie这段配置的意思是:全局只过滤Cookie和Set-Cookie,Authorization被放行。这样做的直接后果是,每个下游服务都能看到用户的Authorization,如果你的下游服务全都是可信的内部服务,而且它们都需要这个头做鉴权,那确实省事。
但我不推荐一上来就这么配,原因后面安全部分会详细说。简单讲,网关后面的服务可能不是你一个人的项目,可能是别的业务线接进来的,他们未必需要用户凭证。一旦全局放行,用户凭证等于在整个内部网络里裸奔。
3.2 单路由配置:精准放行才是主流姿势
实际项目里更常见的是只对某几个路由放开。比如订单服务需要解析JWT,其他服务不需要,那就只配订单服务这一个路由。
zuul: routes: order: path: /order/** serviceId: order-service sensitive-headers:这个sensitive-headers:后面直接换行,表示空集合。效果是:/order/**这个路由不再过滤任何敏感头,Cookie、Authorization、Set-Cookie都能透传到订单服务。其余路由继续走全局默认值,保持过滤。
这种写法的精髓就是“默认收紧、按需放开”,也是我在生产环境里最推荐的方式。
3.3 混合配置:全局和路由同时存在的效果
再复杂一点,全局和路由可以同时配,效果由优先级规则决定。
zuul: sensitive-headers: Cookie,Set-Cookie,Authorization routes: auth: path: /auth/** serviceId: auth-service sensitive-headers: Cookie,Set-Cookie这里全局过滤三个头,但auth路由自己只过滤Cookie和Set-Cookie。于是经过/auth/**的请求,Authorization会放行到auth-service,Cookie和Set-Cookie仍然被过滤。
这个例子正好说明前面说的覆盖规则:路由级不是追加,而是顶替全局。
3.4 YAML写法里的几个“手滑”点
zuul.sensitive-headers是Kebab Case写法,对应Java字段sensitiveHeaders。Spring Boot的Relaxed Binding支持你写zuul.sensitiveHeaders,但项目里大家基本都用短横线风格,保持和文档一致比较好。
空集合的写法要看清楚:
# 推荐:空列表/空集合 zuul: routes: order: path: /order/** serviceId: order-service sensitive-headers:# 这也是可以的 zuul: routes: order: path: /order/** serviceId: order-service sensitive-headers: []但下面这种要小心:
sensitive-headers: null万一哪个同事把配置写成null,某些版本可能会把它当成一个包含字符串"null"的值,或者绑定失败,行为会变得非常不可控。反正我见过不止一次因为缩进或者空值写法问题,导致配置加载后不是自己以为的那个值。遇到这种情况,建议启动后直接打印一下ZuulProperties的内容,确认再往下走。
4. 实战拆解:从透传JWT到只放行某条路由
4.1 场景一:网关内部做JWT解析,下游只需要用户ID
这是我自己最喜欢的一种方案,也是规避sensitive-headers问题最优雅的思路。
网关层面拦截所有请求,解析JWT,拿到userId,然后写入一个新的自定义HeaderX-User-Id,再转发给下游。下游服务完全不接触原始Authorization,只需要读X-User-Id,即可完成身份识别。
这样做的好处是显而易见的:X-User-Id不在默认敏感清单里,不需要动任何sensitive-headers配置,就能正常透传;同时,用户的原始凭证没有暴露给下游服务,安全边界更干净。
实现也不复杂,在Zuul里写一个Pre过滤器:
@Component public class JwtParsePreFilter extends ZuulFilter { @Override public String filterType() { return FilterConstants.PRE_TYPE; } @Override public int filterOrder() { return 1; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest request = ctx.getRequest(); String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); // 这里做JWT解析,拿到userId String userId = parseUserIdFromToken(token); ctx.addZuulRequestHeader("X-User-Id", userId); } return null; } }注意,这里的关键点是addZuulRequestHeader,它会在Zuul自己的请求上下文里塞一个头,后续转发时会带上。由于X-User-Id不在sensitive-headers默认清单中,所以能顺利发到下游。
4.2 场景二:某个服务需要读Cookie里的会话ID
不是所有服务都需要用JWT,有的老服务还保留着Session机制,它要读客户端Cookie里的JSESSIONID,去Redis里查会话。
这种情况你就不得不让Cookie进入下游服务。但不需要全局放开,只放开对应的路由即可。
zuul: routes: legacy-session: path: /legacy/** serviceId: legacy-session-service sensitive-headers:这个路由会透传所有Header,包括Cookie。其他路由的sensitive-headers保持默认,Cookie继续被过滤。这样的好处是,只有这个遗留服务能接触到Cookie,其他新服务依然接触不到,凭证的暴露面被控制在一个点。
4.3 场景三:只放行Authorization,不放大Cookie
有的服务需要读Authorization,但它根本不需要Cookie。这种时候别偷懒把整个空集合拿上去,应该显式写清楚要放行的头。
zuul: routes: business: path: /business/** serviceId: business-service sensitive-headers: Cookie,Set-Cookie这段配置的意思是:business路由只过滤Cookie和Set-Cookie,Authorization放行。我每次改这种配置都会多看一眼,因为一旦写成下面这样:
sensitive-headers: Authorization那么该路由的Cookie就被放行了,这往往不是你的本意,但又特别容易手滑发生。
4.4 场景四:自定义Header被莫名过滤
还有一个比较隐蔽的坑,来自自定义Header。业务团队自己约定了一个X-Signature头,上游系统会带这个头到网关,网关再转发给后端服务。结果发现下游总是拿不到。
后来排查原因大概有问这类:某个同事为了试某个功能,把sensitive-headers临时配成了Cookie,Set-Cookie,X-Signature,用完忘了删。这个案例想说明的是,sensitive-headers清单本身也是需要做变更管理的,尤其是在多人协作的项目里。临时调试配置随手一加,很容易成为后面线上问题的源头。
5. 问题排查的完整链路
如果你接手的是别人留下的网关,遇到Header消失又不知道从哪查起,下面这条排查路径我实测下来很管用。
5.1 第一步:确认请求到底带没带这些Header到网关
在Zuul里写一个临时调试用的Pre过滤器,打印所有进入网关的Header。
@Component public class HeaderDebugFilter extends ZuulFilter { private static final Logger log = LoggerFactory.getLogger(HeaderDebugFilter.class); @Override public String filterType() { return FilterConstants.PRE_TYPE; } @Override public int filterOrder() { return 0; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest request = ctx.getRequest(); Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String name = headerNames.nextElement(); log.info("zuul received header [{}] = {}", name, request.getHeader(name)); } return null; } }这一步主要确认请求头确实到了网关。如果这一层就没打印出来,那就是上游没传,跟Zuul配置无关。
5.2 第二步:对比下游服务实际收到的Header
在下游服务入口打印Header,或者直接在Controller里用调试接口输出。
@PostMapping("/debug/headers") public Map<String, String> debugHeaders(@RequestHeader Map<String, String> headers) { return headers; }把网关收到的Header和下游收到的Header放在一起对比。两边都有,说明转发正常;网关有、下游没有,大概率就是sensitive-headers或者ignored-headers的锅。
5.3 第三步:用curl做一次干净的验证
浏览器会自动带一堆额外Header,Cookie也很容易被带上,这会干扰判断。建议直接用curl,把要验证的Header显式带上。
先直连下游服务,确认服务本身能读到Header:
curl -i -H "Authorization: Bearer test-token" http://localhost:8081/demo再走网关:
curl -i -H "Authorization: Bearer test-token" http://localhost:8080/api/demo两次对比,如果直连有Authorization,走网关就没有,那就能锁死在Zuul的Header转发环节。接下来无非就是查路由的sensitive-headers到底写了什么。
5.4 第四步:确认配置真正被加载
有时候你改了application.yml,但启动时发现没生效。我遇到过的原因有几种:
配置文件没动对地方。比如账号体系用的是外置配置中心,本地application.yml根本没生效;或者配置在bootstrap.yml里,被application.yml覆盖了。
路由Key写错了。Zuul路由匹配是按zuul.routes.<自定义id>来的,如果你在网关里用serviceId注册路由,但配置里写的是另一个id,那配置就落不到目标路由上。
大小写或命名空间不对。虽然Spring Boot支持松散绑定,但如果你写的是zuul.sensitiveHeaders,在有些版本里也能绑定,不过为了保险起见,我还是倾向用短横线zuul.sensitive-headers。这不能算bug,纯粹是让大家少踩坑。
如果项目里有spring-boot-starter-actuator,也可以尝试通过配置属性端点查看ZuulProperties的实际值,确认sensitiveHeaders字段加载成了什么。
5.5 一个容易误判的点:Host头丢了
有同学会发现,即使把所有sensitive-headers都配置成空,下游拿到的请求里也没有原始Host,或者在网关转发后被改成了目标服务的域名。原因在于Zuul里还有一个zuul.add-host-header配置,默认是关闭状态。如果你需要保留原始请求的Host,要设置:
zuul: add-host-header: true这事跟sensitive-headers无关,但排查时很容易被顺藤摸瓜牵扯进去,所以在这里提一句。
6. 安全权衡与后续演进
6.1 默认过滤其实是一个合理的安全设计
回头看Zuul默认过滤Cookie、Set-Cookie、Authorization这三个头,站在网关设计者的角度,这是很正确的“默认安全”思路。网关是这个体系里唯一能完整看到所有流量入口的组件,如果它不拦一下凭证类Header,那就等于把用户的登录态在整个微服务网络里广播。
你可以想象一下:一个用户登录了电商系统,他的Cookie里可能带着会话ID,这个会话ID能在Redis里查出他的用户信息。如果网关把Cookie透传给所有下游服务,任何一个下游服务被拖库或者被入侵,攻击者就能拿着这个Cookie伪装成用户。这个问题在单体应用里不存在,因为业务边界就是进程边界;但在微服务里,服务边界变成了网络边界,网关就成了最后一道闸门。
所以,默认的sensitiveHeaders配置应当被看作是安全基线,而不是阻碍开发的障碍。你每一次放开它,本质上都是在降低这个安全基线,必须要有明确理由。
6.2 我个人的放行原则
现在维护的网关项目里,我基本遵循三条原则。
第一,全局sensitive-headers几乎不动,保持默认值。凡是有透传需求的路由,单独配置。
第二,能不透传原始凭证就不要透传。网关能解析的,就在网关解析,然后把解析结果放入自定义Header,比如X-User-Id,下游只依赖这个自定义Header。这比直接放行Authorization安全得多。
第三,如果要放开某个路由,尽量精确到“哪些头要放行”,用白名单思维,而不是“哪些头要过滤”的黑名单思维。比如路由需要Authorization但不需要Cookie,就只把Cookie和Set-Cookie留在敏感清单里。这样即使以后这条路由被别的不认识的服务接入,它也不会拿到它不该拿的凭证。
6.3 如果已经迁移到Spring Cloud Gateway
新项目很多已经换成Spring Cloud Gateway了。Gateway的默认行为和Zuul正好相反——它默认透传所有原始请求头,没有一个全局的敏感头清单。
如果你是想在Gateway里限制某个Header透传,用RemoveRequestHeader过滤器。
spring: cloud: gateway: routes: - id: demo uri: lb://demo-service predicates: - Path=/demo/** filters: - RemoveRequestHeader=Cookie这段配置会让/demo/**路由转发时移除Cookie头。注意,Gateway这么做是显式移除,而不是默认剥离。也就是说,如果你从Zuul迁移到Gateway,绝不能直接把sensitive-headers配置翻译过去,两个组件的默认行为是相反方向的:Zuul默认过滤一部分,Gateway默认全放行。迁移过程中一定要重新梳理每个路由的Header流转策略,而不是机械地照着老配置改。
最后分享一个我自己的习惯。我每次在两个场景里会特别谨慎地检查sensitive-headers:一是新增路由的时候,二是升级Spring Cloud版本的时候。前者容易因为图省事直接复制别的路由的配置,后者容易因为配置项语义变化导致默认行为变化。干网关这活儿,靠的往往不是有多大本事,而是够不够细心。把这些边界情况都照顾到,生产环境就安稳很多。