列表里能看到的订单,点进去却 403
「拾集」的用户中心有两个接口:订单列表和订单详情。列表按用户 ID 过滤,只返回当前登录用户的订单;详情按订单 ID 查询,再判断“这张订单是不是我的”。上线后,客服反馈了一个奇怪的现象:部分用户在列表里能看到订单,点进详情却收到 403。
排查后发现,两个接口用了不同的 owner 判定。列表用的是 order.UserID == session.UserID;详情用的是 order.UserID == path.UserID,而 path.UserID 是前端从本地缓存里带上的,用户切换过账号或清理过缓存后,这个值可能是旧的。列表用会话,详情用路径参数,两边一旦不一致,就会出现“看得见、点不开”。
还有一个更隐蔽的版本:后台客服账号能在列表里看到用户订单,因为列表对管理员放行;详情接口却只判断 order.UserID == viewer.UserID,忘了管理员分支,于是客服点开任何订单都是 403。客服只能靠列表里的摘要处理问题,看不到收货地址和明细,工单处理时间翻倍。
这两个故障的共同点,是把“能不能看”和“看什么”分散在多个接口里各写一遍。列表、详情、导出、消息推送、客服后台,每多一个入口就多一套判断,迟早会漂移。
用户视图与后台视图
订单查询要先回答两个问题:谁能看,以及看完之后展示哪些字段。把这两个问题分开,才能既保证安全,又让不同角色看到合适的信息:
| 字段 | 本人 | 管理员 / 客服 | 陌生人 |
|---|---|---|---|
| 订单号、状态、下单时间 | 可见 | 可见 | 拒绝访问 |
| 商品 SKU、数量 | 可见 | 可见 | 拒绝访问 |
| 单价、优惠、应付总额 | 可见 | 金额置零 | 拒绝访问 |
| 收货地址、手机号 | 可见 | 按工单权限单独申请 | 拒绝访问 |
| 支付流水号 | 可见 | 可见 | 拒绝访问 |
这张表是案例假设值,真实系统要按隐私政策和工单权限调整。但结构是通用的:可见性判断只做一次,字段裁剪按角色做,列表和详情共用同一套规则。
“管理员能看到”不等于“管理员能看到所有字段”。客服处理物流问题需要地址,处理退款需要金额,但日常查单不应该默认把所有敏感字段铺在页面上。字段裁剪应该是白名单:先决定这个角色能看哪些字段,再组装返回,而不是查出完整对象后随手删几个字段。
列表和详情的口径必须一致。列表返回的每一条,点进去都应该能打开;详情能打开的,列表里也应该能查到。这不是前端体验问题,而是后端鉴权一致性问题。只要两个接口各写一套判断,就一定会出现“列表有、详情无”或反过来“详情有、列表无”的情况。
失败场景:两套 owner 判定
「拾集」的订单列表和详情分别由两个团队维护。列表接口的鉴权逻辑是:
if order.UserID == session.UserID || session.IsAdmin { allow }
详情接口的鉴权逻辑是:
if order.UserID == path.UserID { allow }
上线两周后,客服团队反馈:管理员账号在订单列表里能看到所有订单,但点开详情全部 403。与此同时,C 端有 37 位用户反馈“列表里有订单,点进去提示无权查看”,原因是他们最近切换过账号,本地缓存的 path.UserID 还是旧账号。
这组数字是案例假设值,但它揭示了两类真实风险:
- 功能不可用:管理员和切换账号的用户被挡在详情外,客服效率下降,用户以为订单丢了。
- 鉴权绕过:如果详情改成信任
path.UserID,攻击者只要把路径里的用户 ID 改成别人的,就能越权查看订单。列表用会话、详情用路径,两个口径里总有一个是错的。
修复不是把详情改成和列表一样的 if,而是抽出一个所有入口共用的函数:
Visible(o Order, v Viewer) error只回答“这个查看者能不能访问这张订单”,本人或管理员通过,其他身份返回错误。Mask(o Order, v Viewer) (Order, error)在通过Visible之后,按角色裁剪字段:本人看完整订单,非本人看结构但金额置零。- 列表、详情、导出、客服后台全部调用这两个函数,禁止各自写 owner 判断。
- 测试覆盖本人、管理员、陌生人、匿名四种身份,以及“裁剪后不修改入参”。
Mask 必须返回副本。如果它直接在入参上把金额改成 0,调用方后续再用这张订单去计算退款或对账,就会拿到被污染的数据。切片也要复制,否则修改返回值的明细会影响原对象。
Go 实现:Visible 与 Mask
下面的代码来自本书配套的 webapp-lab。Visible 把“匿名、本人、管理员、陌生人”收敛成一个判断;Mask 先调用 Visible,再复制订单并按角色裁剪金额。两个函数都不依赖数据库和 HTTP,方便所有查询入口复用。
// file: internal/order/visibility.go
// tier: L1
// verify: go test ./internal/order/
package order
import "fmt"
// Viewer 描述一次查询的发起者。Admin 表示后台身份,可以查看任意订单,
// 但金额明细仍会被 Mask 裁剪。
type Viewer struct {
UserID string
Admin bool
}
// Visible 判断查看者能否访问这张订单。
func Visible(o Order, v Viewer) error {
if v.UserID == "" && !v.Admin {
return fmt.Errorf("order: viewer must have a user id or admin flag")
}
if v.Admin || (o.UserID != "" && v.UserID == o.UserID) {
return nil
}
return fmt.Errorf("order: user %q is not allowed to view order %q", v.UserID, o.ID)
}
// Mask 返回一个适合展示给 v 的订单副本。
//
// 本人看到完整订单;管理员和通过 Visible 检查的其他身份看到同样的
// 订单编号、用户、状态和商品结构,但每行金额被置零。函数不修改入参,
// 也不会让返回值与入参共享底层切片。
func Mask(o Order, v Viewer) (Order, error) {
if err := Visible(o, v); err != nil {
return Order{}, err
}
out := Order{
ID: o.ID,
UserID: o.UserID,
State: o.State,
}
if len(o.Items) > 0 {
out.Items = make([]Item, len(o.Items))
copy(out.Items, o.Items)
}
if o.UserID == v.UserID {
return out, nil
}
for i := range out.Items {
out.Items[i].PriceCents = 0
}
return out, nil
}
Visible 里有一条容易被忽略的规则:匿名查看者即使订单的 UserID 也是空字符串,也不能通过。空用户 ID 的订单通常是数据异常或系统单,不能因为“两个空值相等”就放行。管理员分支放在最前面,是因为后台身份不依赖订单归属。
Mask 先做完整复制,再对非本人把 PriceCents 置零。复制发生在裁剪之前,所以本人拿到的也是一个独立副本;调用方修改返回值不会影响原订单,也不会影响同一请求里后续的对账逻辑。
测试:本人、管理员、陌生人
测试覆盖四种身份和三个行为:能否访问、金额是否裁剪、入参是否被修改。最后一组尤其重要,因为 Mask 最容易写成的错误实现就是“直接在原对象上改”。
// file: internal/order/visibility_test.go
// tier: L1
// verify: go test ./internal/order/
package order
import (
"strings"
"testing"
)
func sampleOrder() Order {
return Order{
ID: "o-1",
UserID: "u-1",
Items: []Item{
{SKU: "a", PriceCents: 1999, Qty: 2},
{SKU: "b", PriceCents: 500, Qty: 1},
},
State: StatePaid,
}
}
func TestVisibleAllowsOwnerAndAdmin(t *testing.T) {
o := sampleOrder()
if err := Visible(o, Viewer{UserID: "u-1"}); err != nil {
t.Fatalf("owner rejected: %v", err)
}
if err := Visible(o, Viewer{UserID: "ops-9", Admin: true}); err != nil {
t.Fatalf("admin rejected: %v", err)
}
}
func TestVisibleRejectsStranger(t *testing.T) {
err := Visible(sampleOrder(), Viewer{UserID: "u-2"})
if err == nil {
t.Fatal("want error for stranger")
}
if !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("error %q does not mention authorization", err)
}
}
func TestVisibleRejectsAnonymous(t *testing.T) {
if err := Visible(sampleOrder(), Viewer{}); err == nil {
t.Fatal("want error for anonymous viewer")
}
}
func TestMaskKeepsOwnerViewIntact(t *testing.T) {
o := sampleOrder()
got, err := Mask(o, Viewer{UserID: "u-1"})
if err != nil {
t.Fatal(err)
}
if len(got.Items) != len(o.Items) {
t.Fatalf("items = %d, want %d", len(got.Items), len(o.Items))
}
for i := range o.Items {
if got.Items[i] != o.Items[i] {
t.Fatalf("item %d = %+v, want %+v", i, got.Items[i], o.Items[i])
}
}
}
func TestMaskHidesAmountsFromNonOwner(t *testing.T) {
o := sampleOrder()
got, err := Mask(o, Viewer{UserID: "ops-9", Admin: true})
if err != nil {
t.Fatal(err)
}
if got.State != StatePaid {
t.Fatalf("state = %q, want kept", got.State)
}
if len(got.Items) != 2 {
t.Fatalf("items = %d, want 2", len(got.Items))
}
for i, it := range got.Items {
if it.PriceCents != 0 {
t.Fatalf("item %d price = %d, want 0", i, it.PriceCents)
}
if it.SKU != o.Items[i].SKU || it.Qty != o.Items[i].Qty {
t.Fatalf("item %d structure changed: %+v", i, it)
}
}
}
func TestMaskDoesNotMutateInput(t *testing.T) {
o := sampleOrder()
if _, err := Mask(o, Viewer{UserID: "ops-9", Admin: true}); err != nil {
t.Fatal(err)
}
if o.Items[0].PriceCents != 1999 || o.Items[1].PriceCents != 500 {
t.Fatalf("input mutated: %+v", o.Items)
}
}
func TestMaskReturnsIndependentSlice(t *testing.T) {
o := sampleOrder()
got, err := Mask(o, Viewer{UserID: "u-1"})
if err != nil {
t.Fatal(err)
}
got.Items[0].PriceCents = 1
if o.Items[0].PriceCents != 1999 {
t.Fatalf("masked copy shares backing array with input")
}
}
func TestMaskRejectsStranger(t *testing.T) {
if _, err := Mask(sampleOrder(), Viewer{UserID: "u-2"}); err == nil {
t.Fatal("want error for stranger")
}
}
TestMaskHidesAmountsFromNonOwner 用管理员身份验证“能看但金额置零”,TestMaskKeepsOwnerViewIntact 验证本人看到完整明细,TestMaskDoesNotMutateInput 和 TestMaskReturnsIndependentSlice 分别从原对象和切片别名两个角度防止副作用。TestVisibleRejectsAnonymous 则守住空用户 ID 不被误判为本人。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/order/ -run 'TestVisible|TestMask' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestVisibleAllowsOwnerAndAdmin
--- PASS: TestVisibleAllowsOwnerAndAdmin (0.00s)
=== RUN TestVisibleRejectsStranger
--- PASS: TestVisibleRejectsStranger (0.00s)
=== RUN TestVisibleRejectsAnonymous
--- PASS: TestVisibleRejectsAnonymous (0.00s)
=== RUN TestMaskKeepsOwnerViewIntact
--- PASS: TestMaskKeepsOwnerViewIntact (0.00s)
=== RUN TestMaskHidesAmountsFromNonOwner
--- PASS: TestMaskHidesAmountsFromNonOwner (0.00s)
=== RUN TestMaskDoesNotMutateInput
--- PASS: TestMaskDoesNotMutateInput (0.00s)
=== RUN TestMaskReturnsIndependentSlice
--- PASS: TestMaskReturnsIndependentSlice (0.00s)
=== RUN TestMaskRejectsStranger
--- PASS: TestMaskRejectsStranger (0.00s)
PASS
ok webapp/internal/order 0.361s
输出里所有用例通过,只说明可见性与裁剪逻辑和测试一致,不说明字段白名单符合隐私要求。哪些角色能看地址、手机号、支付流水,仍然要由合规和业务共同定义。
小结
订单查询的坑通常不在 SQL,而在“谁能看”和“看什么”被分散到了多个接口。列表、详情、导出、客服后台各写一套 owner 判断,迟早会出现“列表能看、详情 403”或者更糟的越权。
Visible 只做一次可见性判断,Mask 在通过判断后按角色裁剪字段,并且返回独立副本。所有入口共用这两个函数,列表和详情的口径就不会漂移。安全上,路径参数永远不能作为身份来源;身份只能来自经过验证的会话或令牌。
阅读导航:上一节:2.2 订单状态机与超时关闭 · 下一节:3.1 支付渠道接入 。
常见错误与边界
- 列表和详情各写一套 owner 判断。两套逻辑一旦不一致,就会出现“看得见、点不开”或越权。
- 用路径参数里的用户 ID 做鉴权。路径参数由客户端控制,攻击者可以改成任意值。
- 管理员分支只加在列表,忘了详情。客服能看到摘要却打不开订单,工单效率直接下降。
Mask直接修改入参。后续对账或退款拿到被置零的金额,会产生错误结果。- 返回值和入参共享底层切片。修改返回值的明细会影响原订单,问题在并发请求下更难排查。
- 只判断“是不是本人”,不区分管理员和普通用户。后台要么看不到,要么看到过多敏感字段。
- 列表返回字段和详情返回字段各定一套。同一张订单在两个页面显示不同金额,用户无法判断哪个是真的。
- 空用户 ID 被当作“本人”。系统单或脏数据会因此对匿名请求放行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。