关于 aegis-authz-access-control
用于审查应用代码中认证后的授权决策的安全审计技能,帮助开发者逐路由检查功能、对象、属性、租户隔离及绕过漏洞。
主要功能
- 01检查功能级访问控制
- 02追踪对象与租户归属
- 03审查属性级权限
- 04发现路径和请求头绕过
- 05检查委派与身份模拟
适用场景
- 01审计 API 路由的角色检查
- 02查找对象查询中的 IDOR 风险
- 03检查多租户数据隔离
- 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 |
逐条路由的决策流程:
- 确认主体来源:会话、已验证 JWT 的 sub 声明、API 密钥、mTLS 身份。其他来源(查询参数、
user_id等标头、客户端存储的角色)本身就是危险信号。 - 根据业务命名确定所需的角色/操作(
admin、/users、/internal、DELETE)。 - 找到对象的获取位置,并检查查询是否包含主体条件(
WHERE id = ? AND tenant_id = ?)。 - 找到决策点(中间件/装饰器/注解/策略),并确认它在任何状态变更或数据返回之前执行。
- 不存在明确允许规则时,默认答案为:拒绝。缺少检查就是发现的问题,而不是“应该没问题”。
双账号直觉:对于每个端点,无论调用者是否拥有该对象,行为都必须一致——对象所有者除外,只有所有者能获取数据。
检查内容
函数级授权(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/NestJS | app.use/router.stack、Nest @Controller 元数据、Fastify printRoutes | requireAuth、passport.authenticate、addHook('preHandler')、@UseGuards(...)、@Roles(...) | routes/、controllers/、*.controller.ts、main.ts |
| Django/DRF | urls.py、router.register、django-extensions show_urls | LoginRequiredMixin、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_user | require_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/Symfony | php artisan route:list、bin/console debug:router | auth: 中间件、can: 中间件、策略、Gate::authorize、Symfony 投票器 + access_control YAML | routes/web.php、routes/api.php、app/Policies、config/packages/security.yaml |
| Rails | rails routes | before_action :authenticate_user!、Pundit authorize @record / after_action :verify_authorized、CanCanCan load_and_authorize_resource | config/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 调用、包含其他用户数据的电子邮件/导出内容生成。
流程:
- 对于每个获取单条记录的汇点,列出其参数。标记签名中缺少用户/租户/主体参数的任何查询辅助函数——例如,
getInvoice(id)与getInvoice(id, actor);缺少该参数意味着在后续任何检查之前,上游就可能存在 BOLA。 - 从汇点向来源回溯:如果从参数到 WHERE 子句的谓词链未包含已认证主体的属性,则将其标记为潜在 BOLA。
- 追踪角色/权限值:如果决策中使用的值来自某个来源(JWT 正文、可通过个人资料更新写入的数据库列、由登录时客户端数据设置的 Redis 会话字段),而非规范用户存储,则将其标记为潜在权限提升。
- 对于缓存/队列,追踪键的构造和消费端过滤;写入时带有租户标记、读取时却不进行过滤,仍然存在漏洞。
- 授权不存在所谓的净化器——经过验证的整数 ID 仍可能是其他租户的有效 ID。绝不要将验证视为授权。
框架速查:DRF 的 get_queryset() 重写(缺失时会使用基础管理器查询集,导致数据泄露);Rails 的 ApplicationRecord 默认作用域(默认没有);SQLAlchemy 的 session.query(Model).get(id) 不会考虑任何 Python 层面的租户概念,除非添加过滤条件;JPA 的 EntityManager.find(Cls, id) 不会按租户过滤,除非使用 Hibernate 的 @TenantId/@Filter。
利用与复现
双账户 BOLA 测试(标准流程)
- 注册受害者和攻击者账户:
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!"}' - 验证两个账户;保存令牌:
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) - 受害者创建一个对象;捕获其 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) - 攻击者请求受害者的对象:
curl -i https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" - 解读结果(这对分类判断很重要):
结果 含义 200+ 受害者字段("secret-payroll")已确认 BOLA(CWE-639) 200+ 空/已编辑正文,但与随机 ID 相比响应时间不同存在性预言机;仅部分防护 403可访问该功能,但对象访问被拒绝——在排除问题前,检查其他 HTTP 方法( curl -i -X PUT/PATCH/DELETE)和嵌套路径外部 ID 返回 404,自有 ID 返回200正确地通过伪装响应防止枚举——确认所有者仍能收到完整记录 - 对写入路径(在 PATCH/DELETE 中替换 ID)、集合(
GET /api/invoices,不带?mine=1)、导出(GET /api/invoices/export.csv)和嵌套形式(GET /org/A/invoices/1042,其中 1042 属于组织 B)重复测试。 - 仅静态确认(没有可用的测试账户):定位路由的处理程序,证明获取语句没有所有权谓词,并证明没有策略/中间件对该路由进行保护——两部分都要引用文件:行号。
BFLA 管理端点测试
- 以 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"}' - 结果:
201/200= 已确认 BFLA(+ 可能自行授予权限);403= 有防护;401= 路由也缺少身份验证(更严重)。 - 注册/更新时的角色注入变体:
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 - 针对仅受 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 基类 |
| Flask | before_request 蓝图钩子 + 每个视图上的装饰器 |
| Spring | SecurityFilterChain 路径规则 + @EnableMethodSecurity + @PreAuthorize 默认拒绝元注解 |
| ASP.NET Core | 全局设置 FallbackPolicy = RequireAuthenticatedUser;每个区域使用 [Authorize(Policy)] |
| Laravel | 路由中间件组(auth:sanctum)+ Policies 自动发现;默认使用 $gate->deny |
| Symfony | access_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 路由转储),如果路由数量增加却没有对应的防护改动,则审查失败。
手动重新测试清单
- 重跑利用部分中的双账户操作序列;确认返回
403/404,所有者返回200。 - 重跑所有路径/标头/动词绕过循环;确认拒绝行为一致。
- 确认管理员仍然可以操作(修复经常会过度拦截;对角色矩阵中的每个单元格进行正向测试)。
- 确认导出/下载和 GraphQL 嵌套字段路径现在会强制执行所有权检查。
- 确认模拟身份操作会写入审计行,并按预期过期。
- 确认缓存键带有租户前缀,并清除修复前遗留的过期条目(刷新受影响的命名空间)。
修复后搜索
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/users | CVSS: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 空响应与 404 | CVSS: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 之后、身份在网络层建立的内部工具——仅当同一服务也绑定了公共接口时,才备注残余风险。
- 已在序列化器中将读取端点过滤为公开字段——确认关联/嵌套序列化器中不会泄露任何私有字段。