输入边界不是参数校验清单
参数校验清单只回答“这个字段合不合法”,输入边界要回答三个更靠前的问题:请求体允许多大、哪些字段根本不该出现、解析失败时系统暴露多少信息。前者写起来很快,后者决定了一个畸形请求能不能把内存、日志或下游服务拖垮。
订单 API 的输入边界分两层。JSON 层负责体积、字段集合和对象数量;业务层负责格式、范围、重复与总额。两层都通过之后,数据才进入领域模型。这里不重复 API 契约的写法,契约与错误模型可以回看 5.3 错误模型、版本演进与兼容性 ;本节只关心“不可信输入在哪里被截断”。数据分级与输入边界是同一件事的两面:进来的字段要先知道它属于哪一级,出去写日志时才知道该保留、掩码还是完全抹掉。很多泄漏不是因为没做脱敏,而是因为字段没有分级,日志库只能一视同仁地打印。
JSON 层先挡三件事
第一,限制请求体大小。arch-lab 用 64 KiB,io.LimitReader 多读一个字节,超过就返回 ErrBodyTooLarge,而不是先完整读进内存再判断。第二,DisallowUnknownFields。客户端多发一个 admin: true,服务端应该拒绝,而不是静默忽略;静默忽略会让“字段名拼错”变成难查的业务问题,也会让攻击者试探内部字段。第三,拒绝对象之后的多余内容,{"a":1}{"b":2} 只接受第一个对象是危险的。
未知字段拒绝有代价:客户端升级与服务端升级必须协调,旧客户端多发字段会被新服务拒绝。折中是只在写接口严格拒绝,读接口忽略;或者用版本化契约,把新增字段放进明确的扩展对象。arch-lab 选择严格拒绝,因为订单创建是写路径,宁可让集成方早失败,也不让脏字段进入领域层。
业务层:范围、重复与总额
| 字段 | 规则 | 失败码 |
|---|---|---|
customer_id | cus_ + 6–32 位小写字母数字 | INVALID_FORMAT |
currency | CNY / USD / EUR | UNSUPPORTED |
items | 1–20 项 | OUT_OF_RANGE |
items[].sku | SKU- + 4–12 位大写字母数字 | INVALID_FORMAT |
items[].sku | 同一订单内不重复 | DUPLICATE |
items[].quantity | 1–99 | OUT_OF_RANGE |
items[].unit_price_cents | 1–5,000,000 | OUT_OF_RANGE |
| 订单总额 | ≤ 20,000,000 分 | TOTAL_EXCEEDED |
note | ≤200 字符,禁控制字符 | TOO_LONG / CONTROL_CHAR |
金额用整数分,不用浮点。单价上限 5 万元、数量上限 99、总额上限 20 万元,三个上限互相约束:单品超过总额上限没有意义,数量过大则会在库存与支付环节放大风险。总额累加前先判断 total > MaxTotalCents-line,避免 total + line 溢出;虽然本例的取值范围不会溢出,但这个写法在字段上限调整后仍然成立。请求体里的 unit_price_cents 只作为“客户端期望价”,服务端必须用商品目录重新计价。输入校验通过不代表价格可信:如果直接把客户端价格写入订单,攻击者可以把 1999 分改成 1 分。校验器只保证数值在合理区间,价格权威来自目录服务。
校验器:字段错误而不是一个布尔值
Validate 返回 []FieldError,每个错误带字段路径、稳定错误码和内部消息。字段路径让调用方能定位到 items[1].sku,稳定错误码让客户端可以编程处理,内部消息只进日志。对外响应只暴露前两者,避免把正则、上限和实现细节回显给攻击者。
// file: internal/boundary/order.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary
import (
"bytes"
"encoding/json"
"errors"
"io"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
const (
MaxBodyBytes = 64 << 10
MaxItems = 20
MaxNoteRunes = 200
MaxTotalCents = 20_000_000 // 单笔订单上限 20 万元
)
var (
ErrBodyTooLarge = errors.New("boundary: request body too large")
ErrTrailingData = errors.New("boundary: body must contain exactly one JSON object")
reCustomer = regexp.MustCompile(`^cus_[a-z0-9]{6,32}$`)
reSKU = regexp.MustCompile(`^SKU-[A-Z0-9]{4,12}$`)
currencies = map[string]bool{"CNY": true, "USD": true, "EUR": true}
)
type Item struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
UnitPriceCents int64 `json:"unit_price_cents"`
}
type CreateOrder struct {
CustomerID string `json:"customer_id"`
Currency string `json:"currency"`
Items []Item `json:"items"`
Note string `json:"note"`
}
// FieldError 指到具体字段,便于调用方修正;对外只返回 code 与 field,
// message 仅用于内部日志和测试断言。
type FieldError struct {
Field string
Code string
Message string
}
// DecodeCreateOrder 做 JSON 层的三件事:限制体积、拒绝未知字段、
// 拒绝对象之后的多余内容。业务范围校验放在 Validate 里。
func DecodeCreateOrder(r io.Reader) (CreateOrder, error) {
var req CreateOrder
raw, err := io.ReadAll(io.LimitReader(r, MaxBodyBytes+1))
if err != nil {
return req, err
}
if len(raw) > MaxBodyBytes {
return req, ErrBodyTooLarge
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return req, err
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return req, ErrTrailingData
}
return req, nil
}
func Validate(req CreateOrder) []FieldError {
var errs []FieldError
add := func(field, code, msg string) {
errs = append(errs, FieldError{Field: field, Code: code, Message: msg})
}
if !reCustomer.MatchString(req.CustomerID) {
add("customer_id", "INVALID_FORMAT", "want cus_ plus 6-32 lowercase alnum")
}
if !currencies[req.Currency] {
add("currency", "UNSUPPORTED", "want one of CNY, USD, EUR")
}
if len(req.Items) == 0 || len(req.Items) > MaxItems {
add("items", "OUT_OF_RANGE", "want 1-20 items")
}
if utf8.RuneCountInString(req.Note) > MaxNoteRunes {
add("note", "TOO_LONG", "note exceeds 200 runes")
}
if strings.ContainsFunc(req.Note, func(r rune) bool { return r != '\n' && r != '\t' && unicode.IsControl(r) }) {
add("note", "CONTROL_CHAR", "control characters are not allowed")
}
seen := make(map[string]bool, len(req.Items))
var total int64
for i, it := range req.Items {
prefix := "items[" + strconv.Itoa(i) + "]"
if !reSKU.MatchString(it.SKU) {
add(prefix+".sku", "INVALID_FORMAT", "want SKU- plus 4-12 uppercase alnum")
}
if seen[it.SKU] {
add(prefix+".sku", "DUPLICATE", "sku appears more than once")
}
seen[it.SKU] = true
if it.Quantity < 1 || it.Quantity > 99 {
add(prefix+".quantity", "OUT_OF_RANGE", "want 1-99")
}
if it.UnitPriceCents < 1 || it.UnitPriceCents > 5_000_000 {
add(prefix+".unit_price_cents", "OUT_OF_RANGE", "want 1-5000000")
}
line := it.UnitPriceCents * int64(it.Quantity)
if line > 0 && total > MaxTotalCents-line {
add("items", "TOTAL_EXCEEDED", "order total exceeds 20000000 cents")
continue
}
total += line
}
return errs
}
note 按 rune 计数而不是字节,避免中文备注被过早截断;控制字符允许换行和制表,其余拒绝,防止日志注入和终端转义序列。sku 用大写字母数字的固定前缀,是为了让来源可辨认,不是为了安全;真正的商品存在性由目录服务判断。
数据分级的四类
| 级别 | 例子 | 日志策略 |
|---|---|---|
public | 订单号、商品 SKU | 原样 |
internal | 状态、金额、耗时 | 原样,但控制基数 |
pii | 邮箱、手机、姓名、地址、IP | 掩码 |
payment | 卡号、CVV、银行账号 | 只留后四位,CVV 不记录 |
secret | 密码、令牌、API key | 完全抹掉 |
分级放在字段声明处,而不是日志调用处。同一个 customer_email 在订单服务、通知服务和风控服务里都应该是 pii;如果每个调用点各自决定要不要脱敏,迟早有一个漏掉。分级也要落在数据字典里,和字段的血缘、保留期放在一起,否则新服务接入时只能靠猜。CVV 特殊:它不是“掩码后可以记录”,而是任何日志、追踪、事件里都不应该出现。分级表里把它和卡号放在同一级,但策略要单独写成“禁止落盘”。同理,完整的 Authorization 头和支付回调签名也不能进日志,它们属于 secret。
脱敏是分级的下游
脱敏函数按级别分派,而不是按字段名分派。邮箱保留首字符和域名,手机号保留后四位,姓名保留首字,卡号只留后四位;未知级别默认抹掉,宁可少记日志,也不要因为新增枚举值而泄漏。域名保留是运维需要,它不指向具体个人。
// file: internal/boundary/redact.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary
import (
"strings"
"unicode/utf8"
)
// Class 是日志字段的分级。分级在前,脱敏在后:先声明字段属于哪一类,
// 再按类选择脱敏策略,避免每个调用点各写一套正则。
type Class int
const (
Public Class = iota
Internal
PII
Payment
Secret
)
func MaskValue(c Class, v string) string {
switch c {
case Public, Internal:
return v
case PII:
return maskPII(v)
case Payment:
return maskPayment(v)
default:
return "[REDACTED]"
}
}
func maskPII(v string) string {
if at := strings.IndexByte(v, '@'); at > 0 {
r, _ := utf8.DecodeRuneInString(v[:at])
return string(r) + "***" + v[at:]
}
if d := digits(v); len(d) >= 7 {
return "***" + d[len(d)-4:]
}
r, size := utf8.DecodeRuneInString(v)
if size == 0 {
return ""
}
return string(r) + "**"
}
func maskPayment(v string) string {
d := digits(v)
if len(d) < 4 {
return "****"
}
return "****" + d[len(d)-4:]
}
func digits(s string) string {
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
这个实现刻意不追求“看起来像原值”。掩码的目标是让日志仍然可读、可关联,而不是保留格式供人还原。卡号保留后四位是为了客服核对,邮箱保留域名是为了排查投递问题;除此之外的字符都应该消失。
表驱动测试:八类坏输入
校验测试用变异法:先构造一条合法订单,每次只改一个字段,断言返回的错误字段与错误码。这样新增规则时只需加一行,不会把测试写成大段重复代码。JSON 层单独测未知字段、尾随对象和超限体积;脱敏用一张输入输出表。
// file: internal/boundary/boundary_test.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary
import (
"errors"
"strings"
"testing"
)
func validOrder() CreateOrder {
return CreateOrder{
CustomerID: "cus_abc123",
Currency: "CNY",
Items: []Item{{SKU: "SKU-AB12", Quantity: 2, UnitPriceCents: 1999}},
Note: "请放前台\n谢谢",
}
}
func TestValidateTable(t *testing.T) {
cases := []struct {
name string
mut func(*CreateOrder)
field string
code string
}{
{"customer malformed", func(o *CreateOrder) { o.CustomerID = "CUS_123" }, "customer_id", "INVALID_FORMAT"},
{"currency unsupported", func(o *CreateOrder) { o.Currency = "JPY" }, "currency", "UNSUPPORTED"},
{"no items", func(o *CreateOrder) { o.Items = nil }, "items", "OUT_OF_RANGE"},
{"bad sku", func(o *CreateOrder) { o.Items[0].SKU = "sku-ab" }, "items[0].sku", "INVALID_FORMAT"},
{"duplicate sku", func(o *CreateOrder) { o.Items = append(o.Items, o.Items[0]) }, "items[1].sku", "DUPLICATE"},
{"quantity zero", func(o *CreateOrder) { o.Items[0].Quantity = 0 }, "items[0].quantity", "OUT_OF_RANGE"},
{"note control char", func(o *CreateOrder) { o.Note = "ok\x00hidden" }, "note", "CONTROL_CHAR"},
{"total exceeded", func(o *CreateOrder) {
o.Items = []Item{{SKU: "SKU-AB12", Quantity: 99, UnitPriceCents: 5_000_000}}
}, "items", "TOTAL_EXCEEDED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := validOrder()
tc.mut(&o)
if !hasFieldErr(Validate(o), tc.field, tc.code) {
t.Fatalf("want %s/%s, got %v", tc.field, tc.code, Validate(o))
}
})
}
}
func TestDecodeRejectsUnknownAndTrailing(t *testing.T) {
base := `{"customer_id":"cus_abc123","currency":"CNY","items":[{"sku":"SKU-AB12","quantity":1,"unit_price_cents":100}]}`
if _, err := DecodeCreateOrder(strings.NewReader(base)); err != nil {
t.Fatalf("valid body rejected: %v", err)
}
unknown := strings.Replace(base, `}`, `,"admin":true}`, 1)
if _, err := DecodeCreateOrder(strings.NewReader(unknown)); err == nil {
t.Fatal("unknown field accepted")
}
if _, err := DecodeCreateOrder(strings.NewReader(base + base)); !errors.Is(err, ErrTrailingData) {
t.Fatalf("trailing body err = %v, want ErrTrailingData", err)
}
big := strings.Repeat(" ", MaxBodyBytes) + base
if _, err := DecodeCreateOrder(strings.NewReader(big)); !errors.Is(err, ErrBodyTooLarge) {
t.Fatalf("oversized body err = %v, want ErrBodyTooLarge", err)
}
}
func TestMaskValueTable(t *testing.T) {
cases := []struct {
class Class
in string
want string
}{
{Public, "order", "order"},
{Internal, "ord_01", "ord_01"},
{PII, "alice@example.com", "a***@example.com"},
{PII, "13800138000", "***8000"},
{PII, "李雷", "李**"},
{Payment, "6222 0202 0011 2233 445", "****3445"},
{Secret, "sk_live_abc", "[REDACTED]"},
}
for _, tc := range cases {
if got := MaskValue(tc.class, tc.in); got != tc.want {
t.Fatalf("MaskValue(%d, %q) = %q, want %q", tc.class, tc.in, got, tc.want)
}
}
}
func hasFieldErr(errs []FieldError, field, code string) bool {
for _, e := range errs {
if e.Field == field && e.Code == code {
return true
}
}
return false
}
实测输出
模块为 archlab(go 1.26.0),执行 gofmt -l .、go vet ./...、go test ./internal/boundary/ -v,格式化与静态检查无输出,测试输出节选如下(省略子测试行)。八个变异用例命中预期字段与错误码,未知字段、尾随对象和 64 KiB 超限请求都被挡在解码层,七条脱敏断言全部通过:
=== RUN TestValidateTable
--- PASS: TestValidateTable (0.00s)
=== RUN TestDecodeRejectsUnknownAndTrailing
--- PASS: TestDecodeRejectsUnknownAndTrailing (0.00s)
=== RUN TestMaskValueTable
--- PASS: TestMaskValueTable (0.00s)
PASS
ok archlab/internal/boundary 0.603s
常见错误与边界
第一,只校验类型不校验范围。int 能装下负数,string 能装下一兆字节。第二,忽略未知字段。静默忽略会让拼写错误变成脏数据。第三,把客户端价格当权威。校验通过不等于可信,价格必须由服务端目录重算。第四,错误响应泄漏结构体字段名,日志默认记录完整请求体。这两处都在校验器之外,却同样属于输出边界。第五,按字段名散落脱敏。新增一个 contact_email 就漏一次,应该按分级统一处理。第六,把 CVV 当成“掩码后可存”。它不是可脱敏字段,而是禁止记录字段。
边界也要写清楚:本节只覆盖同步 JSON API,文件上传、表单、gRPC 和消息体需要各自的体积与解析边界;分级只解决日志与展示,不替代加密、访问控制和保留期策略;DisallowUnknownFields 对重复键无效(后值覆盖前值),严格场景需要额外的 schema 校验。输入校验是纵深防御的一层,不是唯一一层。
小结
输入边界分两层:JSON 层限制体积、拒绝未知字段与尾随对象;业务层限制格式、范围、重复与总额。校验器返回字段级错误而不是一个布尔值,稳定错误码给客户端,内部消息进日志。数据分为 public、internal、pii、payment、secret 五级,脱敏按级别分派,未知级别默认抹掉。八个表驱动用例、四条解码断言和七条脱敏断言在 gofmt、go vet、go test 下全部通过,畸形输入在进入领域模型之前就被截断。
阅读导航:上一节:10.1 认证、授权与服务间身份 · 下一节:10.3 第三方支付渠道的信任边界与密钥管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。