Skip to content

安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043

Description

@os-zhuang

概述

#2948 / #3003 修复后,静态 readonly: true 字段在非 system 的 UPDATE 上被服务端剥离——但 INSERT 被有意豁免(与 readonlyWhen 对称:创建时可播种 readonly 列,服务于 defaultValue / 导入 / 迁移)。

对审批 / 状态 / 判定类字段,这个豁免意味着:与其像 #3003 那样先建 draft 再 PATCH 提权,攻击者可以直接 POST 一条已经 approval_status: 'approved' 的新记录——比原复现还少一步,且 update 剥离完全拦不到(它只作用于 UPDATE)。

本 issue 请求 决策该豁免是否收紧(设计级,先定方向再实现)。

定位

packages/objectql/src/engine.ts insert()(L2185 起):

  • L2206-2212 applyFieldDefaults 只填 undefined 字段,不剥离 caller 提交的 readonly 列;
  • 全 insert 路径无 stripReadonlyFields 调用(该函数只在 update() 里被调,见 L2423 / L2442)。

对照 UPDATE 侧:stripReadonlyFields(rule-validator.ts)显式只在 update 调用,注释也写明「INSERT 豁免,symmetric with readonlyWhen」。

备选方向

  1. 维持现状 + 硬性文档指引(最省):承认 insert 可播种 readonly,把「审批/状态/金额/判定字段必须叠 state_machine 验证规则(强制初始状态)或 requiredPermissions FLS 兜底」写成 skill / 文档硬性约束。缺点:仍是「declared ≠ enforced」的认知负担,和 安全:静态字段 readonly: true 仅 UI 层生效、服务端不强制 → 审批/状态/金额字段可被直接 PATCH 绕过写入(FLS 缺口) #3003 同类误读风险。
  2. 收紧 insert:非 system 的 insert,readonly 列只允许取 defaultValue,caller 显式提交的值剥离。保留导入/迁移(走 isSystem)与默认值播种,堵掉「直接 POST approved」。需评估对现有依赖「创建时带 readonly 初值」的应用侧写法的兼容影响。
  3. 字段级 opt-in:给 readonly 增加一个 readonlyOnInsert(或 immutable)维度,让作者显式声明「连创建都不可由用户设」,审批/状态字段声明它。最精确,但扩了 spec 面。

判据 / 需核实

关联

Activity

  1. self-assigned this
    on Jul 18, 2026
  2. os-zhuang commented on Jul 18, 2026

    @os-zhuang
    ContributorAuthor

    落地说明(已修复 · PR #3162,已合并 beaf2de)

    方向决策

    在三个备选里选了收紧(方案 2 的方向),但实现落点从「引擎 insert()」调整到了外部数据写入入口——原因见下。放弃了方案 1(仅文档:其设想的 state_machine 初始态强制在 insert 上今天是 no-op,挡不住播种)和方案 3(字段级 opt-in:不扩 spec 面即可覆盖本威胁)。

    为什么不在引擎 insert() 剥离

    最初按与 #2948 对称的思路在引擎 insert() 里剥离,但 blast radius 远超本 issue「判据」一节的预估:better-auth adapter、metadata-repo 等核心框架写入方都用非 system 上下文经 engine.insert 播种 readonly 列(email/凭据、event_seq/id),被剥离后 dev-admin 登录失败、元数据事件日志 NOT NULL 崩溃——core/platform 里约 40+ 处同类调用点。即「合法依赖 insert 时由非 system 上下文写入 readonly 列」的对象比预估多得多。

    最终实现:入口级剥离

    落在 DataProtocol 的 create 汇聚点(createData / createManyData / batchData / cloneData)——REST CRUD、GraphQL/MCP dispatcher(bridge.create → callData → createData)、批量 import 全部经此,而可信内部写入方直接调 engine.insert、天然绕过。一处拦截覆盖所有 caller/agent 路径(含 MCP),又不误伤内部写入方。

    关键性质:

    • 仅剥离 caller 显式提交的键(入口 payload 是纯 caller 输入,owner_id/organization_id stamp 稍后在引擎内注入)。
    • 被剥离字段回落到 defaultValue(伪造 approval_status → draft,非 NULL)。
    • isSystem 豁免;静默(HTTP 2xx);批量/import 逐行;readonlyWhen 保持 INSERT 豁免。
    • 仅作者自定义业务对象:平台对象(managedBy 或 sys_)交由其自有字段治理——例如 ADR-0086 对 sys_permission_set 伪造 managed_by:'package'/package_id 是硬拒绝 403,若在此静默剥离会吞掉本应被拒的 payload。这与 applySystemFields 的边界一致,本 issue 的威胁面(app 审批/状态字段)从不涉及 sys_。

    与「判据/需核实」的对应

    行为变化(需应用侧知悉)

    非 system 经数据 API(REST/GraphQL/MCP/import)对自定义对象的 create,不再能从 payload 播种 readonly 列。需要在创建时写只读列的合法流程(导入历史 provenance、迁移、程序化 seed)须走 system 上下文——与 UPDATE 侧剥离早已施加的要求相同。

    证明

    • 入口单测 metadata-protocol/src/protocol.readonly-insert.test.ts(6 例:伪造剥离 + 默认回落、system 放行、批量逐行、平台对象豁免)。
    • dogfood showcase-static-readonly.dogfood.test.ts:非 system POST 伪造 lead_score 端到端被剥离;同时 two-doors-permission 的 ADR-0086 provenance 伪造仍 403。
    • authz-conformance.matrix.ts readonly-static-write 行扩展到 INSERT 面;field.zod describe + spec liveness note 同步。
    • 全量 dogfood 294 passed / 0 failed,CI 全绿。

    关联:#3003 / #2948(UPDATE 面,PR #2957 / #3015)—— 本 issue 是其 INSERT 面,现已闭合。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions