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 comparisoncompileCelToFilter 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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions