
当 API 网关返回 401 的请求只在路径末尾多了一个/后就变成了 200安全审查就该紧张了。这类行为通常被称为 trailing slash bypass也就是“尾随斜杠绕过”。在 AWS API Gateway 项目里这个问题并不少见尤其是当 Lambda 授权器依赖event.path做权限判断时一个尾随斜杠足够改变授权结果。这篇文章从一个实际场景出发带你看清尾随斜杠为什么能绕过 Lambda 授权器并给出可复现的配置、日志排查方式、修复方案和生产加固建议。你会看到这不是 AWS 平台的漏洞而是授权器对路径语义处理不一致导致的配置缺陷。文章适用于 API Gateway 开发、安全工程师以及正在排查授权失效问题的后端开发者。1. 先理解尾随斜杠绕过路由与授权器判断为什么会出现偏差1.1 网关把尾随斜杠当成“真实路径”但资源级别并不总是区分API Gateway REST API 中如果只创建了一个/admin资源那么直接请求/admin/通常不会命中/admin下的方法。这样请求/admin/会得到类似Missing Authentication Token的 403而不是授权器返回的 401。真正容易出问题的是另一种写法使用/{proxy}这类代理资源把不同路径全部转发到同一个后端。当 API Gateway 配置了/{proxy}并挂接 Lambda 授权器后/admin和/admin/都会路由到同一个集成函数。区别只是请求路径字段不同后端和授权器都能通过event.path看到差异。这个问题真正的危险点在于网关层已经放行了两条路径授权器却只对其中一种路径做了权限判断。一个典型的路径字段差异如下表event 字段请求/admin请求/admin/event.path/admin/admin/event.resource/{proxy}/{proxy}event.pathParameters.proxyadminadmin/event.requestContext.path/admin/admin/event.httpMethodGETGET从 API Gateway 的角度看两者都是合法请求只是路径值不同。授权器如果“看路径”决定放行还是拒绝就必须对路径做足够严格的规范化处理否则就会出现边界绕过。1.2 授权器常见路径判断方式及其风险在 Lambda 授权器里判断路径的方式通常有三种精确相等判断。前缀判断。正则匹配。精确相等判断最容易出现尾随斜杠绕过。例如代码如下def lambda_handler(event, context): path event.get(path, ) if path /admin: # 只有精确匹配 /admin 才进入管理员权限判断 ...当请求路径变成/admin/时path /admin不成立授权器很可能落到“默认允许”分支。如果这个默认分支是放行攻击就会成功。前缀判断也有风险但风险点不同。path.startswith(/admin)会把/admin-evil、/administrator这类路径一并放行虽然尾随斜杠本身不再绕过但会引入更宽泛的权限问题。path.startswith(/admin/)可以匹配/admin/但判断语义不够清晰维护时非常容易出错。正则匹配^/admin$的问题和精确匹配相同/admin/不在匹配范围内。此外正则表达式出现边界符号遗漏时还会放过 URL 编码、重复斜杠等变体。所以问题并不仅仅是“多了一个斜杠”而是授权器使用了不完整的路径语义。任何没有经过统一规范化的字符串匹配都可能在网关允许的路径变体上出现缺口。1.3 这是配置错误不是 AWS 自身漏洞AWS API Gateway 不会在默认情况下把/admin和/admin/自动转成同一个标准路径。日志里记录什么后端事件里传入什么都是原始请求路径。即便网关有能力把两个路径路由到同一个 Lambda它也不会自动修正授权器内部的判断逻辑。这意味着安全责任在应用侧。授权器是执行访问控制的一道关键边界它必须主动处理尾随斜杠、重复斜杠、URL 编码等路径变体。只依赖“API Gateway 会帮我统一路径”的假设迟早会在某个边界请求上出问题。注意尾随斜杠绕过的本质不是网关漏洞而是授权器对路径的判断规则覆盖不完整。排查时不要只盯着网关配置先看授权器收到的路径值和默认分支。2. 最小复现在 Lambda 授权器里留下一个默认放行分支2.1 创建 API Gateway REST API 与/{proxy}资源先搭建一个最小复现环境。常见方案是使用 AWS 管理控制台或 CloudFormation/SAM 部署创建一个 REST API。创建资源/{proxy}。在/{proxy}上配置ANY方法。将方法集成指向一个后端 Lambda。为ANY /{proxy}方法启用 Lambda 授权器。如果使用 SAM 模板核心资源定义可以这样理解Resources: ApiGateway: Type: AWS::Serverless::Api Properties: StageName: prod Auth: DefaultAuthorizer: PathAuth Authorizers: PathAuth: FunctionArn: !GetAtt AuthFunction.Arn Identity: Headers: - Authorization AuthFunction: Type: AWS::Serverless::Function Properties: CodeUri: auth/ Handler: index.lambda_handler Runtime: python3.12 ProxyFunction: Type: AWS::Serverless::Function Properties: CodeUri: backend/ Handler: index.lambda_handler Runtime: python3.12 Events: ApiEvent: Type: Api Properties: RestApiId: !Ref ApiGateway Path: /{proxy} Method: ANY上面的模板省略了大量生产环境需要的权限和日志配置但足够说明结构所有请求路径都进入同一个 API 方法所有请求都由同一个 Lambda 授权器做判断。2.2 Lambda 授权器缺陷代码下面这段授权器代码模拟了典型的错误写法。它只判断/admin是否精确匹配其他路径全部默认放行。import json def generate_policy(principal_id, effect, method_arn): return { principalId: principal_id, policyDocument: { Version: 2012-10-17, Statement: [ { Action: execute-api:Invoke, Effect: effect, Resource: method_arn } ] } } def lambda_handler(event, context): path event.get(path, /) user event.get(requestContext, {}).get(authorizer, {}).get(principalId) # 错误点只精确匹配 /admin所有其他路径默认放行 if path /admin: if user ops: return generate_policy(user, Allow, event[methodArn]) return generate_policy(user, Deny, event[methodArn]) # 默认放行大量路径包括 /admin/ return generate_policy(default, Allow, event[methodArn])这段代码的问题很明显授权器的最终决策依赖字符串比较结果。路径/admin会进入权限判断路径/admin/则落入默认 Allow。这个示例说明了一个非常重要的原则授权器不能设计成“只拦截已知危险路径”而应该设计成“默认拒绝只放行明确允许的路径”。默认放行是绕过问题的温床。2.3 请求验证与预期结果部署完成后用命令行验证两个请求curl -i https://你的执行地址/prod/admin \ -H Authorization: Bearer test-token curl -i https://你的执行地址/prod/admin/ \ -H Authorization: Bearer test-token假设当前用户不是ops预期结果如下请求路径预期状态码说明/admin401授权器返回 DenyAPI Gateway 拒绝调用后端/admin/200授权器落入默认 AllowAPI Gateway 调用后端如果你用同一个请求路径去测试后端 Lambda会发现后端的event.path确实带有尾随斜杠。也就是说授权器和后端看到同一份路径值但授权器错误地漏掉了带斜杠的格式。这个最小复现一旦通过就可以进入排查阶段确认到底是哪一层导致 401 没有触发。3. 用日志和脚本定位“为什么 401 没有生效”3.1 开启访问日志先看网关层状态码排查授权问题时第一件事是确认 401 到底是 API Gateway 返回的还是后端业务返回的。开启 API Gateway 访问日志可以得到最直接的证据。在 REST API 的 Stage 配置中可以配置 CloudWatch Log Group并设置日志格式为 JSON。{ requestId: $context.requestId, ip: $context.identity.sourceIp, requestTime: $context.requestTime, httpMethod: $context.httpMethod, path: $context.path, status: $context.status, responseLatency: $context.responseLatency, authorizeStatus: $context.authorizeStatus }$context.authorizeStatus是排查授权器问题的关键字段。当 API Gateway 执行 Lambda 授权器失败或返回 Deny 时该字段会记录授权结果状态。访问日志中的两条典型记录{ requestId: xxxx-1111, path: /admin, httpMethod: GET, status: 401, authorizeStatus: DENY }{ requestId: xxxx-2222, path: /admin/, httpMethod: GET, status: 200, authorizeStatus: ALLOW }看到这两种记录就能确认网关确实把/admin/请求交给了授权器授权器最终返回了 Allow。此时问题范围已经缩小到授权器内部的判断逻辑。3.2 在授权器里打印 event 关键字段为了进一步确认授权器为什么把/admin/当成允许路径可以临时在授权器里增加日志输出。import json def lambda_handler(event, context): print(AUTHORIZER_EVENT_PATH, event.get(path)) print(AUTHORIZER_METHOD_ARN, event.get(methodArn)) print(AUTHORIZER_RAW_PATH, event.get(requestContext, {}).get(path)) path event.get(path, /) ...一次/admin/请求会在 CloudWatch Logs 中看到类似输出{ path: /admin/, methodArn: arn:aws:execute-api:us-east-1:123456789012:api-id/prod/GET/, rawPath: /admin/ }从日志中可以直观确认授权器收到的路径就是/admin/没有经过尾随斜杠处理。接下来需要批量验证哪些路径变体会被放行。3.3 用路径矩阵批量验证边界情况单次 curl 足够发现现象但不足以说明绕过范围。建议写一个简单的 Bash 脚本把所有路径变体跑一遍。#!/usr/bin/env bash BASEhttps://你的执行地址/prod TOKENtest-token for path in admin admin/ admin// admin%2f ADMIN; do code$(curl -s -o /dev/null -w %{http_code} $BASE/$path \ -H Authorization: Bearer $TOKEN) echo GET /$path - $code done输出示例GET /admin - 401 GET /admin/ - 200 GET /admin// - 200 GET /admin%2f - 401 GET /ADMIN - 401路径矩阵结果能反映出授权器对 URL 变异路径的容忍度。斜杠数量、URL 编码、大小写都可能产生不同结果。用脚本批量测试比手工一个个请求更稳定也方便后续加入回归测试。注意路径变体测试要使用测试环境的只读接口不要直接在生产环境发起大量无效请求。否则会污染指标也会触发不必要的告警。4. 修复方案先规范化路径再做权限判断4.1 路径规范化规则与实现修复的核心理念是在授权器进行路径比较之前先把各种等效路径转换成同一个标准格式。规范化规则建议如下只处理 path 部分不要带 query string。对 path 做 URL 解码处理%2F、%2f等编码。压缩连续重复斜杠。去掉末尾斜杠但根路径/要保持为/。大小写是否统一需要结合业务路由规则决定。下面是一段 Python 示例import re from urllib.parse import unquote def normalize_path(path): if not path: return / # 去掉 query string 和 fragment path path.split(?)[0].split(#)[0] # 处理 URL 编码 path unquote(path) # 压缩连续斜杠 path re.sub(r/, /, path) # 去掉末尾斜杠但保留根路径 / if path ! / and path.endswith(/): path path.rstrip(/) return path这个函数执行后/admin变成/admin。/admin/变成/admin。/admin//变成/admin。/admin%2f变成/admin/再变成/admin。/仍然是/。规范化不是万能的但它能消除大量由路径写法差异引发的安全边界问题。4.2 把默认放行改成默认拒绝并显式放行白名单规范化之后还需要调整授权器结构。推荐把授权器改成“默认拒绝显式允许”。修正后的授权器示意import re from urllib.parse import unquote def normalize_path(path): if not path: return / path path.split(?)[0].split(#)[0] path unquote(path) path re.sub(r/, /, path) if path ! / and path.endswith(/): path path.rstrip(/) return path def generate_policy(principal_id, effect, method_arn): return { principalId: principal_id, policyDocument: { Version: 2012-10-17, Statement: [ { Action: execute-api:Invoke, Effect: effect, Resource: method_arn } ] } } def lambda_handler(event, context): raw_path event.get(path, /) normalized_path normalize_path(raw_path) user event.get(requestContext, {}).get(authorizer, {}).get(principalId) print(RAW_PATH, raw_path) print(NORMALIZED_PATH, normalized_path) # 默认拒绝 if normalized_path /admin: if user ops: return generate_policy(user, Allow, event[methodArn]) return generate_policy(anonymous, Deny, event[methodArn]) # 其他路径如果需要放行必须显式列入白名单 allowed_prefixes [/public, /health, /api-docs] for prefix in allowed_prefixes: if normalized_path prefix or normalized_path.startswith(prefix /): return generate_policy(anonymous, Allow, event[methodArn]) return generate_policy(anonymous, Deny, event[methodArn])修复后/admin/会被规范化为/admin进入管理员权限判断其他未知路径默认拒绝不会因为写法不同就绕过授权。这里的要点是不要用一个“默认允许”分支兜底而是把需要在 API Gateway 后无鉴权访问的接口显式加入白名单。默认拒绝虽然会给开发联调增加一点成本但安全收益远大于便利性损失。4.3 网关前的可选重写层与 REST/HTTP API 差异有些团队不希望在每个授权器里都写路径规范化而是想在 API 网关前把尾随斜杠统一去掉。这个思路可行但要注意引入新问题。在 API Gateway 前加 CloudFront 时可以配置 LambdaEdge 修改请求 URIdef lambda_handler(event, context): request event[Records][0][cf][request] uri request.get(uri, /) if len(uri) 1 and uri.endswith(/): request[uri] uri.rstrip(/) return request这样进入 API Gateway 的请求已经去掉了尾随斜杠。但这个方案会增加链路复杂度也需要处理重定向语义、缓存、回源等多个细节。另一个要确认的点是团队使用的是 REST API 还是 HTTP API。REST API 和 HTTP API 在资源匹配、路由表、日志字段命名上存在差异。在 REST API 中{proxy}资源很常见在 HTTP API 中路由配置和event.rawPath的语义不同。不要把 REST API 的排查经验直接套用到 HTTP API 上迁移之前需要先在目标网关环境里测一遍路径行为。场景建议做法注意事项REST API在 Lambda 授权器内做路径规范化event.path是主要判断字段HTTP API在 Lambda 授权器内同时留意event.rawPath路由语义和 REST API 不同API Gateway 前有 CloudFront可在边缘去除尾随斜杠注意重定向、缓存和 header 处理多团队共用网关优先把授权器做成平台统一组件避免每个业务各自实现路径判断5. 生产加固把路径变体纳入安全测试和监控5.1 建立路径变换测试矩阵修复一个绕过之后需要防止它再次出现。建议把路径变体整理成测试矩阵每次修改授权器或路由配置时都跑一遍。路径测试矩阵至少应该覆盖输入路径期望授权结果说明/adminDeny正常路径/admin/Deny尾随斜杠/admin//Deny重复斜杠/admin%2fDenyURL 编码斜杠/admin%2FDeny编码斜杠大写形式/ADMIN按业务约定大小写规则/admin?x1Denyquery string 不影响权限判断/admin#fragDenyfragment 不应参与权限判断/admin/../adminDeny路径穿越常见变体/./adminDeny相对路径变体需要注意的是API Gateway 或前端代理可能已经处理了部分变体测试结果要以实际网关行为为准。测试矩阵的目的是把当前环境的行为固定下来一旦行为变化就能及时发现。5.2 在 CI/CD 中加入自动化安全回归手工 curl 适合排查不适合长期维护。推荐把路径矩阵加入 CI/CD 流程。下面是一个 Python 脚本片段用来遍历路径矩阵并断言状态码import requests def run_path_matrix(base_url, token, expected_data): headers {Authorization: fBearer {token}} failed [] for path, expected_status in expected_data.items(): resp requests.get(f{base_url}{path}, headersheaders, timeout5) if resp.status_code ! expected_status: failed.append( { path: path, expected: expected_status, actual: resp.status_code, } ) if failed: for item in failed: print(FAIL, item) raise SystemExit(1) print(ALL_PATH_CASES_PASSED) if __name__ __main__: base https://你的执行地址/prod token test-token cases { /admin: 401, /admin/: 401, /admin//: 401, /admin%2f: 401, /admin?debug1: 401, } run_path_matrix(base, token, cases)这个脚本可以直接放入 GitHub Actions、GitLab CI 或 Jenkins。测试失败时立即阻断构建避免带病上线的授权器悄悄影响生产。5.3 日志监控与异常告警授权器修复之后还要建立监控尽早发现异常访问模式。推荐在授权器里增加两类埋点打印原始路径和规范化路径。打印最终授权结果是 Allow 还是 Deny。同时在 CloudWatch 中设置告警请求/admin/返回 200 但/admin返回 401 时告警。同一路径前缀在很短时间内出现大量不同斜杠/编码变体时告警。授权器默认 Deny 后仍然出现 200需要立刻关注。告警条件要和业务场景匹配。如果只是开发环境告警频率过高会让人忽略如果只监控生产环境又可能在开发阶段就积累错误配置。建议测试环境和生产环境分别采用不同的阈值。{ source: [aws.apigateway], log_group_name: /aws/apigateway/my-api-access-log, metric_name: TrailingSlashAccessCount, metric_value: 1, filter_pattern: $.path /admin/ $.status 200 }这里用 CloudWatch Logs 的 filter pattern 举例。实际配置时需要根据日志字段命名调整。6. 其他容易踩中的边界问题6.1 URL 编码、大小写和重复斜杠尾随斜杠只是路径边界问题的一种。实际项目里URL 编码、大小写、重复斜杠、路径穿越符号都可能造成授权判断不一致。例如/admin%2F在某些网关里会被解码为/admin/。如果授权器只处理了path.endswith(/)却没有处理 URL 编码仍然可能被绕过。又比如大小写问题如果网关对大小写不敏感而后端授权器判断敏感就会造成“网关能路由授权器不匹配”的裂缝。处理这些问题的通用方案是在授权器里统一进行 URL 解码、斜杠合并、大小写策略处理后再与权限规则匹配。任何路径比较都不能基于原始输入直接做字符串判断。6.2 授权逻辑的集中化设计随着 API 数量增长每个 Lambda 授权器各写一套路径判断逻辑很容易出现规则不一致。建议把授权逻辑集中到同一个公共函数或独立服务中路径规范化也放在同一层。这样做的收益是权限规则只有一份实现修改时不会漏掉某个 API。路径规范化逻辑统一不存在“这个授权器处理了斜杠另一个没处理”的问题。新增接口时只需要在规则表里增加配置不需要改动各个 API 的授权器代码。例如可以把受保护路径写在一个配置文件中{ protectedPaths: [ /admin, /internal ], defaultEffect: Deny }授权器加载这份配置后对所有请求统一执行规范化再判断路径是否命中受保护列表。这样即使未来新增/admin/变体也会因为规范化逻辑相同而得到一致结果。6.3 本案例的可复用结论尾随斜杠绕过给出的最大教训是访问控制的结果不能依赖调用方的“规范写法”。任何人都会在某个时刻多打一个斜杠、多写一个编码字符或者自动补全工具生成一个带斜杠的 URL。系统必须主动把输入统一成标准形态后再执行安全判断。在 AWS API Gateway 项目中可以围绕以下几点做长期加固Lambda 授权器默认拒绝显式放行。所有路径判断前执行规范化处理。禁止直接把event.path与字符串常量做精确比较。路径矩阵测试进入 CI/CD。访问日志记录原始路径、规范化路径和授权结果。生产环境建立异常访问监控和告警。尾随斜杠本身并不可怕可怕的是它暴露出访问控制链路中的盲区。把每一次授权边界问题当成一次路径语义审查的机会后续类似的问题才不会被遗漏。