## 背景 #3003 排查过程中发现的同族残留:#2948/#2957 把**静态** `readonly` 的剥离补到了单条与多行两条 UPDATE 路径,但**条件只读 `readonlyWhen` 从一开始就只在单条路径强制**——多行路径完全不评估、不剥离。 ## 定位(以当前 main 为准) `packages/objectql/src/engine.ts` `update`: - **单条 id 路径**(`engine.ts:2405-2426`):fetch `priorRecord` → `stripReadonlyWhenFields` 基于合并的 `{...previous, ...data}` 求值并剥离(`:2416`)→ 强制 ✅ - **多行路径**(`engine.ts:2427-2445`,`options.multi && driver.updateMany`):不 fetch per-row prior(逐行查询无上界),只跑静态 `stripReadonlyFields`(`:2442`,#2948);`readonlyWhen` **不求值、不剥离** ❌ ## 暴露面 REST 直接暴露 `POST /data/:object/updateMany`(`packages/rest/src/rest-server.ts:6076` → `updateManyData` → 引擎多行路径)。 攻击示意(按代码路径判断成立,待复现坐实):`showcase_invoice.tax_rate` 声明 `readonlyWhen: record.status == 'paid'`(paid 后锁税率)。单条 `PATCH { tax_rate: 0 }` 会被剥离;换成 `POST /data/showcase_invoice/updateMany`、`where` 精确命中同一条已 paid 记录、载荷 `{ tax_rate: 0 }` → 写
背景
#3003 排查过程中发现的同族残留:#2948/#2957 把静态
readonly的剥离补到了单条与多行两条 UPDATE 路径,但条件只读readonlyWhen从一开始就只在单条路径强制——多行路径完全不评估、不剥离。定位(以当前 main 为准)
packages/objectql/src/engine.tsupdate:engine.ts:2405-2426):fetchpriorRecord→stripReadonlyWhenFields基于合并的{...previous, ...data}求值并剥离(:2416)→ 强制 ✅engine.ts:2427-2445,options.multi && driver.updateMany):不 fetch per-row prior(逐行查询无上界),只跑静态stripReadonlyFields(:2442,security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948);readonlyWhen不求值、不剥离 ❌暴露面
REST 直接暴露
POST /data/:object/updateMany(packages/rest/src/rest-server.ts:6076→updateManyData→ 引擎多行路径)。攻击示意(按代码路径判断成立,待复现坐实):
showcase_invoice.tax_rate声明readonlyWhen: record.status == 'paid'(paid 后锁税率)。单条PATCH { tax_rate: 0 }会被剥离;换成POST /data/showcase_invoice/updateMany、where精确命中同一条已 paid 记录、载荷{ tax_rate: 0 }→ 写