mcpField
A
aegis-authz-access-control

aegis-authz-access-control

github.com/as-foss安全16145 次安装9 周前更新
代码仓库

关于 aegis-authz-access-control

用于审查应用代码中认证后的授权决策的安全审计技能,帮助开发者逐路由检查功能、对象、属性、租户隔离及绕过漏洞。

主要功能

  1. 01检查功能级访问控制
  2. 02追踪对象与租户归属
  3. 03审查属性级权限
  4. 04发现路径和请求头绕过
  5. 05检查委派与身份模拟

适用场景

  1. 01审计 API 路由的角色检查
  2. 02查找对象查询中的 IDOR 风险
  3. 03检查多租户数据隔离
  4. 04评估 API 密钥与模拟权限范围

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/as-foss/aegis-skills --skill aegis-authz-access-control

安装后在 Claude Code 中输入/aegis-authz-access-control确认已加载。

附带文件references/background.mdreferences/further-reading.md

详细介绍

范围与目标

扫描授权失败:代码错误地判断谁可以调用某项操作(BFLA、垂直权限提升),以及某项操作可以访问哪些对象实例(BOLA/IDOR、水平权限提升)。

范围内:

  • 路由、处理程序、解析器、控制器中缺失或错误的角色/权限检查。
  • 根据请求提供的 ID 获取对象,却未附加所有权/租户条件。
  • 多租户隔离缺陷(租户上下文来自输入、共享缓存/队列/索引)。
  • 授权逻辑错误:缺省拒绝、OR 条件拼写错误、HTTP 方法篡改。
  • 路径规范化和代理标头绕过(信任 X-Original-URL、X-Forwarded-For)。
  • 缺少范围限制或审计的模拟身份/API 密钥/服务信任功能。

范围外(其他模块):身份验证强度、会话固定、注入、SSRF、加密。此处仅审计身份验证后的决策逻辑;若特权操作可被未验证身份访问,则将其标记为 BFLA。

前提与术语

零基础入门:本模块术语,每个术语一行。每类问题的详细通俗解释请参阅仓库中的 GUIDE.md 术语表。

  • BFLA:路由从未检查你的角色,因此你可以访问仅限管理员的操作
  • BOLA / IDOR:更改请求中的标识符(订单 1042 → 1043),以访问其他用户的对象
  • 水平权限提升:在权限级别不变的情况下操作他人的数据
  • 垂直权限提升:执行更高角色专属的操作(普通用户 → 管理员)
  • 租户:共享多客户系统中属于某一客户的隔离区域
  • 缺省拒绝:不存在明确的允许规则时,答案必须是拒绝——缺少检查就是发现的问题,而不是“应该没问题”
  • 污点流/源→汇:不可信数据从入口传播到危险函数的路径
  • 发现状态:已确认 > 可能 > 需复核;证据规则见 templates/finding-report.md

思维模型

沿两个相互独立的维度评估每条路由;只有两个维度都通过,路由才安全。

维度问题失败情况主要 CWE
功能(操作)此主体能否调用此端点?BFLA / 垂直权限提升CWE-862、CWE-863
对象(实例)如果上面的问题答案是“能”,它能否访问此行/文件/租户?BOLA / IDOR / 跨租户泄露CWE-639
属性它能否读取/写入对象的这些字段?过度暴露数据、大规模赋值CWE-285

逐条路由的决策流程:

  1. 确认主体来源:会话、已验证 JWT 的 sub 声明、API 密钥、mTLS 身份。其他来源(查询参数、user_id 等标头、客户端存储的角色)本身就是危险信号。
  2. 根据业务命名确定所需的角色/操作(admin、/users、/internal、DELETE)。
  3. 找到对象的获取位置,并检查查询是否包含主体条件(WHERE id = ? AND tenant_id = ?)。
  4. 找到决策点(中间件/装饰器/注解/策略),并确认它在任何状态变更或数据返回之前执行。
  5. 不存在明确允许规则时,默认答案为:拒绝。缺少检查就是发现的问题,而不是“应该没问题”。

双账号直觉:对于每个端点,无论调用者是否拥有该对象,行为都必须一致——对象所有者除外,只有所有者能获取数据。

检查内容

函数级授权(BFLA)
  • 枚举所有变更数据的路由(POST/PUT/PATCH/DELETE)以及所有 /admin、/internal、/manage 子树;验证每条路由都带有授权保护,且该保护读取的是服务端身份,而非直接照搬客户端声明。
  • 标记“依靠隐蔽性实现安全”的情况:隐藏 UI 按钮但服务端未检查;仅向管理员渲染 SPA 路由,但后端接口开放;唯一防护是难以猜测的路径片段。
  • 检查 HTTP 方法不对称:控制器对 GET 有授权保护,但通过单独的处理器或兜底路由在同一路径上开放 DELETE/PATCH(例如 app.all('/admin/x', ...) 没有授权保护)。
  • 对比 API 版本:v1 模块已添加注解或授权保护,但重复的 v2 控制器被遗漏。统计各版本目录中的授权保护数量差异。
  • 测试注册/更新负载,检查是否接受角色批量赋值("role":"admin"、"isAdmin":true)并写入用户记录。
  • 检查调试/内部接口:Spring Actuator、Django admin + DEBUG 视图、Flask 调试器、Go pprof、ASP.NET 详细错误,以及生产环境中的 GraphQL 内省 + 变更操作。
对象级授权(BOLA/IDOR)
  • 追踪每个名为 id|uuid|guid|uid|email|slug|ref 的请求参数,直到首次数据库/文档查询;要求在查询中包含 owner_id == subject.id 或 tenant_id == subject.tenant_id 条件,而不是查询后再检查。
  • 标记返回 .all() / find() / Model.objects.all() 并整体序列化的集合/列表接口(批量列出数据会泄露其他租户的记录)。
  • 嵌套资源:对于 /org/:orgId/doc/:docId,验证 doc.orgId == orgId 且调用方属于 orgId。仅检查父资源的成员身份并不足够。
  • 导出/报告/下载/CSV/PDF/流式传输接口:这些接口经常绕过常规序列化器,因而跳过授权保护。搜索 export|download|report|dump|backup|csv|xlsx。
  • GraphQL:检查每个解析器的对象级授权(而不仅仅是顶层授权指令),尤其是嵌套字段(invoice.customer.address)以及按 ID 数组查询的数据加载器/批量加载器。
  • 顺序 ID(AutoField、serial、IDENTITY(1,1)、递增的数字 JSON ID):将其标记为提升可利用性的因素;UUID 只能缓解风险,绝不能替代查询条件。
  • 属性级粒度:字段级 BOLA——记录访问权限正确,但将敏感列(salary、role、SSN 类字段)序列化给无权知悉的调用方;对比不同调用方角色的序列化输出。
  • 批量赋值交叉问题:客户端可写的特权字段("role":"admin")同样属于属性级授权失效;此处发现时,应与 API 模块的批量赋值类别交叉引用。
垂直与水平权限提升
  • 信任存储在客户端的角色:敏感调用接受 JWT 声明(role、isAdmin、permissions),却不从权威存储中重新获取;仅用 localStorage.getItem('role') 控制 UI。
  • 通过强制浏览访问管理面板:公开提供静态管理后台资源包(express.static('admin')),或框架管理后台使用默认路径且认证薄弱或缺失。
  • 版本偏差导致权限提升:v2 加固后仍保留旧版 /api/v1/admin/*;已迁移端点的旧微服务副本仍在部署。
  • 跨租户上下文:从请求正文/查询参数/子域名获取 tenant_id,而不是从已认证主体的记录中获取;基于 Host 的租户隔离可通过直接 IP 访问或覆盖 Host 标头进行伪造。
  • 共享基础设施密钥:缓存键缺少租户前缀(cache.get("invoice:"+id))、全局搜索索引、消费队列消息时不重新检查租户,以及在共享目录中使用原始 ID 命名临时文件。
逻辑、请求头与路径绕过
  • 允许列表与拒绝列表风格:查找以拒绝列表形式编写的检查(if (role === 'banned') return),并在脑中将其反转——新角色会在没有明确配置的情况下继承访问权限。
  • 布尔逻辑错误:role === 'admin' || userId === id 允许管理员访问所有内容,这是正确的;但在否定逻辑中使用 role !== 'admin' || userId !== id,或混淆 &&/||,都会意外放行。标记任何混合角色相等判断与身份相等判断的复合条件。
  • 路径规范化:在解码前根据原始 URL 做路由决策 → %2f、//、/./、尾随点、;jsessionid、双重编码(%252f)会导致守卫检查的路径与处理程序使用的路径不一致。
  • HTTP 方法篡改:守卫按方法单独挂载;尝试 HEAD/OPTIONS/TRACE 或方法覆盖(X-HTTP-Method-Override: DELETE、_method=DELETE 表单参数)。
  • 应用代码信任代理请求头:在路由/访问决策中使用 X-Original-URL、X-Rewrite-URL、X-Forwarded-Host、X-Forwarded-Proto;通过 X-Forwarded-For 解析 IP 允许列表(req.headers['x-forwarded-for'].split(',')[0])——除非应用位于配置为剥离/设置此请求头的受信任代理之后,否则该请求头可被伪造。
  • 框架特定问题:ASP.NET 中控制器上的 [AllowAnonymous] 覆盖了全局回退设置;Laravel 中构造函数缺少 $this->middleware();Rails 中使用 skip_before_action :authenticate_user!。
委托与身份冒用
  • 支持人员/管理员身份冒用(“以客户身份登录”):验证范围限制(设定时限、记录原因)、包含操作者+目标+原因的不可篡改审计日志条目、密码更改时终止会话,并且不得在签发令牌时丢弃原操作者的身份。
  • API 密钥 / PAT 继承了交互式用户的完整权限集,包括管理员角色;检查是否定义了密钥范围,并确认服务器端会强制执行这些范围。
  • 服务间调用:内部端点信任由“某个网关”设置的转发用户名/邮箱请求头(X-User-Email),但没有签名验证;假定内部链路缺少 mTLS。
  • OAuth 令牌交换:忽略 scope 降级;管理员权限撤销后,刷新令牌仍携带原先的广泛权限范围。

查看位置

技术栈路由表守卫标记(存在即为良好)文件/目录
Express/Fastify/NestJSapp.use/router.stack、Nest @Controller 元数据、Fastify printRoutesrequireAuth、passport.authenticate、addHook('preHandler')、@UseGuards(...)、@Roles(...)routes/、controllers/、*.controller.ts、main.ts
Django/DRFurls.py、router.register、django-extensions show_urlsLoginRequiredMixin、PermissionRequiredMixin、permission_classes、DRF get_queryset 过滤器*/urls.py、views.py、viewsets.py、serializers.py
Flask/FastAPI装饰器 @app.route/@get/@post、OpenAPI 规范before_request、@login_required、`Depends(current_userrequire_roles)、路由器级 dependencies=[...]`
Spring(Java/Kotlin)@RequestMapping 树、Actuator /mappings(已受保护!)@PreAuthorize、@Secured、@RolesAllowed、SecurityFilterChain 匹配器、@EnableMethodSecurity**/controller/**、SecurityConfig.*、application.yml
ASP.NET Core控制器 + 最简 API Map* 链[Authorize]、[Authorize(Policy=...)]、.RequireAuthorization()、FallbackPolicy、[AllowAnonymous](逐项审查)Controllers/、Program.cs、Startup.cs
Laravel/Symfonyphp artisan route:list、bin/console debug:routerauth: 中间件、can: 中间件、策略、Gate::authorize、Symfony 投票器 + access_control YAMLroutes/web.php、routes/api.php、app/Policies、config/packages/security.yaml
Railsrails routesbefore_action :authenticate_user!、Pundit authorize @record / after_action :verify_authorized、CanCanCan load_and_authorize_resourceconfig/routes.rb、app/controllers/**、app/policies/**
Go (net/http/chi/gin)mux 注册、chi walk中间件包装器 RequireAuth/RequireRole、gin.Use(...)、组级 r.Group(func(r){ r.Use(guard)})cmd/*/main.go、internal/http/**、middleware/

另需检查:docker-compose.yml/k8s 清单中的仅限内部端口(仅作参考,并非证据)、OpenAPI/Swagger 规范与已实现路由的差异(规范与实现不一致 = 未记录的在线路由)、揭示角色名称的种子数据/固定数据,以及用于发现隐藏管理路由的前端 JS 包(/static/js/*.js 中包含 "/admin" 字符串)。

模式与特征

路由清单(先运行;将数量与守卫标记进行比对):

rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping\(" --type java --type kt
rg -n "@app\.(route|get|post|put|patch|delete)|@(get|post|put|delete)\(" --type py
rg -n "\.(get|post|put|patch|delete|all)\(\s*['\"][^'\"]+" -t ts -t js
rg -n "Route::(get|post|put|patch|delete|any|resource|apiResource)" --type php
rg -n "(map|Map)(Get|Post|Put|Delete|Patch)\(\"" --type cs
rg -n "HandleFunc|\.GET\(|\.POST\(|\.DELETE\(|\.PUT\(" --type go

守卫存在标记:

@(PreAuthorize|Secured|RolesAllowed|PostAuthorize)\(
(LoginRequiredMixin|PermissionRequiredMixin|UserPassesTestMixin)
(@UseGuards|@Roles\(|SetMetadata\(['"](roles|permissions))

缺少守卫的迹象(Express:处理程序是第 2 个参数,且中间没有其他中间件):

\.(get|post|put|patch|delete)\(\s*['"][^'"]*['"]\s*,\s*(async\s*)?\(?[A-Za-z_$]

未绑定主体的对象获取启发式:

objects\.(get|filter)\((pk|id)=
findById\((req|params|pathVariable)[^)]*\)
Model\.objects\.all\(\)

客户端可信身份信号:

(decoded|payload|claims|token)\.(role|isAdmin|is_admin|admin|permissions)
localStorage\.(getItem\(['"]?(role|is_admin|isAdmin|user)['"]?\))
(req\.body|request\.body|payload)\.(role|isAdmin|is_admin|account_type)

标头/路径绕过原语:

(?i)x-(original-url|rewrite-url|forwarded-for|forwarded-host|http-method-override)
headers\[['"](x-forwarded-for|x-real-ip)['"]\]
(_method|X-HTTP-Method-Override|method_override)

模拟身份与委托:

(impersonat(e|ion)|acting_as|act_as|as_user|become_user|sudo_mode)
(api[_-]?key|personal_access_token).{0,40}(scopes?|abilities?)

调试/内部信息暴露:

(/debug/pprof|/actuator|/_profiler|/__profiler|/telescope|/horizon|DEBUG\s*=\s*True)

来自输入的租户上下文:

(tenant_id|org_id|organization_id)['"]?\s*[:=]\s*(req\.(query|params|body)|request\.(GET|data|json))

污点追踪指南

来源(未经证明前均不可信):所有路由参数、查询字符串、请求正文、除已验证的 sub 外的 JWT 载荷声明、请求头(X-*,甚至 Authorization API 密钥)、子域名/Host、消息队列载荷。

汇点(与授权相关):ORM/文档查询(findOne/findById/get/filter/Query.first)、原始 SQL、文件读取/写入/下载、缓存 get/put、队列发布/消费、管理员 RPC 调用、包含其他用户数据的电子邮件/导出内容生成。

流程:

  1. 对于每个获取单条记录的汇点,列出其参数。标记签名中缺少用户/租户/主体参数的任何查询辅助函数——例如,getInvoice(id) 与 getInvoice(id, actor);缺少该参数意味着在后续任何检查之前,上游就可能存在 BOLA。
  2. 从汇点向来源回溯:如果从参数到 WHERE 子句的谓词链未包含已认证主体的属性,则将其标记为潜在 BOLA。
  3. 追踪角色/权限值:如果决策中使用的值来自某个来源(JWT 正文、可通过个人资料更新写入的数据库列、由登录时客户端数据设置的 Redis 会话字段),而非规范用户存储,则将其标记为潜在权限提升。
  4. 对于缓存/队列,追踪键的构造和消费端过滤;写入时带有租户标记、读取时却不进行过滤,仍然存在漏洞。
  5. 授权不存在所谓的净化器——经过验证的整数 ID 仍可能是其他租户的有效 ID。绝不要将验证视为授权。

框架速查:DRF 的 get_queryset() 重写(缺失时会使用基础管理器查询集,导致数据泄露);Rails 的 ApplicationRecord 默认作用域(默认没有);SQLAlchemy 的 session.query(Model).get(id) 不会考虑任何 Python 层面的租户概念,除非添加过滤条件;JPA 的 EntityManager.find(Cls, id) 不会按租户过滤,除非使用 Hibernate 的 @TenantId/@Filter。

利用与复现

双账户 BOLA 测试(标准流程)
  1. 注册受害者和攻击者账户:
    curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"V1ctimPass!"}'
    curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Att4ckerPass!"}'
    
  2. 验证两个账户;保存令牌:
    VICTIM=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"V1ctimPass!"}' | jq -r .token)
    ATTACKER=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Att4ckerPass!"}' | jq -r .token)
    
  3. 受害者创建一个对象;捕获其 ID:
    curl -s -X POST https://target/api/invoices -H "Authorization: Bearer $VICTIM" \
      -H 'Content-Type: application/json' -d '{"amount":100,"memo":"secret-payroll"}'
    # -> {"id":1042,...}   (sequential => enumerate 1030..1050 next)
    
  4. 攻击者请求受害者的对象:
    curl -i https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"
    
  5. 解读结果(这对分类判断很重要):
    结果含义
    200 + 受害者字段("secret-payroll")已确认 BOLA(CWE-639)
    200 + 空/已编辑正文,但与随机 ID 相比响应时间不同存在性预言机;仅部分防护
    403可访问该功能,但对象访问被拒绝——在排除问题前,检查其他 HTTP 方法(curl -i -X PUT/PATCH/DELETE)和嵌套路径
    外部 ID 返回 404,自有 ID 返回 200正确地通过伪装响应防止枚举——确认所有者仍能收到完整记录
  6. 对写入路径(在 PATCH/DELETE 中替换 ID)、集合(GET /api/invoices,不带 ?mine=1)、导出(GET /api/invoices/export.csv)和嵌套形式(GET /org/A/invoices/1042,其中 1042 属于组织 B)重复测试。
  7. 仅静态确认(没有可用的测试账户):定位路由的处理程序,证明获取语句没有所有权谓词,并证明没有策略/中间件对该路由进行保护——两部分都要引用文件:行号。
BFLA 管理端点测试
  1. 以 ATTACKER(普通用户)的身份调用管理操作:
    curl -i -X POST https://target/api/users -H "Authorization: Bearer $ATTACKER" \
      -H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"Pwn3dPass!","role":"admin"}'
    
  2. 结果:201/200 = 已确认 BFLA(+ 可能自行授予权限);403 = 有防护;401 = 路由也缺少身份验证(更严重)。
  3. 注册/更新时的角色注入变体:
    curl -s -o /dev/null -w '%{http_code}\n' -X POST https://target/api/register \
      -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Esc4late!","role":"admin","isAdmin":true}'
    # then re-login as [email protected] and retry step 1
    
  4. 针对仅受 GET 保护的路由进行方法替换:curl -i -X DELETE https://target/admin/users/42 -H "Authorization: Bearer $ATTACKER"。
路径/请求头/HTTP 方法绕过测试
# Path confusion (use --path-as-is so curl does not normalize)
for p in '/admin/users' '/admin//users' '/admin/./users' '/admin%2fusers' '/adm%69n/users' \
         '/admin;/users' '/admin/users/' '/ADMIN/users' '/api/v1/admin/stats'; do
  curl -s -o /dev/null -w "%{http_code} $p\n" --path-as-is "https://target$p" -H "Authorization: Bearer $ATTACKER"
done
# Double-encoding round trip: %252f decodes once to %2f, again to '/'
# Proxy-header injection
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/public/health' -H 'X-Original-URL: /admin/users'
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/admin/stats'  -H 'X-Forwarded-For: 10.0.0.1'
# Verb tampering incl. overrides
for m in HEAD OPTIONS TRACE PATCH PUT; do curl -s -o /dev/null -w "%{http_code} $m\n" -X $m https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"; done
curl -s -X POST https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" \
  -H 'X-HTTP-Method-Override: DELETE' -d '_method=DELETE'

预期可观察结果:所有变体的状态码不一致地返回 403/404(且使用有效但权限不足的令牌时行为相同),即表明存在可绕过防护的原语;记录绕过防护的确切字节序列。

修复建议

集中处理策略层

将所有决策移至每个技术栈的一个集中拦截点;处理程序可以进一步细化策略,但绝不能成为唯一的防护:

技术栈集中拦截点
Express/Fastify在分支处理程序之前注册路由级中间件(guardRouter.use(requireAuth, attachPolicy))
NestJS全局 APP_GUARD JwtAuthGuard + RolesGuard;每个处理程序使用 @Roles() 元数据
FastAPI在路由器上使用 dependencies=[Depends(require_auth)];每个操作使用策略依赖项
Django在每个 CBV 上使用 Mixins + DRF DEFAULT_PERMISSION_CLASSES + 自定义 get_queryset 基类
Flaskbefore_request 蓝图钩子 + 每个视图上的装饰器
SpringSecurityFilterChain 路径规则 + @EnableMethodSecurity + @PreAuthorize 默认拒绝元注解
ASP.NET Core全局设置 FallbackPolicy = RequireAuthenticatedUser;每个区域使用 [Authorize(Policy)]
Laravel路由中间件组(auth:sanctum)+ Policies 自动发现;默认使用 $gate->deny
Symfonyaccess_control YAML + 在基类控制器/订阅器中通过 isGranted 调用 voters
Rails在 ApplicationController 中使用 before_action :authenticate_user! + Pundit verify_authorized after_action,在跳过检查时抛出异常
Go使用单一的 func Authz(next http.Handler) http.Handler 包装器,并将其组合到每个非公开路由组中
修复前 / 修复后示例

Express:

// VULNERABLE
router.get('/invoices/:id', async (req, res) => {
  const inv = await Invoice.findByPk(req.params.id);
  res.json(inv);
});

// FIXED
const { requireAuth } = require('./middleware/auth');
const { authorize } = require('./policy');            // centralized decision engine
const { scopeToTenant } = require('./middleware/tenant');

router.get('/invoices/:id', requireAuth, authorize('invoice:read'),
  scopeToTenant, async (req, res) => {
    const inv = await Invoice.findOne({               // predicate lives IN the query
      where: { id: req.params.id, tenantId: req.user.tenantId }
    });
    if (!inv) return res.sendStatus(404);             // uniform mask, no existence oracle
    res.json(inv);
  });

Django/DRF:

# VULNERABLE
class InvoiceViewSet(viewsets.ModelViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer

# FIXED
class InvoiceViewSet(viewsets.ModelViewSet):
    serializer_class = InvoiceSerializer
    permission_classes = [IsAuthenticated, TenantMemberRequired]

    def get_queryset(self):                            # object axis enforced centrally
        return Invoice.objects.filter(
            tenant_id=self.request.user.tenant_id,
        )
# FIXED (classic Django CBV equivalent)
class InvoiceDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
    model = Invoice
    permission_required = 'invoices.view_invoice'

    def get_queryset(self):
        return Invoice.objects.filter(tenant=self.request.user.tenant)

Spring:

// VULNERABLE
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
    return invoices.findById(id).map(mapper::toDto)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

// FIXED
@PreAuthorize("hasAuthority('invoice:read')")
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id, @AuthenticationPrincipal AppUser user) {
    return invoices.findByIdAndTenantId(id, user.getTenantId())
        .map(mapper::toDto)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Laravel:

// VULNERABLE
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);

// FIXED
Route::middleware(['auth:sanctum'])
    ->get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('can:view,invoice');                 // policy = single decision point
// app/Policies/InvoicePolicy.php  (registered automatically by name convention)
class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->tenant_id === $invoice->tenant_id;
    }
}

Rails(Pundit):

# VULNERABLE
def show
  @invoice = Invoice.find(params[:id])
end

# FIXED
def show
  @invoice = Invoice.find(params[:id])
  authorize @invoice                                   # raises unless policy permits
end
# with_scope in policy keeps index endpoints tenant-safe:
class ApplicationPolicy
  class Scope
    def resolve
      scope.where(tenant_id: user.tenant_id)
    end
  end
end
默认拒绝路由
// Express: public surface is an EXPLICIT allowlist; everything else falls through the guard wall
app.use(['/health', '/live', '/ready', '/login', '/register'], publicRouter);
app.use(requireAuth);                       // wall: nothing below resolves without identity
app.use('/admin', requireRole('admin'));    // vertical wall for whole subtree
app.use('/api', apiRouter);                 // apiRouter assumes authenticated ctx
# Spring Security: authenticate everything, permit only listed public paths
security:
  config:
    http:
      authorizeHttpRequests:
        - "/health"
        - "/login"
      others: "authenticated"
      admin-prefix: "/admin/**"
      admin-role: "ROLE_ADMIN"
    method-security: true

等效默认设置:ASP.NET 的 FallbackPolicy(见下文)、Symfony 最后的兜底规则 - { path: ^/, roles: ROLE_USER }、Laravel 在 api 组上使用 auth、Rails 在 ApplicationController 中进行身份验证,并对每处 skip_before_action 进行审计。

// ASP.NET Core: fail closed
builder.Services.AddAuthorization(o =>
{
    o.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser().Build();
});
// minimal APIs: app.MapGet("/x", h).RequireAuthorization("TenantMember");
租户范围限定与行级安全
  • 在所有查询下方的一个层中强制实施租户隔离:仓储基类、DRF get_queryset、EF Core 全局查询筛选器(HasQueryFilter(i => i.TenantId == _tenant.Id))、Hibernate @Filter/@TenantId、SQLAlchemy 事件,或将 Postgres RLS 作为兜底:
-- Postgres row-level security as defense-in-depth under app-level checks
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request/connection: SET LOCAL app.tenant_id = '<uuid-from-session>';
  • 在缓存键和队列标头中加入租户范围(t:{tenant}:invoice:{id});消费时重新验证租户。
  • 将连续整数替换为不透明的 UUIDv4,作为外部引用(缓解措施,而非修复方案)。
  • 模拟身份:使用专用的短 TTL 令牌类型,包含 act(操作者)和目标;将原因字符串持久化到仅追加审计表;目标用户更改密码时自动过期;只有管理员可以授予模拟身份权限本身。

验证与确认

自动化测试(GIVEN/WHEN/THEN)
Scenario: Owner retains access (negative test against over-blocking)
  GIVEN user A authenticated and owning invoice 1042
  WHEN A requests GET /api/invoices/1042
  THEN status is 200 AND body contains A's memo value

Scenario: Non-owner denied on read
  GIVEN users A and B on distinct tenants
  WHEN B requests GET /api/invoices/1042
  THEN status is 403 or 404 AND body contains no A-owned field values

Scenario: Non-owner denied on write
  WHEN B sends PATCH /api/invoices/1042 {"amount": 1}
  THEN status is 403/404 AND a follow-up GET by A shows unchanged amount

Scenario: Role matrix cell denial
  GIVEN role "viewer" exists with read-only rights
  WHEN viewer calls DELETE /api/invoices/1042
  THEN status is 403 regardless of ownership

Scenario: Unauthenticated denial
  WHEN request omits credentials entirely
  THEN status is 401 for every non-public route

对每条路由 × 每种角色 × {自己的、他人的、不存在的}对象 ID 运行矩阵;断言状态码,而不只是确认没有错误文本。

路由覆盖率测试(默认拒绝断言)
// pseudocode — port enumerator per framework (Express stack walk, rails routes, artisan route:list)
const PUBLIC_ROUTES = new Set(['/health', '/live', '/ready', '/login', '/register', '/docs', '/openapi.json']);

test('every non-public route declares >=1 authz guard; no public mutations', () => {
  for (const r of enumerateRoutes(app)) {
    const isPublic = PUBLIC_ROUTES.has(r.path);
    const guards = r.middleware.filter(isAuthzGuard); // requireAuth/authorize/roles...
    expect(guards.length >= 1 || isPublic).toBe(true);           // deny-by-default
    if (isPublic) expect(r.methods.some(m => ['POST','PUT','PATCH','DELETE'].includes(m))).toBe(false);
  }
});

CI 配套措施:对路由表进行快照测试(rails routes、artisan route:list、Nest 路由转储),如果路由数量增加却没有对应的防护改动,则审查失败。

手动重新测试清单
  1. 重跑利用部分中的双账户操作序列;确认返回 403/404,所有者返回 200。
  2. 重跑所有路径/标头/动词绕过循环;确认拒绝行为一致。
  3. 确认管理员仍然可以操作(修复经常会过度拦截;对角色矩阵中的每个单元格进行正向测试)。
  4. 确认导出/下载和 GraphQL 嵌套字段路径现在会强制执行所有权检查。
  5. 确认模拟身份操作会写入审计行,并按预期过期。
  6. 确认缓存键带有租户前缀,并清除修复前遗留的过期条目(刷新受影响的命名空间)。
修复后搜索
rg -n "skip_authorization|SkipAuthorization" app/
rg -n "objects\.(get|filter)\((pk|id)=" --type py
rg -n "findById\(" --type java --type kt | rg -v "AndTenant|ForTenant|Scoped"
rg -ni "x-(original-url|rewrite-url)" .
rg -n "x-forwarded-for" -i . | rg -v "trust proxy|trustedProxies|rate.?limit"
rg -n "@(Get|Post|Put|Delete|Patch)Mapping\(" --type java --type kt | wc -l   # compare vs @PreAuthorize count

严重性评估

发现类型示例基准 CVSS v3.1 向量分数范围
未经身份验证的管理操作预认证即可访问 POST /api/usersCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H严重(9.8)
垂直权限提升,已认证写入普通用户创建或禁用用户CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H高(8.8)
跨租户批量读取列表接口返回所有租户的记录(影响范围发生变化)CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N高(7.7)
水平写入,单个对象攻击者编辑其他租户的发票CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N中至高(6.5)
单条跨租户读取通过猜测 ID 读取一条他人记录,包含高度敏感数据CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N中(6.5)
仅存在性/时序探测可区分 200 空响应与 404CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N低至中(3.1–4.3)

评估标准修正因素:可枚举的连续 ID 或缺少速率限制会使严重性提高一个级别;密钥/PHI/财务字段会提高严重性;对于确实仅供内部使用的服务,补偿性网络 ACL 可降低严重性,但只要代码缺陷仍然存在,绝不能降为信息级。管理员接管 ≥9.0 时始终为严重;跨租户读取默认评为高危,即使只涉及一条记录,只要数据属于个人信息或机密信息。

主要 CWE 映射:完全缺少防护 → CWE-862;存在防护但有误 → CWE-863;用户可控密钥指向他人的对象 → CWE-639;统括分类 → CWE-285;共享缓存/队列粒度问题 → CWE-1220。

常见误报

  • 网关/Sidecar 强制执行:裸处理程序位于具备授权功能的 API 网关、服务网格 AuthorizationPolicy 或 mTLS-spiffe 网格之后。排除问题前,请确认强制执行点确实存在并覆盖该路由;并在报告中记录。
  • 有意公开的端点:定价目录、状态页、文档——确认这是产品设计意图,且不存在变更方法。
  • UUID 能力引用:不可猜测的 ID 会降低实际可利用性,但缺少所有权谓词仍是真实缺陷——应作为较低严重性的加固问题报告,而不是将该模式本身视为误报。
  • 有意隐藏 404:对他人对象统一返回 404,对所有者返回 200,属于正确设计,并非信息泄露——确认对于不存在的 ID 和他人的 ID,响应时序/正文无法区分。
  • 仅用于开发/测试且在生产构建中被排除的路由(确认构建标志/环境门控确实将其排除,例如 if (process.env.NODE_ENV !== 'production'))。
  • 位于 VPN + mTLS 之后、身份在网络层建立的内部工具——仅当同一服务也绑定了公共接口时,才备注残余风险。
  • 已在序列化器中将读取端点过滤为公开字段——确认关联/嵌套序列化器中不会泄露任何私有字段。

参考资料

评论

支持 Markdown