技术5/20/2026·8 min

把标识符做成不可错的:6 层防御契约

一个带点的 Google 邮箱原样进了 username,被拼成 org slug,后端用 422 拒掉。修法不是补一次校验,而是六层各自 fail-closed 的契约。

一个 422,二十五个潜伏缺陷

一名用户用 Google OAuth 注册,邮箱是 [email protected]。邮箱本地部分 kudin.private 原样进了 users.username。Web onboarding 页面随后用字符串模板拼出个人工作区的 slug:

const slug = `${user.username}-workspace`; // -> "kudin.private-workspace"
await lightCreateOrganization({ name, slug });

后端 slug 规则是 ^[a-z0-9]+(-[a-z0-9]+)*$,点号不在字符集里,于是创建请求返回 422,onboarding 走到死胡同。单个 case 很简单,但它代表的「类」不简单:审计发现有 25+ 标识符字段完全没有契约约束。任何被用作 URL 参数、@mention key 或查找 key 的列,距离同样的失败只差一次畸形输入。

制造这个 bug 的写入路径值得一读,因为它看起来是有防御的:

username := providerUsername
if username == "" {
    username = email            // 带点的本地部分原样存入
}
for i := 0; i < 100; i++ {      // 只去重,从不做规范化
    if _, err := s.GetByUsername(ctx, username); err != nil {
        break
    }
    username = fmt.Sprintf("%s_%d", providerUsername, i)
}

循环保证了唯一性,却对「形状」不做任何保证。这正是标识符的陷阱:唯一性和合法性是两个独立的属性,而处理了其中一个的代码,往往让人误以为两个都处理了。

契约的形状

先定规则本身。一个标识符(slug / username / pod_key / handle)的定义是:小写 ASCII 字母与数字,连字符只出现在段之间,长度 2–100,且不在保留字集合内。一条正则、一个长度边界、一份保留字表,只在 backend/pkg/slugkit 定义一次:

const (
    MinLen = 2
    MaxLen = 100
)
var pattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)

func Validate(s string) error {
    if len(s) == 0       { return ErrEmpty }
    if len(s) < MinLen   { return ErrTooShort }
    if len(s) > MaxLen   { return ErrTooLong }
    if !pattern.MatchString(s) { return ErrInvalidFormat }
    if IsReserved(s)     { return ErrReserved }
    return nil
}

在 API 边界加一次 Validate 调用,能挡住这一个 bug。但它挡不住另外 25 个字段,也撑不过下一个加上第 26 个字段、却忘了调用的工程师。所以这条规则在六个相互独立的层上各自落地,每一层都能单独 fail-closed。

Layer 1 — 数据库 CHECK

存储形状的权威源头是列本身:

ALTER TABLE users ADD CONSTRAINT users_username_format
  CHECK (username ~ '^[a-z0-9]+(-[a-z0-9]+)*$'
         AND char_length(username) BETWEEN 2 AND 100)
  NOT VALID;

NOT VALID 是承重的。表里已经躺着 kudin.private 时代的历史行;一个立即校验的约束会直接让 migration 失败。模式是两阶段:NOT VALID 立刻对新写入生效,backfill 重写历史行,之后一个 migration 跑 VALIDATE CONSTRAINT 把它升级为全量强制。新列(channels.slugapi_keys.slug)先以可空形态上线,backfill 完成后,再由 Phase-4 migration 加上 NOT NULLUNIQUE (organization_id, slug)

因为 Layer 1 对读取是权威的,类型化标识符的 Scan 刻意对入库的 DB 值重新校验——在读时再跑一遍 CHECK 会重复 DB 的保证,并且会破坏约束正在引入或放宽的那段窗口。读保持廉价,真正要守的是写。

Layer 2 — ORM 钩子,且不破坏 DIP

DB CHECK 只在写到达数据库时触发。一次带坏值的 db.Create 仍会往返一趟才失败。要更早拦下就得用 GORM 钩子——但 domain 模型不允许 import gorm(那会反转依赖方向)。解法是 domain 里放接口、infra 里放插件:

type IdentifierValidator interface {
    ValidateIdentifiers() error
}

每个 domain 模型实现它,不引入 gorm:

func (u *User) ValidateIdentifiers() error {
    return slugkit.ValidateIdentifier("users.username", u.Username)
}

gormvalidate.Plugin 注册 BeforeCreate / BeforeUpdate 回调,反射遍历语句,判断模型(经指针接收者)是否实现该接口并调用之。反射结果按类型用 sync.Map 记忆,于是从不携带标识符的关联表、消息行只付一次查表开销,而不是每行一次。钩子加在七个模型上:agent、agentpod、apikey、channel、loop、organization、user。

Layer 3 — registry 作为唯一写入路径

校验只拒绝坏输入,不产出好输入。把 "Kudin Private" 变成唯一且合法的 kudin-private 是「生成」,它落在每张表的 *Registry 助手里,并被声明为标识符列唯一被认可的写入路径:

func (s *Service) EnsureUniqueUsername(ctx context.Context, seeds []string) (string, error) {
    check := slugkit.FromExistsCheck(s.repo.UsernameExists)
    if u, ok := slugkit.TrySeeds(ctx, seeds, check); ok {
        return u, nil
    }
    return randomFallbackUsername(ctx, check) // "user-{8hex}"
}

TrySeeds 按优先级遍历种子列表——provider 用户名,其次邮箱本地部分,再次显示名——逐个规范化,碰撞时用 -2 / -3 后缀重试,全部用尽再回退到随机 handle。OAuth bug 里那段手写循环被删除,所有入口(Google / GitHub / GitLab / Gitee OAuth,以及 SAML、OIDC、LDAP)现在都汇入这一个助手。channel 与 api-key 的 slug 用 org 维度隔离的等价物。

Layer 4 — 编译器能查的 newtype

对新字段,目标是让「标识符列里塞裸 string」变得不可表达。slugkit.Slugstring 的 newtype,唯一带校验的入口是 UnmarshalJSON,于是畸形标识符在请求解析阶段就失败,而非深陷到 handler 里:

type Slug string

func (s *Slug) UnmarshalJSON(b []byte) error {
    var raw string
    if err := json.Unmarshal(b, &raw); err != nil {
        return err
    }
    sl, err := NewFromTrusted(raw) // 跑 Validate
    if err != nil {
        return err
    }
    *s = sl
    return nil
}

它同时实现 database/sqlScanner / Valuer,可直接落进 GORM 列。新的 UNIQUE 标识符列应当类型化为 Slug;裸 string 列被明确标注为依赖 Layer 1–3 兜底的技术债。

Layer 5 — 正则查不出的反模式交给 CI lint

Layer 1–4 拦的是坏值,拦不住工程师把反模式重新引回来——把 strings.Split(email, "@")[0] 直接塞进列,或在客户端拼 ${username}-workspacetools/identifier-lint/lint.sh 是六条基于 grep 的规则,每条针对本次事故里一个已知形状:

  • 在助手之外切分邮箱本地部分;
  • username_registry 与 OAuth 漏斗之外对 .Username 裸赋值;
  • 在客户端拼接 -workspace(Go 与 TypeScript 都查);
  • 新增 UNIQUE VARCHAR migration(序号 ≥ 000135)却没带 slug CHECK——并对 email / hash / token 这类本就不是 slug 的列设了显式白名单;
  • 在前端把 user.username 插值进路由路径。

CI 跑全量扫描;快速模式(IDENT_LINT_FAST=1)把范围收窄到与 origin/main 的 diff,供本地迭代。

Layer 6 — 在边界做净化,并把身份与展示拆开

最后一层处理本就自由格式的输入。channel 名称和 ticket 标题持有任意 Unicode——它们不是 slug——但仍是零宽字符、RTL/LTR 覆盖符和控制字节的攻击面。displaykit.Sanitize 剥除 Unicode 类别 Cf(format)与 C0/C1 控制符,折叠空白,并按 rune 而非 byte 计长,让中文与 emoji 各计为一。四个 MCP gRPC 适配器在边界调用它。

这逼出了一处属性模型清理,那才是真正的根因:channelsapi_keys 此前把 name 同时当展示串和查找 key。一列无法既是自由 Unicode 又是严格 slug。修法把两者拆开——name 纯展示,新增 slug 列作标识符——并配上 GET /channels/by-slug/:slugGET /apikeys/by-slug/:slug 供查找。

成本与结果

改动跨 132 个文件:约 +2,800 / −140 行,62 个新增 Go 测试函数,6 个端到端 case。onboarding 被覆盖两次——一次走 Web UI(Playwright),一次走 Electron NAPI 桥——因为客户端写入路径经过 Rust core → UniFFI/wasm/NAPI → electron-adapter,单测抓不到这条链上的断裂。slug 规则、长度边界与保留字集合在 TypeScript 侧镜像了一份,让客户端在往返前就拒掉坏输入。

两条可迁移的约束:

  • 唯一性和合法性是不同属性。 一个只去重、从不规范化的循环,会把攻击者或 OAuth provider 给的任何东西原样存下。把它们当作两次校验。
  • 一次校验是补丁;契约是各自 fail-closed 的多层。 DB CHECK、ORM 钩子、registry、newtype、CI lint、边界净化器是有意重叠的。去掉任意单层都不会重新捅开这个洞——而这正是能撑过下一个忘掉其中某层的工程师的那个性质。