Envoy jwt_authn 过滤器安全加固:Filter-Wide Payload/Claim 头部清理机制深入解析
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
导读
Envoy 的jwt_authn(JWT 认证)HTTP 过滤器在验证成功后,会通过forward_payload_header和claim_to_headers将 JWT 的载荷与声明写入请求头,传递给上游服务。然而在旧版本中,这些头部的清理(sanitize)只发生在"匹配到的验证器内部",导致大量绕过路径存在身份伪造(spoofing)漏洞:客户端可以自行注入这些"保留头"并随请求直达上游。本文基于 Envoy 仓库中的变更记录(changelogs/current/minor_behavior_changes/jwt_authn__sanitize-payload-headers-filter-wide.rst),完整剖析这次 filter-wide(过滤器级)清理机制的变更内容、安全动机、源码实现、测试验证以及回滚开关,帮助你理解并安全升级。
背景:jwt_authn 如何把 JWT 身份"写入"请求
在 Envoy 中,jwt_authn过滤器(全名envoy.extensions.filters.http.jwt_authn)负责验证请求携带的 JWT,验证通过后可以把令牌中的可信信息透传给上游服务。它主要通过两种机制实现(定义见 api/envoy/extensions/filters/http/jwt_authn/v3/config.proto):
| 配置字段 | 行为 | 所在定义 |
|---|---|---|
forward_payload_header | 将验证成功的 JWT 载荷以base64url_encoded(jwt_payload_in_JSON)的格式写入指定头部,转发给后端;未指定则不转发 | config.proto#L256-L263 |
claim_to_headers | 将 JWT 中指定的 claim 复制到 HTTP 头部;string/int/double/bool 类型原样复制,array/object 类型序列化为 JSON 后 Base64 编码 | config.proto#L359-L372 |
其中claim_to_headers是可重复字段,每个条目由JwtClaimToHeader消息描述(config.proto#L907-L961),包含三个关键字段:
header_name:承载 claim 的 HTTP 头部名,该头部专为 JWT claim 保留,任何其他值都会被覆盖;claim_name:要复制的 claim 名称,按.分割以支持嵌套(如nested.claim.key);claim_path:以显式段列表指定路径,用于处理 claim 名本身含点号(如 URL 命名空间形式的 OIDC claim)的场景。claim_name与claim_path必须且只能设置一个。
一个典型配置(摘自 docs/root/configuration/http/http_filters/_include/jwt-authn-claim-filter.yaml#L32-L41):
http_filters: - name: envoy.extensions.filters.http.jwt_authn typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication providers: provider_name2: issuer: https://example2.com claim_to_headers: - header_name: x-jwt-claim-sub claim_name: sub - header_name: x-jwt-claim-nested-key claim_name: nested.claim.key - header_name: x-jwt-tenants claim_name: tenants local_jwks: inline_string: "..." # 本地内联 JWKS验证成功后,这些头部会携带如下格式的值发往上游(docs/root/configuration/http/http_filters/jwt_authn_filter.rst#L215-L221):
x-jwt-claim-sub: <JWT Claim> x-jwt-claim-nested-key: <JWT Claim> x-jwt-tenants: <Base64 encoded JSON JWT Claim>安全前提:上游服务依赖这些头部时,默认它们"只可能是 Envoy 在验证通过后写入的可信值"。这一前提正是本次变更要保护的。
旧行为的安全缺陷:Sanitize 只发生在验证器内部
本次变更记录明确指出了旧实现的两类问题(变更记录原文):
Previously those headers were sanitized only inside the matched verifier, so paths that bypassed verification (empty
requires, per-routedisabled, or CORS preflight bypass) could forward client-supplied values upstream, and a request authenticated by one provider could retain spoofed payload/claim headers configured on another provider.
归纳为两个具体漏洞场景:
场景一:绕过验证路径直接透传客户端伪造值
以下三种路径不需要任何 JWT 验证即可放行(返回Continue),但在旧实现中不会触发头部清理:
- 空的
requires:RequirementRule没有设置requirement_type时,匹配后直接放行; - per-route
disabled:路由级配置通过PerRouteFilterConfig将过滤器标记为disabled(对应 filter_config.cc 中findPerRouteVerifier返回空验证器); - CORS preflight 绕过:
bypass_cors_preflight启用且当前请求是 preflight 时直接放行(filter.cc#L64-L72)。
在这些路径上,客户端可以预先自行设置x-jwt-claim-sub、x-jwt-claim-nested-key等保留头,旧版本 Envoy 会原样转发给上游。而上游无法区分该值究竟来自 Envoy 的可信写入还是客户端伪造——身份伪装攻击(impersonation)就此成立。
场景二:多 Provider 间的头部"串台"
当一个过滤器配置了多个 provider,且各自配置了不同的forward_payload_header/claim_to_headers时:请求被 provider A 认证通过后,A 的验证器只会清理/写入自己配置的头部;而 provider B 配置的那些头部,其客户端伪造值仍残留在请求中,随 provider A 的认证结果一起被放行上游,造成"一个提供方认证的请求却携带另一个提供方配置的伪造头部"的混乱状态。
新行为:Filter-Wide 头部清理
变更后的行为一句话概括(变更记录原文):
The
jwt_authnHTTP filter now strips every configuredforward_payload_headerandclaim_to_headersheader name from the requestbeforeapplying rules.
即:在应用任何匹配/验证规则之前,过滤器就把"所有 provider 配置的forward_payload_header与claim_to_headers头部名"从请求中无条件剥离。这些头部从此真正成为"仅由 Envoy 在验证通过后写入的保留头",客户端在任何路径上都无法携带它们穿透过滤器。
源码级实现剖析
1. 一次构建:收集所有需要清理的头部名
在过滤器配置构造阶段(source/extensions/filters/http/jwt_authn/filter_config.cc#L65-L69),实现代码显式注释了设计意图:
// Union of every provider's forward_payload_header and claim_to_headers names. Built once so // Filter::decodeHeaders can sanitize before any verifier bypass path returns Continue. if (!all_providers.empty()) { header_sanitizer_ = Extractor::create(all_providers); }这里基于全部 provider 的并集创建了一个Extractor实例(存于header_sanitizer_,声明见 filter_config.h#L161)。注意"并集"这个细节——它正是为了修复场景二的多 provider 串台问题:每个 provider 配置的保留头都会被纳入清理名单,而不仅是"当前匹配到的那个"。
ExtractorImpl::addProvider负责收集头部名(source/extensions/filters/http/jwt_authn/extractor.cc#L229-L235):
if (!provider.forward_payload_header().empty()) { headers_to_sanitize_.emplace_back(provider.forward_payload_header()); } for (const auto& header_and_claim : provider.claim_to_headers()) { headers_to_sanitize_.emplace_back(header_and_claim.header_name()); }forward_payload_header非空即收集;claim_to_headers中每一个条目的header_name都收集,统一存入std::vector<LowerCaseString> headers_to_sanitize_(extractor.cc#L200),以大小写无关形式存储,确保 HTTP 头名匹配不区分大小写。
2. 早于一切分支:decodeHeaders 第一步即清理
关键调用点在过滤器入口 source/extensions/filters/http/jwt_authn/filter.cc#L52-L62:
Http::FilterHeadersStatus Filter::decodeHeaders(Http::RequestHeaderMap& headers, bool) { ... // Sanitize before any bypass decision when the reloadable feature is enabled (default). // Payload and claim headers are reserved for values this filter writes after verification; // leaving client-supplied values in place on no-verifier paths (empty requires, per-route // disabled, CORS preflight) would forward spoofed identity upstream. config_->sanitizePayloadHeaders(headers); ... }这段代码的注释直接复述了本次变更的安全动机:payload 与 claim 头专为过滤器验证后写入的值保留;在无验证器路径上(空的requires、per-routedisabled、CORS preflight)如果保留客户端原值,就会把伪造身份转发到上游。因此清理必须先于所有绕过决策执行——这正是本次"filter-wide"语义的核心:无论后续走哪条分支,头部都已被处理。
3. 运行时开关与最终剥离动作
sanitizePayloadHeaders的实现(source/extensions/filters/http/jwt_authn/filter_config.h#L96-L105):
void sanitizePayloadHeaders(Http::RequestHeaderMap& headers) const override { // Behavior change vs pre-filter-wide sanitization: guard so operators can // disable during rollout if a deployment relied on client-supplied payload // / claim headers on bypass paths. if (header_sanitizer_ != nullptr && Runtime::runtimeFeatureEnabled( "envoy.reloadable_features.jwt_authn_sanitize_payload_headers_filter_wide")) { header_sanitizer_->sanitizeHeaders(headers); } }最终剥离动作非常简单直接(source/extensions/filters/http/jwt_authn/extractor.cc#L347-L351):
void ExtractorImpl::sanitizeHeaders(Http::RequestHeaderMap& headers) const { for (const auto& header : headers_to_sanitize_) { headers.remove(header); } }遍历预先收集的头部名并逐一remove。这保证了:如果后续验证失败或走绕过路径,这些头在到达上游前已被删除;如果验证成功,则由验证器在认证后重新写入可信值(claim_to_headers的语义仍是"已有其他值则替换为 claim 值",见 jwt_authn_filter.rst#L202)。
测试验证:绕过路径必须清理
仓库中的单元测试直接覆盖了本次变更的两个关键绕过场景(test/extensions/filters/http/jwt_authn/filter_test.cc):
测试一:无匹配规则也要清理(filter_test.cc#L316-L327)
// Bypass paths must still sanitize payload/claim headers before Continue. TEST_F(FilterTest, TestNoRequirementMatchedSanitizesPayloadHeaders) { EXPECT_CALL(*mock_config_.get(), sanitizePayloadHeaders(_)) .WillOnce(Invoke([](Http::RequestHeaderMap& headers) { headers.remove(Http::LowerCaseString("x-jwt-claim-sub")); })); EXPECT_CALL(*mock_config_.get(), findVerifier(_, _)).WillOnce(Return(nullptr)); auto headers = Http::TestRequestHeaderMapImpl{{"x-jwt-claim-sub", "spoofed"}}; EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(headers, false)); EXPECT_FALSE(headers.has("x-jwt-claim-sub")); EXPECT_EQ(1U, mock_config_->stats().allowed_.value()); }模拟请求携带伪造的x-jwt-claim-sub: spoofed,验证器匹配返回nullptr(即无 requirement 放行路径),断言最终Continue后该头部已被移除。
测试二:per-route disabled 绕过也要清理(filter_test.cc#L330-L347)
// Per-route disabled bypass must sanitize before Continue. TEST_F(FilterTest, TestPerRouteBypassSanitizesPayloadHeaders) { ... EXPECT_CALL(*mock_config_.get(), findPerRouteVerifier(_)) .WillOnce(Return(std::make_pair(nullptr, EMPTY_STRING))); EXPECT_CALL(*mock_config_.get(), sanitizePayloadHeaders(_)) .WillOnce(Invoke([](Http::RequestHeaderMap& headers) { headers.remove(Http::LowerCaseString("sec-istio-auth-userinfo")); })); auto headers = Http::TestRequestHeaderMapImpl{{"sec-istio-auth-userinfo", "spoofed"}}; EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(headers, false)); EXPECT_FALSE(headers.has("sec-istio-auth-userinfo")); EXPECT_EQ(1U, mock_config_->stats().allowed_.value()); }findPerRouteVerifier返回空验证器(对应 per-routedisabled的路径,与 filter_config.cc#L126-L130 中per_route.config().disabled()的处理一致),断言sec-istio-auth-userinfo(一个常见的服务网格场景自定义头)的伪造值同样被清除。
两个测试的测试名注释都写着 "Bypass paths must still sanitize payload/claim headers before Continue",精确对应变更记录中列出的绕过路径。
运行时开关:如何控制与回滚
本次变更由 reloadable feature(可重载特性)控制,定义于 source/common/runtime/runtime_features.cc#L100:
RUNTIME_GUARD(envoy_reloadable_features_jwt_authn_sanitize_payload_headers_filter_wide);| 属性 | 值 |
|---|---|
| 特性名 | envoy.reloadable_features.jwt_authn_sanitize_payload_headers_filter_wide |
| 默认值 | true(变更默认开启) |
| 关闭方式 | 在 bootstrap 的runtime层中设置该 key 为false |
默认开启意味着升级后行为立即收紧:所有配置了forward_payload_header/claim_to_headers的jwt_authn过滤器,无论请求走何种路径,客户端注入的这些保留头都会被剥离。实现注释(filter_config.h#L97-L99)明确说明了开关的用途:如果某个部署此前在绕过路径上依赖客户端提供的 payload/claim 头(一种不安全但确实存在过的用法),运维方可以在滚动升级期间临时关闭该特性以保持行为一致,待业务侧适配后再开启。
从变更分类看,它属于"minor behavior change"而非 bug fix 或 breaking change——因为对绝大多数正确使用jwt_authn的部署而言,行为只会变得更安全;只有那些依赖不安全旧语义的边缘部署才可能受到影响,这正是提供回滚开关的原因。
升级建议与最佳实践
- 检查配置:梳理所有
jwt_authn过滤器配置中的forward_payload_header与claim_to_headers,确认上游服务对这些头的消费逻辑——它们必须被当作"仅可信来源"处理。 - 确认无绕过依赖:审查是否存在依赖
empty requires、per-routedisabled或 CORS preflight 路径携带自定义x-jwt-*头的行为;若有,升级前需改造。 - 多 provider 场景:如果同一过滤器配置多个 provider 且各自定义了不同的保留头,升级后任一 provider 配置的保留头都会被全量剥离——这是修复后的预期行为,上游不应再收到任何 provider 的伪造值。
- 滚动升级:默认值即为安全值,无需额外开启;只有确认受影响时才通过 runtime 层临时
false回滚,并尽快重新开启。
延伸阅读
- 完整配置文档:docs/root/configuration/http/http_filters/jwt_authn_filter.rst(含 claim_to_headers 嵌套 claim、claim_path 用法及统计数据说明)
- 完整可运行示例:docs/root/configuration/http/http_filters/_include/jwt-authn-claim-filter.yaml
- Proto API 定义:api/envoy/extensions/filters/http/jwt_authn/v3/config.proto
- 过滤器核心实现:source/extensions/filters/http/jwt_authn/filter.cc、source/extensions/filters/http/jwt_authn/filter_config.cc、source/extensions/filters/http/jwt_authn/extractor.cc
- 单元测试:test/extensions/filters/http/jwt_authn/filter_test.cc
- 变更记录原文:changelogs/current/minor_behavior_changes/jwt_authn__sanitize-payload-headers-filter-wide.rst
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考