希曼日记

默认拒绝:别让前端流程替服务器做访问控制

约 6 分钟2,356 字

很多越权漏洞不是出现在复杂的密码学代码里,而是出现在一段看起来很合理的业务代码中:先根据请求里的 ID 找到一条数据,数据存在就继续执行。

例如删除订单:

Go
order, err := orders.Get(ctx, req.Msg.OrderID)
if err != nil {
return notFound()
}
return orders.Delete(ctx, order.ID)

这段代码回答了「订单存在吗」,却没有回答真正重要的问题:当前登录用户有没有权删除这条订单?

如果接口对所有已登录用户开放,用户 A 只要把 order_id 换成用户 B 的订单 ID,就可能删除用户 B 的订单。若请求身份甚至没有被验证,游客也可能直接调用接口。

这类问题通常被称为:

  • 水平越权:同等权限的用户访问或修改其他用户的资源。
  • 垂直越权:低权限用户调用管理员或高权限功能。
  • 对象级授权缺失:接口验证了身份,却没有验证主体与目标资源之间的关系。
  • 多步骤访问控制缺失:第 1 步做了检查,第 2 步却默认相信用户一定经过了第 1 步。

本文的核心准则只有一句:

默认拒绝;每个请求在服务端独立认证、独立授权、独立验证资源归属。

1. 默认拒绝,而不是默认开放

接口没有明确的公开定义时,应当按受保护接口处理。路由、网关和 IAM 都应该采用默认拒绝:

Text
未匹配到公开规则 → 拒绝
未认证 → 401 Unauthorized
已认证但无权限 → 403 Forbidden
资源不存在或无权访问 → 可统一返回 404,避免泄露资源存在性

公开接口也要显式登记。商品详情、公开搜索、地区字典等接口可以允许游客访问,但不能因为「前端没有显示入口」就把接口当成不可达。

攻击者不需要使用你的页面。他可以:

Text
直接构造 HTTP 请求
修改 URL 参数
修改 JSON 请求体
修改 Cookie 和请求头
跳过前端步骤
重复发送旧请求

因此,前端是否提供按钮,不是访问控制。

2. 每个 HTTP 方法都要独立授权

授权不能只绑定到一个 URL 或一个页面。相同资源的不同操作,权限通常不同。

方法常见操作必须考虑的授权问题
GET查询订单、地址、配置当前主体能否读取目标资源
POST创建订单、退款、触发任务当前主体能否执行该动作,创建范围是否受限
PATCH修改地址、订单状态当前主体能否修改,哪些字段允许修改
PUT覆盖资源、替换配置是否拥有完整替换权限,版本是否匹配
DELETE删除订单、文件、用户是否能删除,资源是否属于当前主体,状态是否允许删除

不能因为 GET /orders/{id} 做了资源归属检查,就认为 DELETE /orders/{id} 也天然安全。读取、修改和删除必须分别检查。

推荐把授权检查放在靠近业务动作的位置,而不是只依赖前端或路由表:

Go
func (s *OrderService) Delete(ctx context.Context, req *connect.Request[DeleteOrderRequest]) (*connect.Response[DeleteOrderResponse], error) {
subject, err := s.iam.RequireUser(ctx)
if err != nil {
return nil, unauthenticated()
}
order, err := s.orders.Get(ctx, req.Msg.OrderID)
if err != nil {
return nil, notFound()
}
if !s.iam.Allowed(subject, "order:delete") {
return nil, forbidden()
}
if order.UserID != subject.UserID && !s.iam.Allowed(subject, "order:delete:any") {
return nil, forbidden()
}
if !order.CanDelete() {
return nil, failedPrecondition()
}
if err := s.orders.Delete(ctx, order.ID); err != nil {
return nil, internalError(err)
}
return connect.NewResponse(&DeleteOrderResponse{}), nil
}

这里至少做了四件事:确认当前用户已登录、确认拥有动作权限、确认目标资源属于当前用户、确认资源当前状态允许删除。

3. 资源存在,不等于可以操作

下面这类代码非常危险:

Go
// 错误:只检查资源是否存在。
resource, err := repo.Get(ctx, req.ResourceID)
if err != nil {
return notFound()
}
return repo.Update(ctx, resource.ID, req.Fields)

Get 只证明资源存在,不证明当前用户能访问它。

最小的资源级检查应该至少包含:

Go
subject := session.Subject(ctx) // 来自服务端会话
resource := repo.Get(ctx, req.ResourceID)
if resource.OwnerID != subject.UserID {
return forbidden()
}

多租户系统还要检查租户边界:

Go
if resource.TenantID != subject.TenantID {
return forbidden()
}

如果管理员可以跨用户或跨租户操作,也应该使用明确的高权限动作,例如:

Text
order:read:self
order:read:any
order:delete:self
order:delete:any
config:rollback
user:impersonate

不要用一个模糊的 admin=true 代替所有范围和动作判断。

4. 浏览器发来的所有数据都不可信

以下内容全部由客户端控制,不能直接参与权限决策:

Text
Cookie
Authorization
自定义请求头
URL 参数
JSON 请求体
表单字段
隐藏字段
前端计算出的 isAdmin
role
owner_id
user_id
tenant_id
approved
confirmed
step

这并不表示所有客户端参数都不能使用。order_id 可以作为「用户想操作哪个订单」的输入,但它只能用于定位候选资源,不能证明:

Text
这个订单属于当前用户
这个用户是管理员
这个操作已经确认过
这个对象已经通过前一步检查

错误写法:

Go
// 错误:信任客户端声称的 owner。
if req.OwnerID == req.UserID {
return updateResource(req)
}

正确写法:

Go
// 正确:主体来自服务端会话,资源关系来自服务端数据库。
subject := session.Subject(ctx)
resource := repository.Get(ctx, req.ResourceID)
if resource.OwnerID != subject.UserID {
return forbidden()
}

如果使用 JWT,必须验签并校验 issuer、audience、过期时间和 token 类型。只解码 JWT payload、读取其中的 isAdmin,不算认证。

如果使用服务端 Session,浏览器只携带不可预测的 session id;角色、用户 ID、租户和权限保存在服务端,并且每次请求根据 session 重新得到主体。

5. 多步骤流程不能依赖前端顺序

常见流程包括:

Text
预览 → 确认
确认 → 删除
上传 → 确认
创建订单 → 支付
申请 → 审批
登录 → OAuth 回调

错误设计是:

Text
第 1 步:检查登录状态、管理员身份和参数
第 2 步:默认用户一定经过第 1 步,只执行危险动作

攻击者可以直接调用第 2 步。因为第 2 步本来就是一个 HTTP 接口,而 HTTP 不会自动记住「用户是否按页面顺序操作过」。

但即使给第 2 步补上登录和管理员检查,也可能仍有问题:

Text
第 1 步确认删除订单 A
第 2 步把订单 ID 改成订单 B

所以多步骤操作要同时解决两个问题:

  1. 第 2 步独立认证和授权:不能因为第 1 步通过了就跳过。
  2. 绑定步骤状态:第 1 步的结果存到服务端,第 2 步只能消费与主体、动作、目标对象绑定的一次性状态。

令牌或服务端状态至少应绑定:

Text
主体 user_id / tenant_id
动作 action
目标资源 ID 或不可变摘要
创建时间
过期时间
随机 nonce
单次消费状态

第 2 步不应重新接受客户端提交的关键业务语义,而应从服务端保存的状态中读取。例如:

Go
// 第 1 步:服务端保存「谁确认了什么」。
confirmation := Confirmation{
SubjectID: subject.UserID,
Action: "order:delete",
Resource: order.ID,
ExpiresAt: now.Add(5 * time.Minute),
Nonce: randomNonce(),
}
confirmationID := store.Create(ctx, confirmation)
Go
// 第 2 步:再次认证授权,并消费一次性状态。
subject := session.Subject(ctx)
confirmation, err := store.Take(ctx, req.ConfirmationID)
if err != nil {
return invalidOrExpiredConfirmation()
}
if confirmation.SubjectID != subject.UserID {
return forbidden()
}
if confirmation.Action != "order:delete" {
return forbidden()
}
return deleteOrder(ctx, confirmation.Resource)

Take 必须是原子的一次性消费。过期、已使用、主体不一致、动作不一致或资源摘要不一致,都必须拒绝。

6. 集中 IAM,但不要把网关当成唯一防线

集中访问控制可以减少规则漂移。推荐的边界是:

Text
客户端
→ 网关:TLS、会话/JWT 验证、基础路由授权、限流
→ 业务服务:资源级授权、租户隔离、状态约束
→ 数据层:数据库账号、表级/行级最小权限

所有外部请求先经过网关。网关应当:

  • 拒绝未认证请求访问受保护路由。
  • 剥离客户端伪造的身份头。
  • 根据已验证的会话注入内部身份。
  • 限制方法、路径、请求体大小和请求频率。
  • 记录授权结果和安全事件。

但网关不是后端授权的替代品。后端可能被内部调用、被任务队列调用,也可能因为配置错误而被绕过网关访问。订单、支付、地址、配置和用户数据服务仍应独立进行资源级授权。

如果下游服务信任网关注入的身份头,至少要同时满足:

Text
只有网关能连接下游
下游网络不允许任意 Pod 直连
网关在鉴权前剥离客户端身份头
关键服务仍有纵深校验或服务间身份

网络隔离解决的是「谁能到达服务」,IAM 解决的是「到达后能做什么」。两者不能互相替代。

7. 最小权限不是只给用户分角色

最小权限至少覆盖四个维度:

  • 主体:普通用户、运营人员、管理员、服务账号使用不同身份。
  • 动作:读取、创建、修改、删除、导出、回滚分别授权。
  • 资源:限制到用户、商户、租户、项目和环境范围。
  • 时间:高危权限短时授予,机器令牌设置有效期并可撤销。

服务账号也不要使用一个全能共享 token。机器身份应该:

Text
绑定具体服务
限制具体 API
限制具体动作
设置有效期
支持撤销
记录最后使用时间

权限越具体,审计越容易,误授予的影响范围越小。

8. 测试不能只测前端按钮

每新增一个受保护接口,至少准备以下身份:

Text
管理员
普通用户 A
普通用户 B
未登录游客

然后针对每个方法和每个资源做授权矩阵:

场景预期
管理员读取管理员资源允许
普通用户读取自己的资源允许
普通用户读取他人的资源拒绝或返回统一 404
普通用户调用管理员动作403
游客调用受保护接口401
第 2 步不带第 1 步状态直接调用拒绝
第 2 步替换资源 ID拒绝
重放已使用的一次性令牌拒绝
拒绝请求之后检查数据库没有副作用

测试必须检查的不只是 HTTP 状态码,还包括:

Text
响应体是否泄露数据
数据库是否发生变化
文件是否被创建或删除
队列是否发布消息
支付状态是否变化
审计日志是否记录正确主体

尤其要给每个历史漏洞留一条能复现它的回归测试。修复不是把结果改成 403 就结束,还要确认原来的副作用没有发生。

9. 一份代码评审清单

看到下列代码时,应立即停下来追问:

Go
repo.Get(ctx, req.ID)
repo.Delete(ctx, id)

需要继续问:

  • ctx 中的当前主体从哪里来?是否已经验签或从服务端会话得到?
  • 代码是否检查了当前主体对这个资源的所有权?
  • 是否检查了租户、商户、项目或环境边界?
  • 是否检查了动作权限,而不只是登录状态?
  • req.ID 是否只是定位输入,还是被误当成授权依据?
  • PATCH 和 DELETE 是否有自己的授权检查?
  • 这是不是第 2 步接口?第 1 步的结果存在哪里?
  • 拒绝请求是否绝对不会产生副作用?

最后再问一句:如果攻击者不使用网站页面,而是直接构造这条请求,服务器仍然安全吗?

如果答案不是明确的「是」,就不能把前端流程当作安全控制。

结语

访问控制的安全边界只能在服务端。前端可以改善体验,网关可以集中认证,IAM 可以统一策略,网络策略可以限制到达范围,但最终的业务服务必须在执行动作前重新判断:

Text
谁在操作?
他是否已认证?
他是否被授予这个动作?
目标资源是否属于他的范围?
当前状态是否允许这个动作?
这一步是否与之前的确认状态绑定?

默认拒绝不是让系统难以使用,而是让每一条允许路径都可以被解释、被审计、被测试。真正危险的不是多写几行权限检查,而是把「用户应该会按页面操作」当成了安全假设。