fix(security): enforce readonlyWhen on the multi-row UPDATE path (#3042)#3044
Merged
Conversation
Conditional `readonlyWhen` field locks were stripped only on the single-id
UPDATE path (`stripReadonlyWhenFields`). The bulk `update({ multi: true, where })`
path enforced static `readonly` (#2948) but never `readonlyWhen` — an asymmetry
with both the single-id path and the static-readonly bulk strip. A programmatic
or embedded caller (or a plugin) issuing a multi-row update in a user context
could write a field its own `readonlyWhen` predicate should have locked.
Scope note: the external REST/SDK `updateMany` endpoint was already safe — it
loops single-id `engine.update` calls, each of which strips `readonlyWhen`. The
gap was the engine's true where-predicate multi path, reached programmatically
(embedded ObjectQL, plugins, internal services) — plus defense-in-depth for any
future route that wires a bulk where-based update.
engine.update now, on the multi-row path and only when the payload actually
writes a `readonlyWhen` field (cheap `hasReadonlyWhenInPayload` gate), reads the
row-scoped match set with the SAME composed AST the write binds (one query) and
drops any field whose predicate is TRUE for at least one matched row via the new
`stripReadonlyWhenFieldsMulti`. A single bulk payload cannot keep a field for
some rows and drop it for others, so a field locked in any target row is
fail-safe-dropped for the batch; a conditional field no matched row locks is
written normally (legitimate bulk edits unaffected). Symmetric with the single-id
strip; INSERT stays exempt. The per-field predicate eval is factored into a
shared `isReadonlyWhenLocked` helper used by both single-id and bulk strips.
Tests: rule-validator unit tests for `hasReadonlyWhenInPayload` and
`stripReadonlyWhenFieldsMulti` (locked-in-all / locked-in-one / locked-in-none /
empty-match / no-field-in-payload), plus an engine integration test driving the
real `objectql.update(..., { multi: true, where })` path (forge stripped when a
matched row is locked, sibling editable field survives, kept when no row locks
it, pre-read is row-scoped with the write's predicate). objectql suite: 898
passed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 16, 2026 10:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景(#3042)
条件只读
readonlyWhen的服务端剥离此前只在单条 by-id update 路径上运行(stripReadonlyWhenFields);多行update({ multi: true, where })路径只强制了静态readonly(#2948),从不强制readonlyWhen——与单条路径、以及静态 readonly 的多行剥离都不对称。用户上下文下的编程式/嵌入式调用方(或插件)发起多行更新时,可以写入本应被自身readonlyWhen谓词锁住的字段。影响范围校正(重要,诚实说明)
排查后确认:issue 原文「REST
POST /data/:object/updateMany对外可达 → 多行路径」的判断不准确。核实结论:/updateMany端点与 SDKupdateMany本身是安全的——protocol.updateManyData是按 id 列表循环单条engine.update(每条走单条路径、已剥离readonlyWhen);HTTP dispatcher / hono / SDK 的所有 update 入口也都是单条where:{id}。options.multi=true),它只被编程式/嵌入式消费方触达:内部服务(lifecycle / messaging outbox / settings,均 system 上下文)、插件、以及embed-objectql这类直连 ObjectQL 的用户上下文调用方。因此这是一个对称性 + 纵深防御 + 编程式面封堵的修复,严重性低于 issue 初判。相应地,证明层级也据此选取:集成测试直接驱动真实的
objectql.update(..., {multi:true, where})多行路径——这才是真实受影响面;HTTP dogfood 测试不会经过此路径(它循环单条),加了反而误导,故不加。变更
engine.update多行路径,仅当载荷确实写了readonlyWhen字段时(廉价的hasReadonlyWhenInPayload门控):stripReadonlyWhenFieldsMulti剥离「在 ≥1 匹配行上谓词为 TRUE」的字段——一次批量载荷无法对部分行写、部分行跳,故只要任一目标行锁住即对整批 fail-safe 丢弃(想更新未锁行请收窄where);stripReadonlyWhenFields、静态 readonly 多行剥离对称;INSERT 豁免不变。isReadonlyWhenLocked,零行为漂移。验证
packages/objectql全套 898 通过(67 文件)。hasReadonlyWhenInPayload+stripReadonlyWhenFieldsMulti(全锁 / 单锁 / 全不锁 / 空匹配 / 载荷无该字段)。objectql.update(...,{multi:true,where})——伪造字段在有锁行时被剥离、同载荷可编辑字段存活、无锁时保留、且预读用的是与写入相同的行级谓词 AST。tsc --noEmit、objectql DTS build 均绿。纯行为修复,但会改变嵌入式消费方的可观察行为(multi:true 下曾可写的
readonlyWhen字段现被静默丢弃),故附@objectstack/objectqlpatch changeset。分支说明:上一轮 #3003 的 designated 分支 PR(#3015)已合并,本次为独立后续修复,从最新 main 起新分支开出,不复用已合并历史。
Closes #3042.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
Generated by Claude Code