Skip to content

安全:readonlyWhen 服务端剥离仅覆盖单条 update 路径,多行 updateMany 不强制(条件只读可被批量绕过) #3042

Description

@os-zhuang

概述

readonlyWhen(条件只读)的服务端剥离 只在单条 by-id update 路径上运行;options.multi 的 多行 updateMany 路径不做 readonlyWhen 剥离。因此一个命中 readonlyWhen 条件、本应被锁的字段,可以用一次多行更新绕过——例如「invoice 变 paid 后锁 tax_rate / 锁行项」这类条件锁,PATCH … /updateMany 就能改写。

这是 #3003 / #2948 的同族问题:静态 readonly: true 的剥离(#2957)已同时覆盖单条 + 多行两条路径,但 readonlyWhen 的剥离只补了单条路径,两者不对称。

定位

packages/objectql/src/engine.ts,update():

  • 单条 by-id 路径(hookContext.input.id 分支):
    • L2416 stripReadonlyWhenFields(...) ✅
    • L2423 stripReadonlyFields(...)(静态 readonly,非 system)✅
  • 多行 options.multi && driver.updateMany 路径(L2427 起):
    • L2442 stripReadonlyFields(...)(静态 readonly)✅
    • 缺 stripReadonlyWhenFields(...) ❌

REST 侧多行入口存在且默认开启:packages/rest/src/rest-server.ts L6075 POST /data/:object/updateMany(operations.updateMany 默认 true,L1646),所以该路径对外可达。

根因

stripReadonlyWhenFields 需要 prior record 求值合并态 {...previous, ...data};多行路径出于成本考虑不逐行 fetch prior record(现有代码对 state_machine/cross_field 规则同样只 logger.warn 跳过,见 L2434-2436),readonlyWhen 顺势没接上剥离。

期望行为

多行 update 也应强制 readonlyWhen,与单条路径及静态 readonly 的多行覆盖对称。需要决策实现方式:

  • 方案 A(简单、可能过严):多行路径对任何声明了 readonlyWhen 的字段,若出现在载荷里就无条件剥离(不看 per-row prior,等价「宁可锁死」)。对「锁字段」语义通常可接受,但对少数 readonlyWhen 依赖行间差异的场景会误剥。
  • 方案 B(精确、成本高):多行路径按匹配集逐行拉 prior、逐行求值剥离——与 state_machine/cross_field 多行强制是同一笔账,可一并解决(去掉那处 warn 跳过)。
  • 方案 C(收口):若判定多行路径无法安全强制条件规则,则对「声明了 readonlyWhen 或 object-level 转换规则的对象」在多行 update 上 fail-closed 拒绝,把 warn 升级为 error。

倾向 A 或 C 作为短期封堵(和「剥离优先、兼容性好」的既有取向一致),B 作为长期精确解。

关联

Activity

  1. os-zhuang commented on Jul 16, 2026

    @os-zhuang
    ContributorAuthor

    修复已开 PR #3044(draft)。落地时核实出一处影响范围需要更正,如实同步:

    范围校正:外部 REST/SDK 面其实已安全

    本 issue 原文说「REST POST /data/:object/updateMany 对外可达 → 多行路径」——不准确。核实结论:

    • protocol.updateManyData(REST /updateMany 与 SDK updateMany 背后)是按 id 列表循环单条 engine.update,每条走单条路径,而单条路径已经剥离 readonlyWhen(L2431)。HTTP dispatcher / hono / SDK 的所有 update 入口也都是单条 where:{id}。
    • 所以真正没被强制的是引擎的真·where 谓词多行路径(options.multi=true),它只被编程式 / 嵌入式消费方触达:内部服务(lifecycle / messaging outbox / settings,均 system 上下文、本就豁免)、插件、以及 embed-objectql 这类直连 ObjectQL 的用户上下文调用方。

    因此严重性低于初判:外部 HTTP 攻击面本就走单条、已安全;缺口在编程式多行面。但修复仍然值得做——它是三件事:(1) 对称性(静态 readonly 在多行路径已剥离、readonlyWhen 没有,属 declared≠enforced 的不一致);(2) 封堵编程式/嵌入式用户上下文多行调用;(3) 纵深防御,若将来有路由接上真·where 批量更新即已覆盖。

    实现(采纳方案 B 的精确变体,而非 A/C)

    之前列的 A(无条件剥离)会误伤合法批量编辑;C(fail-closed 拒绝)与既有「静默剥离、兼容优先」取向不一致。最终实现是精确但仍批量安全的变体:

    • 多行路径仅当载荷确实写了 readonlyWhen 字段时,用与写入绑定的同一个已组合 AST(含 RLS/sharing 行级过滤)读回匹配集(一次查询);
    • 剥离「在 ≥1 匹配行谓词为 TRUE」的字段——批量载荷无法分行差异化,故任一目标行锁住即对整批 fail-safe 丢弃;
    • 无任一匹配行锁住的条件字段照常写入(合法批量编辑零回归);
    • 与单条 stripReadonlyWhenFields、静态 readonly 多行剥离对称,INSERT 豁免不变。

    证明层级(据校正后的面选取)

    集成测试直接驱动真实的 objectql.update(..., {multi:true, where}) 多行路径——这才是真实受影响面。HTTP dogfood 测试不会经过此路径(循环单条),加了会误导,故不加。objectql 全套 898 通过。

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


    Generated by Claude Code

  2. 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

No one assigned

    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