默认拒绝:别让前端流程替服务器做访问控制
很多越权漏洞不是出现在复杂的密码学代码里,而是出现在一段看起来很合理的业务代码中:先根据请求里的 ID 找到一条数据,数据存在就继续执行。
例如删除订单:
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 都应该采用默认拒绝:
未匹配到公开规则 → 拒绝未认证 → 401 Unauthorized已认证但无权限 → 403 Forbidden资源不存在或无权访问 → 可统一返回 404,避免泄露资源存在性公开接口也要显式登记。商品详情、公开搜索、地区字典等接口可以允许游客访问,但不能因为「前端没有显示入口」就把接口当成不可达。
攻击者不需要使用你的页面。他可以:
直接构造 HTTP 请求修改 URL 参数修改 JSON 请求体修改 Cookie 和请求头跳过前端步骤重复发送旧请求因此,前端是否提供按钮,不是访问控制。
2. 每个 HTTP 方法都要独立授权
授权不能只绑定到一个 URL 或一个页面。相同资源的不同操作,权限通常不同。
| 方法 | 常见操作 | 必须考虑的授权问题 |
|---|---|---|
GET | 查询订单、地址、配置 | 当前主体能否读取目标资源 |
POST | 创建订单、退款、触发任务 | 当前主体能否执行该动作,创建范围是否受限 |
PATCH | 修改地址、订单状态 | 当前主体能否修改,哪些字段允许修改 |
PUT | 覆盖资源、替换配置 | 是否拥有完整替换权限,版本是否匹配 |
DELETE | 删除订单、文件、用户 | 是否能删除,资源是否属于当前主体,状态是否允许删除 |
不能因为 GET /orders/{id} 做了资源归属检查,就认为 DELETE /orders/{id} 也天然安全。读取、修改和删除必须分别检查。
推荐把授权检查放在靠近业务动作的位置,而不是只依赖前端或路由表:
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. 资源存在,不等于可以操作
下面这类代码非常危险:
// 错误:只检查资源是否存在。resource, err := repo.Get(ctx, req.ResourceID)if err != nil { return notFound()}return repo.Update(ctx, resource.ID, req.Fields)Get 只证明资源存在,不证明当前用户能访问它。
最小的资源级检查应该至少包含:
subject := session.Subject(ctx) // 来自服务端会话resource := repo.Get(ctx, req.ResourceID)
if resource.OwnerID != subject.UserID { return forbidden()}多租户系统还要检查租户边界:
if resource.TenantID != subject.TenantID { return forbidden()}如果管理员可以跨用户或跨租户操作,也应该使用明确的高权限动作,例如:
order:read:selforder:read:anyorder:delete:selforder:delete:anyconfig:rollbackuser:impersonate不要用一个模糊的 admin=true 代替所有范围和动作判断。
4. 浏览器发来的所有数据都不可信
以下内容全部由客户端控制,不能直接参与权限决策:
CookieAuthorization自定义请求头URL 参数JSON 请求体表单字段隐藏字段前端计算出的 isAdminroleowner_iduser_idtenant_idapprovedconfirmedstep这并不表示所有客户端参数都不能使用。order_id 可以作为「用户想操作哪个订单」的输入,但它只能用于定位候选资源,不能证明:
这个订单属于当前用户这个用户是管理员这个操作已经确认过这个对象已经通过前一步检查错误写法:
// 错误:信任客户端声称的 owner。if req.OwnerID == req.UserID { return updateResource(req)}正确写法:
// 正确:主体来自服务端会话,资源关系来自服务端数据库。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. 多步骤流程不能依赖前端顺序
常见流程包括:
预览 → 确认确认 → 删除上传 → 确认创建订单 → 支付申请 → 审批登录 → OAuth 回调错误设计是:
第 1 步:检查登录状态、管理员身份和参数第 2 步:默认用户一定经过第 1 步,只执行危险动作攻击者可以直接调用第 2 步。因为第 2 步本来就是一个 HTTP 接口,而 HTTP 不会自动记住「用户是否按页面顺序操作过」。
但即使给第 2 步补上登录和管理员检查,也可能仍有问题:
第 1 步确认删除订单 A第 2 步把订单 ID 改成订单 B所以多步骤操作要同时解决两个问题:
- 第 2 步独立认证和授权:不能因为第 1 步通过了就跳过。
- 绑定步骤状态:第 1 步的结果存到服务端,第 2 步只能消费与主体、动作、目标对象绑定的一次性状态。
令牌或服务端状态至少应绑定:
主体 user_id / tenant_id动作 action目标资源 ID 或不可变摘要创建时间过期时间随机 nonce单次消费状态第 2 步不应重新接受客户端提交的关键业务语义,而应从服务端保存的状态中读取。例如:
// 第 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)// 第 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,但不要把网关当成唯一防线
集中访问控制可以减少规则漂移。推荐的边界是:
客户端 → 网关:TLS、会话/JWT 验证、基础路由授权、限流 → 业务服务:资源级授权、租户隔离、状态约束 → 数据层:数据库账号、表级/行级最小权限所有外部请求先经过网关。网关应当:
- 拒绝未认证请求访问受保护路由。
- 剥离客户端伪造的身份头。
- 根据已验证的会话注入内部身份。
- 限制方法、路径、请求体大小和请求频率。
- 记录授权结果和安全事件。
但网关不是后端授权的替代品。后端可能被内部调用、被任务队列调用,也可能因为配置错误而被绕过网关访问。订单、支付、地址、配置和用户数据服务仍应独立进行资源级授权。
如果下游服务信任网关注入的身份头,至少要同时满足:
只有网关能连接下游下游网络不允许任意 Pod 直连网关在鉴权前剥离客户端身份头关键服务仍有纵深校验或服务间身份网络隔离解决的是「谁能到达服务」,IAM 解决的是「到达后能做什么」。两者不能互相替代。
7. 最小权限不是只给用户分角色
最小权限至少覆盖四个维度:
- 主体:普通用户、运营人员、管理员、服务账号使用不同身份。
- 动作:读取、创建、修改、删除、导出、回滚分别授权。
- 资源:限制到用户、商户、租户、项目和环境范围。
- 时间:高危权限短时授予,机器令牌设置有效期并可撤销。
服务账号也不要使用一个全能共享 token。机器身份应该:
绑定具体服务限制具体 API限制具体动作设置有效期支持撤销记录最后使用时间权限越具体,审计越容易,误授予的影响范围越小。
8. 测试不能只测前端按钮
每新增一个受保护接口,至少准备以下身份:
管理员普通用户 A普通用户 B未登录游客然后针对每个方法和每个资源做授权矩阵:
| 场景 | 预期 |
|---|---|
| 管理员读取管理员资源 | 允许 |
| 普通用户读取自己的资源 | 允许 |
| 普通用户读取他人的资源 | 拒绝或返回统一 404 |
| 普通用户调用管理员动作 | 403 |
| 游客调用受保护接口 | 401 |
| 第 2 步不带第 1 步状态直接调用 | 拒绝 |
| 第 2 步替换资源 ID | 拒绝 |
| 重放已使用的一次性令牌 | 拒绝 |
| 拒绝请求之后检查数据库 | 没有副作用 |
测试必须检查的不只是 HTTP 状态码,还包括:
响应体是否泄露数据数据库是否发生变化文件是否被创建或删除队列是否发布消息支付状态是否变化审计日志是否记录正确主体尤其要给每个历史漏洞留一条能复现它的回归测试。修复不是把结果改成 403 就结束,还要确认原来的副作用没有发生。
9. 一份代码评审清单
看到下列代码时,应立即停下来追问:
repo.Get(ctx, req.ID)repo.Delete(ctx, id)需要继续问:
ctx中的当前主体从哪里来?是否已经验签或从服务端会话得到?- 代码是否检查了当前主体对这个资源的所有权?
- 是否检查了租户、商户、项目或环境边界?
- 是否检查了动作权限,而不只是登录状态?
req.ID是否只是定位输入,还是被误当成授权依据?PATCH和DELETE是否有自己的授权检查?- 这是不是第 2 步接口?第 1 步的结果存在哪里?
- 拒绝请求是否绝对不会产生副作用?
最后再问一句:如果攻击者不使用网站页面,而是直接构造这条请求,服务器仍然安全吗?
如果答案不是明确的「是」,就不能把前端流程当作安全控制。
结语
访问控制的安全边界只能在服务端。前端可以改善体验,网关可以集中认证,IAM 可以统一策略,网络策略可以限制到达范围,但最终的业务服务必须在执行动作前重新判断:
谁在操作?他是否已认证?他是否被授予这个动作?目标资源是否属于他的范围?当前状态是否允许这个动作?这一步是否与之前的确认状态绑定?默认拒绝不是让系统难以使用,而是让每一条允许路径都可以被解释、被审计、被测试。真正危险的不是多写几行权限检查,而是把「用户应该会按页面操作」当成了安全假设。