Skip to content

安全:静态字段 readonly: true 仅 UI 层生效、服务端不强制 → 审批/状态/金额字段可被直接 PATCH 绕过写入(FLS 缺口) #3003

Description

@baozhoutao

概述

字段声明为静态 readonly: true 时,该限制只在 UI 层生效,服务端不会从更新载荷里剥离该字段。因此任何能 update 该记录的用户,都可以用一次直接的 REST PATCH 写入这个字段。凡是依赖 readonly: true 来保护「审批状态 / 审批节点 / 金额」等字段完整性的对象,都会被绕过。

对比:readonlyWhen: <CEL> 是服务端强制的——命中条件的字段在更新时被静默剥离(HTTP 200,值不变)。两者不一致。

  • 框架版本:15.0.0
  • 受影响对象的共享模型:public_read_write(默认 OWD)

影响(真机复现)

对象 sporadic_application(零星派工申请单)带 4 级审批流。approval_status、approval_stage 声明为 readonly: true;confirmed_total 是金额字段。

通过 Console UI + 同一登录会话内的浏览器 fetch(同一个已登录 worker 的 cookie,非管理员,isSystem=false)端到端复现:

  1. worker 在 Console UI 新建草稿。创建表单根本不渲染 approval_status / confirmed_total——UI 层看起来受保护,唯一合法动作是「提交审批」按钮。记录停在 draft。
  2. 在同一登录会话的浏览器里直接发:
    PATCH /api/v1/data/sporadic_application/<id>
    { "approval_status": "approved", "approval_stage": "approved",
      "confirmed_total": 99999 }
    → HTTP 200
    
  3. 刷新详情页:状态显示审批通过,流水线跳到终节点,confirmed_total = 99,999.00,并冒出下游「派工」动作按钮。

最终效果:申请人自我批准、完全跳过 4 级审批,并自定结算金额。

系统性——在第二个对象上用同样手法坐实:assessment(考核单,3 级签核,assessment_status 同样是 readonly: true)——PATCH { assessment_status:'approved', approval_stage:'approved' } → 200,dept_head → gm → finance 三级全跳过。凡是依赖 readonly: true 保护审批 / 状态 / 金额 / 判定字段的对象,一律中招。

补充:RECORD_LOCKED 只在审批流**活动态(pending)**时拦直接 PATCH。记录仍在 draft(流从未启动)时不会被锁,而 readonly: true 又只是 UI 层——所以 draft → approved 是一步直跳。

期望行为

readonly: true 的字段级写入应当服务端强制,和 readonlyWhen 字段在更新时被剥离的方式一致(insert 豁免)。即:对非特权写入者,静态 readonly: true 字段应在更新载荷里被静默丢弃。

线索 / 指引

  • readonlyWhen 已有服务端剥离路径(在 beforeUpdate 之后、基于合并的 {...previous, ...data} 求值,insert 豁免)。不一致点在于:静态 readonly: true 没有走同一条剥离路径。
  • 我们应用侧的临时绕行:把 readonly: true 改成 readonlyWhen,或加一个高优先级 beforeUpdate 守卫,在 !ctx.isSystem 时剥离这些受保护字段。

Activity

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

    @os-zhuang
    Contributor

    排查结论,分三块说:行为缺口本身、为什么 15.0.0 上能复现、以及本 issue 补掉的部分。

    1. 行为缺口:已在 main 上修复(#2948 → PR #2957)

    你期望的行为——静态 readonly: true 与 readonlyWhen 对称、在 UPDATE 时对非特权写入者做服务端剥离(insert 豁免)——已经由 #2957 实现,落在 objectql/engine.ts 的 update 路径(单条 + 多行都覆盖):

    • 非 system 上下文的 UPDATE,调用方提交的静态 readonly 字段被静默剥离(HTTP 200、持久值保留),与 readonlyWhen 的剥离语义一致;
    • 剥离只针对调用方提交的 key(在 hooks/middleware 之前快照),所以审计钩子写入的 updated_by/updated_at 等服务端戳不受影响;
    • isSystem: true(导入、种子回放、审批引擎、生命周期钩子)豁免;入站 HTTP 永远不会携带 isSystem,不可能借此绕过;
    • REST PATCH/PUT/批量/事务批处理最终都走 engine.update,同受保护。

    2. 为什么你在 15.0.0 上打穿了

    时间线问题:#2957 于 7 月 15 日合入 main,在 15.0.0 发布之后,尚未随任何版本发出(changeset 还在 .changeset/ 里待发布)。所以 15.0.0 上的复现完全成立——draft → approved 一步直跳、confirmed_total 自定金额,都是修复前的真实行为。下一个发布版本会带上该修复;在此之前,你们应用侧的绕行(改 readonlyWhen,或高优先级 beforeUpdate 在 !ctx.isSystem 时剥离)是正确的临时方案,升级后可移除。

    另外两点确认:

    • confirmed_total 若本身没有声明 readonly: true,修复后它仍是普通可写字段——金额字段要受保护,需要在元数据上显式声明(或用验证规则/FLS);
    • insert 豁免是有意的契约(创建时可播种 readonly 列:defaultValue、导入、迁移),与 readonlyWhen 对称。你们复现里创建表单不渲染这些字段是 UI 投影,服务端 POST 依然可以带值——如果这条边对你们是风险,建议单独开 issue 讨论是否要收紧。

    3. 本 issue 补的东西(PR #3015)

    修复已存在,但这次事故暴露的是契约被误读 + 没有端到端防回退,PR #3015 补齐:

    • 端到端 dogfood 证明(真 HTTP、真 showcase 应用):非管理员 owner 直接 PATCH 伪造 readonly 字段 → 200 且值不变、同载荷可编辑字段正常落库、纯伪造载荷为 no-op、insert 豁免显式钉住;
    • authz 一致性矩阵新增 readonly-static-write 行并列入 HIGH_RISK,证明文件删了 CI 就红;
    • ADR-0054 proof-registry 绑定 field/readonly,liveness 台账标 live 必须携带证明;
    • 契约文本修正:FieldSchema.readonly 的 Zod 描述原文就是 "Read-only in UI"——这正是这次误读的源头。描述、liveness 台账、手写文档现在都写明服务端剥离语义。

    PR CI 绿后合入,issue 随 PR 关闭。


    Generated by Claude Code

  3. added a commit that references this issue on Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecurity

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions