Envoy jwt_authn 过滤器安全加固:Filter-Wide Payload/Claim 头部清理机制深入解析
Envoy jwt_authn 过滤器安全加固Filter-Wide Payload/Claim 头部清理机制深入解析【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy导读Envoy 的jwt_authnJWT 认证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-L263claim_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.keyclaim_path以显式段列表指定路径用于处理 claim 名本身含点号如 URL 命名空间形式的 OIDC claim的场景。claim_name与claim_path必须且只能设置一个。一个典型配置摘自 docs/root/configuration/http/http_filters/_include/jwt-authn-claim-filter.yaml#L32-L41http_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-L221x-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 (emptyrequires, 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但在旧实现中不会触发头部清理空的requiresRequirementRule没有设置requirement_type时匹配后直接放行per-routedisabled路由级配置通过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 头部清理变更后的行为一句话概括变更记录原文Thejwt_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 providers 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-L235if (!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::vectorLowerCaseString headers_to_sanitize_extractor.cc#L200以大小写无关形式存储确保 HTTP 头名匹配不区分大小写。2. 早于一切分支decodeHeaders 第一步即清理关键调用点在过滤器入口 source/extensions/filters/http/jwt_authn/filter.cc#L52-L62Http::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-L105void 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-L351void 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#L100RUNTIME_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.yamlProto 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),仅供参考