后端安全模型:资产、主体、权限与攻击入口
账单导出涉及的数据和程序可以拆成下面几部分:
用户浏览器
├── 查询账单:携带登录凭证和账单 ID
├── 创建导出:提交筛选条件、字段和数量
└── 下载结果:使用任务 ID 或短期下载地址
后端应用
├── 账号记录:用户、租户成员关系、允许的操作
├── 账单数据库:归属租户、所有者、金额、明细
├── 导出任务:发起人、查询范围、输出位置、状态
├── 文件存储:导出文件与下载权限
└── 运行资源:查询连接、线程、内存、存储空间攻击者可以修改账单 ID、扩大筛选范围,也可以在退出租户后继续使用旧下载地址。一次合法登录只解决了其中的身份验证;其余操作需要在各自的执行位置检查。
资产、主体和数据流怎样组成安全模型
先确定损害发生在什么对象上
账单明细被其他租户读取,损害的是保密性;收款账户被篡改,损害的是完整性;少量导出任务占满数据库连接,损害的是可用性。三种目标可能同时受到影响,但处理方法不同。
资产不只包括数据库行。账号恢复入口可以改变账号控制权,签名私钥能生成其他系统信任的凭证,CI 发布身份能替换整个应用,审计记录影响事后追查。把这些对象写出来,才能看见只围绕 Controller 设置权限时遗漏了哪些路径。
每项资产还需要明确有效状态。例如“用户可以下载自己的账单”通常包含:用户仍属于该租户、账单仍允许导出、请求字段不包含内部备注、文件没有过期。只写“防止越权”,无法直接落实到查询和测试。
主体、凭证与权限分别表达什么
主体是发起操作的实体,可以是自然人、合作方服务、定时任务或管理员。凭证用于验证某种主体声明,例如密码、Session ID、签名证书或 access token。凭证验证成功后,应用取得认证结果,再结合目标对象判断操作是否允许。
权限至少要回答“谁,对哪个对象,在什么条件下,执行什么操作”。下面三个规则的限制范围逐步不同:
角色规则:财务人员可以使用账单导出功能
属性规则:当前租户 = 账单租户,账单状态允许导出
关系规则:用户是该项目成员,或被授予这份账单的只读访问权RBAC 以角色分配能力,适合管理岗位与功能菜单;ABAC 把主体、对象、操作及环境属性放进决策;ReBAC 通过成员、所有者、共享关系等连接判断访问。实际系统常组合使用:角色打开功能入口,租户和所有者限制数据范围,临时共享关系允许一项额外操作。
权限数据必须来自应用信任的记录或已经验证的声明。请求中的 X-Tenant、ownerId、isAdmin 都只是输入。即使前端不显示这些字段,调用方仍能自行构造请求。OWASP 授权指南建议默认拒绝,并在每次访问时检查具体权限。
沿数据流找出信任变化的位置
信任边界出现在数据或操作进入另一种权限控制范围时。浏览器进入应用是一处;应用使用高权限数据库账号执行 SQL 是另一处;导出 worker 从队列读取任务、存储服务接受文件写入、下载服务生成临时地址,也都可能改变可用权限。
浏览器输入
↓ 验证凭证;校验允许提交的字段
HTTP 应用
↓ 固定租户和可见对象范围;记录发起主体
导出任务记录
↓ worker 使用任务保存的范围;敏感操作重新授权
数据库查询 → 文件生成 → 私有存储
↓ 校验下载主体、对象和有效期
文件下载worker 使用服务账号执行任务时,仍需要知道它代表谁处理哪一份数据。队列消息里只放一个可任意替换的 tenantId,而 worker 拥有全库读取权限,会把一个输入错误扩大成跨租户导出。
下载授权也有两种常见实现。每次下载经过应用,可以实时查询成员关系;短期签名 URL 把一段访问权交给持有者,在 URL 有效期内通常不再逐次查询用户状态。后一种方式减轻应用流量,但退出租户后的撤销时效需要由短有效期、对象撤回或存储侧策略补足。
OWASP 威胁建模指南把系统结构、潜在威胁、应对措施和检查结果联系起来。图中的箭头应代表实际传输或调用;部署图上两块相邻的组件,并不能说明它们使用了什么权限。
从滥用条件推导防护位置
用具体动作描述威胁
“用户修改 URL 中的账单 ID,读到另一租户的明细”同时给出了入口、攻击动作、受影响数据和损害。开发者可以检查查询条件,也可以用两个账号构造请求。相比之下,“系统存在数据安全风险”没有提供足够的信息。
STRIDE 可用来检查遗漏:身份伪装、数据篡改、行为抵赖、信息泄露、拒绝服务、权限提升。它们是观察角度,不要求给每个组件机械填写六个问题。比如导出功能的重点可能是越权读取和资源耗尽;密钥管理服务则需要优先考虑签名权限、密钥替换及操作审计。分类定义见 Microsoft 威胁建模。
以下四个例子对应不同实施位置:
| 滥用动作 | 应检查的位置 | 允许与拒绝的结果 |
|---|---|---|
| 替换账单 ID | 数据查询或对象授权服务 | 本租户可见对象返回;不可见对象不泄露明细 |
| 在 JSON 中追加内部金额字段 | 请求 DTO 和业务变更方法 | 只接受该操作允许修改的字段 |
| 把导出数量设为极大值 | 参数校验、查询限制、任务调度 | 超限请求在创建任务前拒绝 |
| 权限撤销后使用旧下载地址 | 下载入口或存储访问策略 | 按约定撤销窗口停止读取 |
对象授权还要覆盖列表、批量 ID、父子关联、搜索结果、文件下载和后台任务。单对象查询增加 tenant 条件后,另一个批量接口仍可能直接执行 WHERE id IN (...);父订单通过权限校验后,传入的子明细也可能属于另一份订单。OWASP API Security分别讨论对象、功能和对象属性级授权问题。
风险判断需要攻击条件和业务影响
优先处理公网可达、低成本利用、影响高价值数据的路径。一个不需要登录的批量明细接口,通常比只能由隔离运维账号触发的低影响日志格式问题更紧急。判断中同时记录已有控制:接口是否对外、需要何种账号、是否必须先攻破其他服务、可读取多少数据、是否能够重复调用。
数值评分需要明确的尺度和数据。缺少请求量和业务损失信息时,“影响 4 分、可能性 3 分”很难支持工程取舍。直接说明“普通租户账号可以枚举其他租户账单;接口公网可达;查询无速率和对象范围限制”,更容易决定先修哪一条路径。
资源消耗需要按昂贵操作限制。入口每秒请求数相同,查询一行与导出百万行的成本差异很大。限制可以落在单次行数、累计字节、执行时间、租户并发任务、待处理队列长度和下游连接预算上。单一全局限流容易让一个租户占满其他租户的配额;分租户配额还需配合全局上限保护共享数据库。
预防、检测和恢复解决不同阶段的问题
参数化查询阻止数据被解释成 SQL 结构,对象授权控制哪些行可读;两者都需要。TLS 保护传输过程,数据库访问权限约束存储读取;启用 HTTPS 后仍要检查 SQL 和文件访问范围。
检测负责发现绕过、攻击尝试和控制失效。安全事件可以记录主体、操作、目标标识、拒绝原因类别与关联请求 ID;不记录密码、完整 Cookie、access token 或导出内容。日志字段的可见范围和保留期限也要限制,否则排障数据本身会成为泄露入口。
恢复方式取决于损害类型。账号接管需要撤销会话并恢复账号控制;签名密钥泄露需要处理旧密钥信任窗口;错误账单修改需要业务纠正和审计追踪;连接池耗尽则可能需要取消任务并重新分配容量。把所有问题处理成“重启服务”,可能清掉运行现场,却留下有效的被盗凭证。
系统需求可以对照 OWASP ASVS展开到认证、会话、授权、输入处理和安全配置。ASVS 提供可检查的要求目录,具体优先级仍由应用的数据和部署方式决定。
验证账单查询与导出限制
启动账单查询与导出应用
下载 security-http-lab.zip。Linux 宿主需要 Docker、curl、jq、unzip 和 OpenSSL;当前账号具有 Docker daemon 访问权限。使用 Java 17 编译目标、Maven 3.9.12、Spring Boot 4.1.1 和 Spring Security 7.1.1。框架版本由 Spring Boot 依赖版本表统一管理。
unzip security-http-lab.zip
cd security-http-lab
mkdir -p .m2 secrets
umask 077
openssl rand -base64 24 > secrets/password
LAB_UID=$(id -u)
LAB_GID=$(id -g)
docker run --rm --user "$LAB_UID:$LAB_GID" -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify
docker run -d --name sec15-model --user "$LAB_UID:$LAB_GID" \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
-p 127.0.0.1:18512:8080 \
-v "$PWD/target:/app:ro" -v "$PWD/secrets:/run/secrets:ro" \
eclipse-temurin:17.0.20_8-jdk java -jar /app/security-http-lab-1.0.0.jar \
--lab.password-file=/run/secrets/password
docker logs --tail 20 sec15-model
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18512/health构建生成 target 下的 JAR;日志显示 Tomcat 监听后,健康接口返回 ready。连接被拒绝时先确认监听完成;持续失败再检查密码挂载是否可读、容器日志和端口占用。Maven 写入宿主账号拥有的工程与缓存目录,应用使用同一 UID/GID,只读挂载 JAR 和密码文件。离线环境需要预先同步固定版本镜像和 Maven 依赖,不通过关闭证书校验处理下载失败。
实验中 alice 属于 tenant-a,bob 属于 tenant-b。BillingEndpoints 使用服务端的成员关系映射和账单归属,数据保存在内存,退出进程后重建。真实多租户数据库隔离见租户、签名与重放。
相同身份,只替换对象 ID
BASE=http://127.0.0.1:18512
LAB_PASSWORD=$(cat secrets/password)
curl -q --noproxy '*' --fail-with-body \
-u "alice:$LAB_PASSWORD" "$BASE/api/bills/bill-a"
code=$(curl -q --noproxy '*' -sS -u "alice:$LAB_PASSWORD" \
-H 'X-Tenant: tenant-b' -o denied.body -w '%{http_code}' \
"$BASE/api/bills/bill-b")
transport=$?
test "$transport" -eq 0 && test "$code" = 404 && \
! grep -q '2400' denied.body || \
{ printf '跨租户请求的状态或内容不符合预期\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body \
-u "bob:$LAB_PASSWORD" "$BASE/api/bills/bill-b"alice 读取 bill-a 时得到自己的账单;把目标换成 bill-b 并伪造租户 Header,仍得到 404。bob 使用相同目标则能看到自己账单的 2400 分金额。不存在的 ID 同样返回 404,响应中不区分“对象存在但你无权读取”和“对象不存在”。这有助于减少对象枚举信息,应用内部仍应保留适当的拒绝原因。
curl 的 -q 忽略默认配置文件,--noproxy '*' 固定回环直连。成功请求使用 --fail-with-body,负例同时判断传输退出状态和 HTTP 状态;选项说明见 curl 手册。Basic 演示密码可能短暂出现在本机命令参数中,只使用随机实验密码。
导出数量在创建任务前检查
创建任务是写操作,需要浏览器会话与 CSRF token。先完成表单登录,再获取登录后的 token:
curl -q --noproxy '*' --fail-with-body -sS -c model.cookies \
"$BASE/browser/csrf" > csrf.json
CSRF=$(jq -r .token csrf.json)
curl -q --noproxy '*' --fail-with-body -b model.cookies -c model.cookies \
--data-urlencode username=alice --data-urlencode "password=$LAB_PASSWORD" \
--data-urlencode "_csrf=$CSRF" "$BASE/browser/login"
curl -q --noproxy '*' --fail-with-body -sS -b model.cookies \
"$BASE/browser/csrf" > csrf.json
CSRF=$(jq -r .token csrf.json)
code=$(curl -q --noproxy '*' -sS -b model.cookies \
-H "X-CSRF-TOKEN: $CSRF" -X POST -o limit.body -w '%{http_code}' \
"$BASE/browser/exports?limit=101")
transport=$?
test "$transport" -eq 0 && test "$code" = 400 || \
{ printf '过大导出没有按参数错误拒绝\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body -b model.cookies \
-H "X-CSRF-TOKEN: $CSRF" -X POST "$BASE/browser/exports?limit=10"合法请求返回 export、tenant 和 limit 字段。新启动的进程第一次成功创建任务时 export 为 1;重复操作后编号会增加。测试 exportLimitFailsBeforeAnyTaskIsCreated 还直接读取任务计数,比较超限请求前后的值,防止“响应报错了,任务却已经创建”的实现错误。
这个 limit 只展示单次操作预算。实际导出还要限制租户并发、数据库扫描量、输出字节和最长执行时间;输入数量小,并不保证底层 SQL 的扫描成本也小。
安全结果不符合预期时怎样定位
请求在对象检查前就失败
401 先检查凭证和认证入口。403 可能来自角色要求,也可能来自写请求缺少 CSRF token;记录响应类型、请求方法和经过的过滤链,再进入业务检查。过滤器顺序和异常响应见 Spring Security 过滤链。
缺少凭证的请求没有进入对象查询,不能用它判断对象范围是否正确。验证对象授权时,应先让同一账号成功读取一个明确属于它的对象,再只替换目标 ID。修改多个变量后得到拒绝,很难知道究竟是哪条规则起了作用。
接口返回拒绝,后台仍产生数据
查看拒绝发生在创建任务、发送消息、写入文件之前还是之后。把校验放在 Controller 的最后一行,无法撤销此前已入队的任务。先确定副作用开始的位置,再把参数与对象检查放到相应业务操作前;涉及并发状态时,还需要在数据库事务内重新判断。
异步 worker 的授权输入必须来自可追溯的任务记录。消息中的用户、租户、目标文件或查询条件若能被低权限调用方任意替换,worker 的服务身份会放大其影响。修复后使用原始消息和变造消息分别测试,并检查数据库、任务和文件数量,而不只看消费日志有没有报错。
HTTP 接口正确,其他入口仍可绕过
继续检查批处理、导入工具、管理端、内部 RPC、搜索和对象存储。公共业务方法应承担多个入口共用的规则;数据库账号、网络访问和存储策略限制服务自身的可用权限。需要管理员跨租户操作时,提供明确的管理操作、额外授权与审计,不把普通查询条件临时删除。
新增缓存、队列、共享文件或第三方回调时,重新检查数据流。缓存键需要表达租户或可见范围;搜索索引必须保留授权过滤条件;回调先验证调用方和消息,再改变业务状态。每次结构变化都会带来新的输入位置,原来只画 HTTP 到数据库的模型也应随之补充。
结束实验:
docker stop sec15-model
docker rm sec15-model
unset LAB_PASSWORD CSRF确认不再使用后删除本轮 secrets、Cookie 和响应文件。Maven 缓存可以保留;没有使用数据库或持久任务卷。
权威资料与规范地址
OWASP 授权指南:https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
OWASP 威胁建模指南:https://cheatsheetseries.owasp.org/cheatsheets/Threat_Modeling_Cheat_Sheet.html
Microsoft STRIDE 威胁分类:https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats
OWASP API Security Top 10:https://owasp.org/API-Security/editions/2023/en/0x11-t10/
OWASP ASVS:https://owasp.org/www-project-application-security-verification-standard/
Spring Boot 依赖版本:https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
