电商篇:安全——基于请求方法的访问控制绕过
本文是全栈电商微服务从 0 到 1 再到 10系列的第 6 篇。上一篇:电商篇:客户支持——配置自定义域名邮箱。服务端访问控制的通用准则见默认拒绝:别让前端流程替服务器做访问控制,本文只讲其中一种具体绕过方式,以及它在本项目里的核查与修补。
有一类越权漏洞不需要猜密码、不需要找注入点,攻击者只改请求行的第一个单词:把 POST 换成 GET。授权层没匹配到规则,业务代码照样执行,一个普通用户就把自己改成了管理员。
这篇文章分三部分:先讲它为什么成立;再拿本项目的网关和微服务代码逐层核查,看它在这套架构里能不能成立;最后把核查中发现的缺口补上,并留下门禁防止它回来。
漏洞原理:授权层和执行层对「操作」的理解不一致
一个很常见的实现习惯
漏洞不是来自某个框架的 bug,而是来自一个几乎每个团队都写过的组合:
授权规则:p, admin, /api/users/*/role, POST, allow处理函数:func UpdateRole(w, r) { ... 不看 r.Method ... }路由注册:mux.HandleFunc("/api/users/{id}/role", UpdateRole) ← 所有方法都进这个函数三行各自都没错。授权规则精确到方法,是好习惯;处理函数只管业务,是好习惯;路由注册不限方法,是 net/http 的默认行为。合在一起就出了问题:授权层认为「操作」是 (路径, 方法),执行层认为「操作」是路径。 两边对同一个请求的理解不同,攻击者只需要找到落在这个缝隙里的请求。
攻击者的三步推理
攻击者不是在猜,是在利用这个习惯:
- 抓到管理员的
POST /api/users/42/role {"role":"admin"},用普通用户身份重放,得到 403。这说明这条规则在起作用,且规则确实检查了角色。 - 授权规则按 (路径, 方法) 匹配,规则表里只写了
POST。那么GET没匹配到任何规则会怎样?三种可能:默认拒绝(安全)、默认放行(漏洞)、根本没有规则去查(漏洞)。 - 把方法改成
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 里显式标注:
rpc GetProductDetail(GetProductDetailRequest) returns (GetProductDetailResponse) { option idempotency_level = NO_SIDE_EFFECTS; // 只有标了它,Connect 才放开 GET}核查本仓全部 14 份公开 proto,没有一个方法带这个选项,也没有任何服务用 connect.WithIdempotency 在代码里开启它:
$ 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 业务路由:
paymentv1connectPath, paymentv1connectHandler := paymentv1connect.NewPaymentServiceHandler( paymentv1Service, combinedOptions...,)mux.Handle(paymentv1connectPath, paymentv1connectHandler)结论:在业务服务这一层,GET /user.v1.UserService/UserProfile 会被 Connect 生成代码以 405 拒绝,到不了业务函数。方法改写在 handler 就死了。
第二层:网关 Casbin 默认拒绝
网关比 handler 更早。鉴权中间件把 HTTP 方法原样交给 Casbin:
// control-tower services/gateway/internal/httpmw/auth.goallowed, 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 是这样的:
[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。网关的既有测试就断言了这一点:
// 方法不匹配。{[]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 校验:
// control-tower services/gateway/internal/httpmw/auth.goif 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 看,商家服务的审批接口是这样的:
// 修补前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 内零授权。它能不被普通用户调用,完全依赖网关策略表里的那一行:
p, admin, /merchant.v1.MerchantService/ApproveApplication, POST, allow这行策略被改宽、请求从服务间调用直接到达(东西向不经网关)、或某次误配把这条路由放进了匿名清单——任何一种情况下,一个商家申请就能被任意人批准。这和开头那个 UpdateRole 是同一个病:判据在网关,执行在服务,两处对「谁能审批」没有共同的定义。
防御:让权限绑在操作上,不绑在方法上
修法不是给 ApproveApplication 加一句 if r.Method != "POST"——那是把授权层的错误理解复制到执行层。三条根治:
一、授权检查放在 handler 内部,判「动作」不判「方法」
网关的 Casbin 保留,但降级为粗粒度放行(谁能碰这条路);真正的判定在服务里。先在共享包 pkg/identity 加一个角色判定:
// 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 的入口统一加一行:
// 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 而失败:
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 行做列值校验:
// 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 仍然拒绝:
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 前逐条核对——
- 真的无副作用:不写库、不发事件、不扣库存、不改会话、不记「最后访问时间」之类的状态。「读多写少」不算,「只写日志」算。
- 参数进 URL 可接受:GET 的请求体走 query string,会进访问日志、浏览器历史、Referer。带 PII 或令牌的请求不能标。
- 同一个 PR 三处同步:proto 加选项、结构检查白名单加 procedure、网关
allowedAct放开该 procedure 的 GET。少任何一处,要么门禁红,要么标了也到不了 handler。 - CSRF 豁免只对它成立:写进方法注释,说明它为何无副作用,评审时对着注释核。
规则写在文档里会被忘,所以再加一道结构检查(本项目用 Go 测试做仓库级门禁,go test ./structcheck/... 是提交前必跑项):
// 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 注入一行选项,跑测试,看它红在正确的位置,再还原:
--- 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 第一行。三层没有一层依赖另一层「碰巧」做对。
自查清单
核查自己项目时按这个顺序:
- 路由注册限方法了吗?
mux.HandleFunc("/path", h)这种写法,所有方法都进同一个函数。 - 授权规则按 (路径, 方法) 匹配时,没匹配到规则的默认行为是什么?拿一条只写了
POST的规则,发GET、HEAD、PUT、PATCH、OPTIONS各试一次。 - 有没有中间件处理
X-HTTP-Method-Override或_method参数?如果有,授权层看到的方法和执行层看到的是不是同一个? - 策略文件或规则表的方法列,允许写正则或通配吗?如果允许,加载时校验了吗?
- 「安全方法免 CSRF 校验」的前提——没有任何 GET 改状态——由什么保证?是有门禁,还是靠大家记得?
- 每个有副作用的 handler 第一行是不是授权检查?把网关策略整个删掉,它还能拒绝普通用户吗?
最后一条是最省事的判断方法:假设网关不存在。 如果一个 handler 在没有网关的情况下会对任何人放行,那它的授权就是借来的,借来的东西随时会被收走。
相关
- 默认拒绝:别让前端流程替服务器做访问控制 —— 服务端访问控制的七条通用准则,本文是其中原则三和原则六的具体实例。
- 把 admin=false 改成 true:拿自己的网关代码做一次攻防复盘 —— 本文信任的
x-md-global-*身份头,其剥离与注入机制在那篇里逐层验证过。