希曼日记

电商篇:安全——基于请求方法的访问控制绕过

约 9 分钟3,564 字

本文是全栈电商微服务从 0 到 1 再到 10系列的第 6 篇。上一篇:电商篇:客户支持——配置自定义域名邮箱。服务端访问控制的通用准则见默认拒绝:别让前端流程替服务器做访问控制,本文只讲其中一种具体绕过方式,以及它在本项目里的核查与修补。

有一类越权漏洞不需要猜密码、不需要找注入点,攻击者只改请求行的第一个单词:把 POST 换成 GET。授权层没匹配到规则,业务代码照样执行,一个普通用户就把自己改成了管理员。

这篇文章分三部分:先讲它为什么成立;再拿本项目的网关和微服务代码逐层核查,看它在这套架构里能不能成立;最后把核查中发现的缺口补上,并留下门禁防止它回来。

漏洞原理:授权层和执行层对「操作」的理解不一致

一个很常见的实现习惯

漏洞不是来自某个框架的 bug,而是来自一个几乎每个团队都写过的组合:

Text
授权规则:p, admin, /api/users/*/role, POST, allow
处理函数:func UpdateRole(w, r) { ... 不看 r.Method ... }
路由注册:mux.HandleFunc("/api/users/{id}/role", UpdateRole) ← 所有方法都进这个函数

三行各自都没错。授权规则精确到方法,是好习惯;处理函数只管业务,是好习惯;路由注册不限方法,是 net/http 的默认行为。合在一起就出了问题:授权层认为「操作」是 (路径, 方法),执行层认为「操作」是路径。 两边对同一个请求的理解不同,攻击者只需要找到落在这个缝隙里的请求。

攻击者的三步推理

攻击者不是在猜,是在利用这个习惯:

  1. 抓到管理员的 POST /api/users/42/role {"role":"admin"},用普通用户身份重放,得到 403。这说明这条规则在起作用,且规则确实检查了角色。
  2. 授权规则按 (路径, 方法) 匹配,规则表里只写了 POST。那么 GET 没匹配到任何规则会怎样?三种可能:默认拒绝(安全)、默认放行(漏洞)、根本没有规则去查(漏洞)。
  3. 把方法改成 GET,参数挪进 query(?role=admin),或者请求体照带——很多框架不管方法照样解析 body。如果处理函数不区分方法、授权层对 GET 又没有规则,提权成功。

变体

方法只是攻击面的入口,同一个缝隙有很多种钻法:

变体做法依赖的条件
换成其他方法HEAD、OPTIONS、PUT、PATCH路由不限方法,规则只写了 POST
方法覆盖头发 GET 并带 X-HTTP-Method-Override: POST某个中间件把 GET 当 POST 处理,而授权层看到的仍是 GET
参数位置转移body 挪到 query,或反过来框架统一解析,业务代码不关心参数来自哪里
大小写post、Post授权层做精确字符串匹配,路由层不区分大小写

它们的共同点只有一个:授权判据和执行逻辑不在同一处,且对「这是什么操作」的定义不一致。 修任何一个变体都是治标;让两层对操作的定义重新一致才是治本。

用真实代码核查:这套架构挡住了几层

本项目的对外入口是网关仓 control-tower,业务在 ecommerce 仓的 10 个 Golang 微服务里,服务之间与前后端之间统一走 Connect RPC。下面按请求经过的顺序核查。

第一层:Connect 协议本身只接受 POST

Connect RPC 的 procedure 形如 POST /pkg.Service/Method。生成的 handler 对非 POST 请求直接返回 405——除非 proto 里显式标注:

Protocol Buffer 3
rpc GetProductDetail(GetProductDetailRequest) returns (GetProductDetailResponse) {
option idempotency_level = NO_SIDE_EFFECTS; // 只有标了它,Connect 才放开 GET
}

核查本仓全部 14 份公开 proto,没有一个方法带这个选项,也没有任何服务用 connect.WithIdempotency 在代码里开启它:

Bash
$ grep -rn "NO_SIDE_EFFECTS" backend/api --include=*.proto
(无输出)
$ grep -rln "connect.WithIdempotency" backend --include=*.go | grep -v third_party
(无输出)

再核查路由注册。10 个服务的 server.go 全部只注册 Connect 生成的 handler,没有任何裸的 mux.HandleFunc 业务路由:

Go
paymentv1connectPath, paymentv1connectHandler := paymentv1connect.NewPaymentServiceHandler(
paymentv1Service,
combinedOptions...,
)
mux.Handle(paymentv1connectPath, paymentv1connectHandler)

结论:在业务服务这一层,GET /user.v1.UserService/UserProfile 会被 Connect 生成代码以 405 拒绝,到不了业务函数。方法改写在 handler 就死了。

第二层:网关 Casbin 默认拒绝

网关比 handler 更早。鉴权中间件把 HTTP 方法原样交给 Casbin:

Go
// control-tower services/gateway/internal/httpmw/auth.go
allowed, err := d.Enforcer.Allowed(claims.RoleNames(), path, r.Method)
if !allowed {
d.Errors.Write(w, r, connect.CodePermissionDenied, "RBAC_DENIED", "role not permitted for this procedure")
return
}

Casbin 的 model 是这样的:

INI
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)

policy_effect 的语义是「至少一条 allow 且没有任何 deny」——没有任何规则匹配时结果是拒绝。策略里若只写了 POST,GET 落不到任何 allow 规则,直接 403。网关的既有测试就断言了这一点:

Go
// 方法不匹配。
{[]string{"customer"}, "/cart.v1.CartService/AddItem", "DELETE", false},

这正是默认拒绝一文的原则一。到这里,本项目对「换方法」这个动作有两道独立的防线。

但两道防线各有一个开关

两道防线都是「凑巧」——Connect 的协议约束、Casbin 的默认拒绝。它们各自有一个开关,拨一下就没了,而且拨的时候不会有任何编译错误或测试失败。

开关一:策略文件的 act 列写成通配。 matcher 用的是 regexMatch(r.act, p.act),它允许策略写成 (GET)|(POST) 甚至 .*。哪条策略图省事写了 .*,网关这道防线就没了,只剩 Connect 的 405 兜底。策略文件 policies.csv 不在任何仓库里,存在 Config Center 的数据库中。这次核查时 Config Center 恰好连不上它的 PostgreSQL(经隧道到远端节点的 TLS 握手失败),线上策略读不到——而更早一次架构漂移审计的报告里,这一项同样记着「未核」。

一道没有人能看见的防线,实际上不能算防线。这条结论直接导向了后面的第二道门禁。

开关二:proto 标了 NO_SIDE_EFFECTS。 网关对 GET/HEAD/OPTIONS 跳过 cookie 轨的 CSRF Origin 校验:

Go
// control-tower services/gateway/internal/httpmw/auth.go
if res.viaCookie && !safeMethod(r.Method) && d.OriginAllowed != nil {
if !d.OriginAllowed(r.Header.Get("Origin")) {
d.Errors.Write(w, r, connect.CodePermissionDenied, "CSRF_ORIGIN_REJECTED", "origin not allowed for state-changing request")
return
}
}
func safeMethod(m string) bool {
switch m {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}

这在语义上正确——安全方法不该有副作用。但它依赖一个前提:没有任何 GET 会改状态。 今天成立,因为 Connect 拒 GET。将来若有人给某个 RPC 标了 NO_SIDE_EFFECTS,让它能走 GET 以便被 CDN 缓存,而那个 RPC 实际有副作用,CSRF 防线就在那条 RPC 上开了口子。

这才是这类漏洞在本项目里最可能的入口:不是攻击者改方法,是开发者为了缓存把一个有副作用的方法标成了无副作用。

核查挖出的真实缺口

方法层没有发现可利用路径,但按「授权判据是否与执行逻辑在同一处」这个标准逐个 RPC 看,商家服务的审批接口是这样的:

Go
// 修补前
func (s *MerchantService) ApproveApplication(ctx context.Context, c *connect.Request[v1.ApproveApplicationRequest]) (*connect.Response[v1.ApproveApplicationResponse], error) {
req := c.Msg
_, err := s.uc.ApproveApplication(ctx, &biz.ApproveApplicationRequest{
AuditComment: req.AuditComment,
ApplicationId: req.ApplicationId,
})
// ...
}

handler 内零授权。它能不被普通用户调用,完全依赖网关策略表里的那一行:

Text
p, admin, /merchant.v1.MerchantService/ApproveApplication, POST, allow

这行策略被改宽、请求从服务间调用直接到达(东西向不经网关)、或某次误配把这条路由放进了匿名清单——任何一种情况下,一个商家申请就能被任意人批准。这和开头那个 UpdateRole 是同一个病:判据在网关,执行在服务,两处对「谁能审批」没有共同的定义。

防御:让权限绑在操作上,不绑在方法上

修法不是给 ApproveApplication 加一句 if r.Method != "POST"——那是把授权层的错误理解复制到执行层。三条根治:

一、授权检查放在 handler 内部,判「动作」不判「方法」

网关的 Casbin 保留,但降级为粗粒度放行(谁能碰这条路);真正的判定在服务里。先在共享包 pkg/identity 加一个角色判定:

Go · backend/pkg/identity/identity.go
// HeaderRole 是网关按已验签 claims 注入的角色列表,多角色逗号拼接。
// 角色名与 Casdoor 原值一致:admin / merchant / customer。
const HeaderRole = "x-md-global-role"
const RoleAdmin = "admin"
var ErrRoleNotAllowed = errors.New("identity: 当前用户没有执行该操作的角色")
// RequireRole 返回登录用户的 ID,并要求其持有 role。
// 访客、无身份、角色不含 role 分别返回 ErrAnonymousNotAllowed / ErrNoIdentity / ErrRoleNotAllowed。
func RequireRole(h http.Header, role string) (string, error) {
id, err := RequireUser(h)
if err != nil {
return "", err
}
for _, r := range Roles(h) {
if r == role {
return id, nil
}
}
return "", fmt.Errorf("%w: 需要 %q", ErrRoleNotAllowed, role)
}

x-md-global-role 这类身份头由网关在鉴权后注入,入站时无条件剥离,客户端伪造不了——这是上一篇攻防复盘里验证过的第一道防线。服务侧直接信任它,不再验签。

然后在商家服务里,三条审批 RPC 的入口统一加一行:

Go · backend/services/merchant/internal/service/merchant_service.go
// requireAdmin 是审批类 RPC 的 handler 内授权:判「主体能不能审批」,不判 HTTP 方法。
func requireAdmin(h http.Header) error {
_, err := identity.RequireRole(h, identity.RoleAdmin)
switch {
case err == nil:
return nil
case errors.Is(err, identity.ErrRoleNotAllowed):
return connect.NewError(connect.CodePermissionDenied, err)
default:
return connect.NewError(connect.CodeUnauthenticated, err)
}
}
func (s *MerchantService) ApproveApplication(ctx context.Context, c *connect.Request[v1.ApproveApplicationRequest]) (*connect.Response[v1.ApproveApplicationResponse], error) {
if err := requireAdmin(c.Header()); err != nil {
return nil, err
}
req := c.Msg
// ...
}

到了这一层,请求是 GET 还是 POST 已经无关——它进的是 ApproveApplication 这个函数,检查的是「你能不能审批」。无论攻击者怎么改传输层,逃不掉这一行。

RejectApplication 和 ActivateMerchant 目前还是 panic("implement me") 的桩子,也先把 requireAdmin 挂上。授权先于实现:桩子被填成真实逻辑时这行已经在了,不靠实现者记得补。

回归测试用一个 use case 为 nil 的服务实例来证明「授权先于业务」——如果授权没先拦住,用例会因空指针 panic 而失败:

Go
func TestApproveApplication_RequiresAdminBeforeUseCase(t *testing.T) {
s := &MerchantService{} // uc 故意留 nil
cases := []struct {
name string
userID string
anon string
roles string
want connect.Code
}{
{"merchant 角色被拒", user, "", "merchant", connect.CodePermissionDenied},
{"无角色被拒", user, "", "", connect.CodePermissionDenied},
{"访客被拒", user, "true", "admin", connect.CodeUnauthenticated},
{"无身份被拒", "", "", "admin", connect.CodeUnauthenticated},
}
// ...
}

对象级授权(「这个 admin 能不能审批这一份申请」)的目标态是 OpenFGA 关系检查;接线前先用角色收口动作级授权,接线后把 requireAdmin 换成 fga.Check(subject, "can_review_application", ...),角色判定退为兜底。

二、HTTP 方法只由协议层校验

不要在业务代码里写 if r.Method == "POST" 这种散落的检查,它一定会漏。方法校验只属于两个地方:

  • Connect 生成代码——它已经做了,非 POST 返 405。
  • 如果将来有裸 HTTP 路由,用 Go 1.22 的方法路由 mux.Handle("POST /path", h),让 net/http 在路由匹配时就把方法定死。

授权层则彻底不再关心方法:它判的是「主体 + 动作」。方法层和授权层各管一件事,攻击者在方法上做文章时,撞到的是协议层的 405,而不是授权层的某条漏写的规则。

三、策略文件的方法列不许写通配

这是针对「开关一」的门禁。网关加载 policies.csv 时,对每一条 p 行做列值校验:

Go
// control-tower services/gateway/internal/authz/authz.go
// allowedAct 是 p 行 act 列唯一合法的字面值。Connect procedure 一律 POST。
const allowedAct = "POST"
// validatePolicyRow 校验 p 行(不含 ptype):列数固定 4,act 列只认字面 POST。
//
// 拒绝的不只是 `.*`:`GET|POST`、`(GET)|(POST)`、`POST.*`、小写 `post`、
// 单独的 `GET` 全部拒绝——regexMatch 下这些都不是「恰好 POST」,而任何比
// POST 更宽的匹配都会让授权层与执行层对「这是什么操作」的理解分叉。
func validatePolicyRow(fields []string) error {
if len(fields) != pFieldCount {
return fmt.Errorf("p row must have %d fields (sub, obj, act, eft), got %d", pFieldCount, len(fields))
}
if act := fields[2]; act != allowedAct {
return fmt.Errorf("p row act column must be literally %q, got %q (wildcards and method alternations are refused)", allowedAct, act)
}
return nil
}

loadCSV 在 AddNamedPolicy 之前调用它,任一行不合法整表拒绝加载。网关原本就有 last-known-good 语义——坏策略下发时保留旧的 enforcer——所以热更新一份带通配的策略不会让线上放开,只会让新策略不生效并在日志里指出是第几行、哪一列。

回归测试覆盖 7 种坏写法,并反向验证字面 POST 正常加载、对 GET 仍然拒绝:

Go
func TestActColumnMustBeLiteralPOST(t *testing.T) {
a := newEnforcer(t)
bad := []string{
"p, admin, /user.v1.UserService/UpdateRole, .*, allow",
"p, admin, /user.v1.UserService/UpdateRole, (GET)|(POST), allow",
"p, admin, /user.v1.UserService/UpdateRole, GET|POST, allow",
"p, admin, /user.v1.UserService/UpdateRole, POST.*, allow",
"p, admin, /user.v1.UserService/UpdateRole, GET, allow",
"p, admin, /user.v1.UserService/UpdateRole, post, allow",
"p, admin, /user.v1.UserService/UpdateRole, allow", // 少一列:act 位被 eft 顶上
}
for _, row := range bad {
if err := a.SetPolicies(modelText, row); err == nil {
t.Errorf("row %q must be refused", row)
}
}
// 旧策略仍然生效(last-known-good)。
ok, err := a.Allowed([]string{"customer"}, "/cart.v1.CartService/AddItem", "POST")
if err != nil || !ok {
t.Fatalf("old enforcer must keep working: ok=%v err=%v", ok, err)
}
// 反向:字面 POST 正常加载,且对 GET 仍然默认拒绝。
if err := a.SetPolicies(modelText, "p, admin, /user.v1.UserService/UpdateRole, POST, allow"); err != nil {
t.Fatalf("literal POST must load: %v", err)
}
if ok, _ := a.Allowed([]string{"admin"}, "/user.v1.UserService/UpdateRole", "GET"); ok {
t.Fatal("GET must not match a POST-only rule")
}
}

这道门禁把「策略文件在数据库里、没人看得见」的问题反过来解决了:不需要看见它,任何通配写法都到不了 enforcer。

有一个部署注意点必须提前知道:带门禁的网关版本冷启动时没有旧表可退。如果线上策略已经有通配行,新网关会以 AUTHZ_NOT_READY 拒绝全部需登录的请求。这是 fail-close 的正确行为,但上线前要先核一遍线上策略,别让它在发布窗口里把自己锁死。

四、把 NO_SIDE_EFFECTS 当安全开关评审

这是针对「开关二」的门禁。规则写进 proto 设计规范:给一个方法加 NO_SIDE_EFFECTS 前逐条核对——

  1. 真的无副作用:不写库、不发事件、不扣库存、不改会话、不记「最后访问时间」之类的状态。「读多写少」不算,「只写日志」算。
  2. 参数进 URL 可接受:GET 的请求体走 query string,会进访问日志、浏览器历史、Referer。带 PII 或令牌的请求不能标。
  3. 同一个 PR 三处同步:proto 加选项、结构检查白名单加 procedure、网关 allowedAct 放开该 procedure 的 GET。少任何一处,要么门禁红,要么标了也到不了 handler。
  4. CSRF 豁免只对它成立:写进方法注释,说明它为何无副作用,评审时对着注释核。

规则写在文档里会被忘,所以再加一道结构检查(本项目用 Go 测试做仓库级门禁,go test ./structcheck/... 是提交前必跑项):

Go · backend/structcheck/rpc_method_test.go
// getEnabledRPCs 是允许经 GET 到达的 procedure 白名单(`/pkg.Service/Method`)。
var getEnabledRPCs = map[string]bool{}
func TestNoSideEffectsRPCsAreAllowlisted(t *testing.T) {
// 遍历 backend/api/**/*.proto,逐行跟踪 package / service / rpc,
// 遇到 idempotency_level = NO_SIDE_EFFECTS 就拼出 procedure 查白名单。
// ...
if !getEnabledRPCs[procedure] {
t.Errorf("%s:%d 标了 NO_SIDE_EFFECTS(放开 GET)但 %s 不在 getEnabledRPCs 白名单;"+
"先确认它真的无副作用并同步 control-tower authz.allowedAct,再把它加进白名单", path, line, procedure)
}
}

白名单当前为空。任何 proto 出现这个选项,测试红,错误信息直接指到文件、行号和 procedure。

门禁自己也要被验证——一个从来没红过的门禁可能根本没在检查。金丝雀做法:往 product.proto 注入一行选项,跑测试,看它红在正确的位置,再还原:

Text
--- FAIL: TestNoSideEffectsRPCsAreAllowlisted (0.00s)
rpc_method_test.go:74: ../api/product/v1/product.proto:13 标了 NO_SIDE_EFFECTS(放开 GET)
但 /product.v1.ProductService/GetProductDetail 不在 getEnabledRPCs 白名单;……

还原后绿。这一步不入库,但它是「门禁可信」的唯一证据。

三层各管一件事

修补后,这套架构对「操作」的定义在三层上重新一致:

层管什么不管什么落点
协议层HTTP 方法:非 POST 返 405谁在调、能不能调Connect 生成代码;将来裸路由用 mux.Handle("POST /path", h)
网关层粗粒度放行:这个角色能不能碰这条 procedure对象级归属Casbin,act 列只认字面 POST
服务层主体能不能做这个动作、对这个对象HTTP 方法identity.RequireRole,目标态 OpenFGA Check

攻击者改方法,撞到的是协议层;改策略文件,撞到的是加载门禁;绕过网关直达服务,撞到的是 handler 第一行。三层没有一层依赖另一层「碰巧」做对。

自查清单

核查自己项目时按这个顺序:

  1. 路由注册限方法了吗?mux.HandleFunc("/path", h) 这种写法,所有方法都进同一个函数。
  2. 授权规则按 (路径, 方法) 匹配时,没匹配到规则的默认行为是什么?拿一条只写了 POST 的规则,发 GET、HEAD、PUT、PATCH、OPTIONS 各试一次。
  3. 有没有中间件处理 X-HTTP-Method-Override 或 _method 参数?如果有,授权层看到的方法和执行层看到的是不是同一个?
  4. 策略文件或规则表的方法列,允许写正则或通配吗?如果允许,加载时校验了吗?
  5. 「安全方法免 CSRF 校验」的前提——没有任何 GET 改状态——由什么保证?是有门禁,还是靠大家记得?
  6. 每个有副作用的 handler 第一行是不是授权检查?把网关策略整个删掉,它还能拒绝普通用户吗?

最后一条是最省事的判断方法:假设网关不存在。 如果一个 handler 在没有网关的情况下会对任何人放行,那它的授权就是借来的,借来的东西随时会被收走。

相关