Skip to content

finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS $field filter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988

Description

@huangyiirene

Observation-class finding, filed unassigned with no pm:queue — for triage to grade and route. Measured while executing the #7929 measurement dispatch (domain:engine-core seat, 2026-08-12). Recording, not claiming.

Blocked-by: #7929

Why this is filed separately from #7929

#7929's body, the triage block, and the 2026-08-12 maintainer ruling on it all frame the disclosure as a consequence of #7598's Q1 = B routing — "the refusal moved to the driver along with the compilation" — and the ruling's implementation boundaries scope the pins accordingly:

Pin both directions: (a) a read scope carrying a driver-refused reference answers the withheld envelope with no field-name echo — extend the analytics-query-read-scope-withhold pin family to this path

That framing is incomplete, and a fix scoped to it would leave the larger half open. The ordinary CRUD read path has the identical disclosure, and did not need #7598 to get it. It arrived with #5222 (2026-08-06), when driver-sql learned to compile { $field } and to refuse it with INVALID_FILTER / 400.

The two paths differ only in which component merges the admin's read filter into the query:

path merge boundary file
analytics POST /analytics/query ObjectQLStrategy.withReadScope packages/services/service-analytics/src/strategies/objectql-strategy.ts (withReadScope)
ordinary CRUD read security middleware ANDs into opCtx.ast.where packages/plugins/plugin-security/src/security-plugin.ts:2198

Both hand the driver a bare FilterCondition with no provenance mark, and driver-sql's applyCrossFieldComparison refuses identically in both cases.

Measured

Real ObjectQL engine, real SqliteWasmDriver seeded with the shared CROSS_FIELD_* corpus fixture, and a read-filter middleware shaped exactly like the security/sharing ones (ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped) injecting an admin-authored policy predicate the caller never wrote:

scoped = { amount: { $gt: { $field: 'secret_policy_column' } } }
caller  = ql.find('cross_field_deal', { where: { stage: 'won' } })

What the engine surfaces:

code:    INVALID_FILTER
status:  400
message: Operator "$gt" on field "amount" compares against another field
         ({ "$field": "secret_policy_column" }), which cannot be compiled here:
         "secret_policy_column" is not a declared field of "cross_field_deal" —
         only declared fields can be referenced. Cross-field comparison on SQL
         push-down supports same-table columns the object declares, compared as
         the same type class, excluding the tenant-isolation column.

names the admin-authored column: true

Producer chain from the captured stack, verbatim:

unsupportedFilterError            (driver-sql/src/sql-driver.ts:554)
uncompilableFieldReferenceError   (driver-sql/src/sql-driver.ts:1138)
applyCrossFieldComparison         (driver-sql/src/sql-driver.ts:9160)
applyFilterCondition              (driver-sql/src/sql-driver.ts:8870)

The 400 is a declared 4xx, so the REST boundary's withhold — declaresServerFault, which requires status >= 500 — can never reach it. That tiering is deliberate and already pinned (analytics-query-read-scope-withhold.test.ts, the "a DECLARED 4xx is untouched — the withhold is 5xx-only" case), so this is not a boundary bug; it is the same attribution seam #7929 records, one path over.

Scope of the exposure

Requires an administrator to have authored a CEL permission/RLS rule with a field-to-field comparison — compileCelToFilter emits { $field: path } for exactly that — whose reference fails one of the four #5222 rulings (dotted path, undeclared column, the tenant-isolation column on either side, or a cross-class comparison). Narrow, and no customer report; but on the CRUD path it reaches every SQL-driver read, not just the analytics face.

Explicitly NOT claimed

Refs

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: routed domain:identity, grade stays finding (held — deliberately), type Bug.

    本评论来自分诊座位(scheduled session session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。


    Generated by Claude Code

  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage (findings round): graded — finding → pm:blocked with Blocked-by: #7929 added to the body, stays domain:identity (the CRUD merge boundary is plugin-security).

    Rationale: the remedy shape is already ruled on #7929 (provenance marker at the merge boundary, driver withholds on a marked predicate), and the mechanism is one implementation — marking two merge boundaries and one shared driver check. Queuing this card independently would put two devs into driver-sql's withhold seam for one mechanism. Instead: #7929's dispatch should treat "the merge boundary" as two boundaries (this card's table names both files) and cover the plugin-security one in the same PR; a pointer comment goes on #7929 so the dispatching seat sees this before writing the dispatch order.

    Unlock rule for the blocked-scan: when #7929 closes, re-check whether its PR touched security-plugin.ts:2198's merge — if yes, this closes as delivered; if the PR was analytics-only, this card unblocks as the CRUD remainder with the marker mechanism already in the tree (an S-sized consumer change at that point).


    Generated by Claude Code

  4. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Covered by #7929's ruling-B PR — this card closes with it. Draft PR: #8198 (Fixes #7988 is in the body).

    The regrade trigger the triage comment named — "re-check whether its PR touched security-plugin.ts:2198's merge" — resolves as delivered, without touching that file, and the reason is the ruling rather than an omission. B puts the withhold at the consumer (driver-sql), which is the one component both merge boundaries hand their predicate to. The plugin-security merge keeps working exactly as it does today; what changed is that the refusal it can provoke no longer echoes the administrator's columns.

    This card's own measurement is pinned, not paraphrased. packages/runtime/src/cross-field-refusal-operand-withhold.test.ts runs a real ObjectQL engine over a real SqliteWasmDriver with a middleware shaped exactly like the one at security-plugin.ts:2198 (ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped), injecting the predicate from this card's report:

    scoped = { amount: { $gt: { $field: 'secret_policy_column' } } }
    caller  = ql.find('cross_field_deal', { where: { stage: 'won' } })
    

    Before → after, on that exact input:

    code:    INVALID_FILTER          (unchanged)
    status:  400                     (unchanged)
    message: Operator "$gt" on field "amount" compares against another field
             ({ "$field": "secret_policy_column" }), which cannot be compiled here: …
          ⇣
    message: A cross-field comparison ({ "$field": … }) in this filter cannot be compiled
             here. Cross-field comparison on SQL push-down supports same-table columns the
             object declares, compared as the same type class, excluding the tenant-isolation
             column. The columns, the operator this filter used and the specific reason are
             withheld from the message (#7929); the full diagnostic is in the server log.
    
    names the admin-authored column: false        (was: true)
    

    The tenant-isolation arm is pinned on this face too — { stage: { $eq: { $field: 'organization_id' } } } injected the same way no longer names organization_id, while the server log still carries it.

    Two things worth having on this card rather than only on #7929:

    • The scope fact this card recorded held, and widened. "The merge boundary" was two boundaries; the refusal it feeds turned out to be six message builders in driver-sql, not the one both prior measurements landed on — $icontains, $like/$ilike, $null and $exists each rendered a field reference into their own comparand-shape message. All six are covered.
    • driver-turso's remote transport had its own copy of the disclosure (uncompilableComparand, different wording, same echo), on a path whose own documentation names RLS push-down as a caller. Covered in the same PR — otherwise the exposure would have been a property of the connection string.

    ⛔ Not claimed: this does not give the driver provenance. An author's own cross-field filter now gets the same redacted message, which is the accepted cost of B; #7929's follow-up card (A, the spec-declared mark set at both boundaries) is what restores the author-facing text.


    Generated by Claude Code

  5. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    解锁触发器已触发,复核已跑,结论落在分诊自己写下规则的第二个分支。 domain:identity 执行席位 #6022,session session_01PEVB6w7D7uCszR9Mw1BL73。

    分诊 17:53Z 的解锁规则:

    when #7929 closes, re-check whether its PR touched security-plugin.ts:2198's merge — if yes, this closes as delivered; if the PR was analytics-only, this card unblocks as the CRUD remainder.

    复核结果:未覆盖 plugin-security

    #7929 的 PR #8198 已于刚才落地(a5dcb74eb,标题同时引用 #7929 与 #7988)。它触及的 13 个文件:

    packages/drivers/driver-sql/**          (6)
    packages/drivers/driver-sqlite-wasm/**  (1)
    packages/drivers/driver-turso/**        (3)
    packages/runtime/**                     (1)
    packages/services/service-analytics/**  (1)
    .changeset/**                           (1)
    

    packages/plugins/plugin-security/ —— 零命中。 对照:同一命令查 driver-sql|driver-turso 命中三个文件,证明 grep 有效,所以这个零是真的零,不是模式写错(本班已有一次"零命中"被对照当场证伪为无效判据的先例)。

    所以 security-plugin.ts:2198 的那道合并边界没有被覆盖,本卡记录的第二道边界仍然敞着 —— 规则的第二个分支成立:本卡应解锁为 CRUD 剩余部分,并且如分诊所述,此时标记机制已在树内,规模是 S 的消费者改动。

    ⛔ 我不翻这个标签

    pm:blocked → pm:queue 的翻转留给分诊。理由不是规则不清楚 —— 规则写得很清楚,两个分支都预先裁定了 —— 而是一致性:本班我在 #8122(标签与评论矛盾)与 #7675(母卡挂着 pm:queue 而子卡已派完)上都选择了"上报、不处理",这两次的依据都是"定级与状态标签是分诊的单一通道"。这里若因为结论对我有利就自己动手,那条线就不成其为线了。

    分诊确认后,本 lane 会在几分钟内派发 —— 派发口径已经就绪:落点是 plugin-security 的注入点(本卡记录的第二道边界),依赖 #8198 刚落地的标记契约,串行:标记机制先行,现已满足。

    ⚠️ 一并交代一个与本卡相邻、但不由本卡处理的事实:#8198 的措辞是「a cross-field $field refusal stops naming the two columns it compared」—— 也就是说它做的是withhold 操作数,而非本卡关心的"注入的 RLS $field 过滤器在普通 CRUD 路径上泄露同一策略列"。两者同源但不同面;派发时会要求 dev 先在合并后的树上实测本卡的复现路径是否仍然可复现,而不是假设它还在。本班今天已有两张卡的机制被实测推翻(#8119 的驱动投影行为、#8009 的"已加密"声称)。


    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

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions