Skip to content

driver-mongodb: translateFilter passes a { $field } cross-field reference through as a literal document, so record.s != record.t matches every row (an RLS using read widens) #19949

Description

@objectstack-fleet

Filed by the domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) from the out-of-scope findings of the #19886 stage-2c dev (report 5806886759, class a). ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim.

The defect

compileCelToFilter lowers a field-to-field comparison such as record.s != record.t to { s: { $ne: { $field: 't' } } }: a declared cross-field reference (FieldReferenceSchema). packages/drivers/driver-mongodb/src/mongodb-filter.ts has no $field handling. The seat grepped packages/drivers/driver-mongodb/src on origin/main aeaaa44292 and found no $field arm in the filter translation, so translateFilter emits the reference verbatim, as a literal sub-document.

Measured by the #19886 stage-2c dev at 10efa63fb1, through translateFilter plus mingo 7.2.4 as the proxy (live mongod NOT MEASURED), over the rows {s:'a', t:'a'} and {s:'a', t:'b'}:

  • $ne against the reference selects BOTH rows, including the one where s equals t;
  • $eq selects none.

Why it matters

A row-level using that compares two fields with != is a read restriction. On driver-mongodb it widens to every row. The SQL family compiles $field to a column-to-column comparison (#5222).

Seam: spec:FieldReferenceSchema { $field } → runtime: driver-mongodb translateFilter (no lowering).

Dedupe words: mongodb field reference $field not lowered · rls field-to-field ne mongodb every row · translateFilter $field

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p2 · security · bug · domain:engine · pm:queue —— 维护者裁定 B:#5499 冻结下的安全例外,只做最小的"拒绝"修法

    Path: packages/drivers/driver-mongodb/src/mongodb-filter.ts → translateFilter / translateFieldOperators

    Triage: lands in the MongoDB driver's filter translation ⇒ domain:engine, security, bug, priority:p2, pm:queue; rationale: a { $field } cross-field reference is passed through as a literal sub-document, so an RLS using such as record.s != record.t matches every row on MongoDB — a read restriction that silently widens; the driver is under the #5499 investment freeze (no MongoDB customer), and the maintainer ruled a narrow security exception: refuse the reference (fail closed), do not implement it.

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T04:53Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论)。

    前提复核(origin/main 2dc5889013)

    • packages/drivers/driver-mongodb/src/mongodb-filter.ts 里 $field 出现 0 次。对照组正常:同一文件里 translateFilter 在 :568;driver-sql 有 [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 的字段对字段一致性用例,$field 在 cross-field-conformance-cases.ts 里能搜到。
    • ⇒ 卡面读数成立:compileCelToFilter 把 record.s != record.t 降成 { s: { $ne: { $field: 't' } } },MongoDB 驱动原样下发,$ne 选中所有行,$eq 一行也不选(dev 用 mingo 7.2.4 代测;真实 mongod 未测)。

    维护者裁定(本席在对话里把两条规则的冲突报给了维护者)

    要做的(⛔ 只做这些)

    1. translateFilter 遇到任何运算符的值是 { $field: … } 时直接拒绝:抛一个清楚的错误,点名"MongoDB 驱动不支持字段对字段比较($field)",⛔ 不再当作普通值下发。
    2. 确认这个错误在行级权限的读取路径上表现为拒绝读取(fail closed),而不是放行,也不是被吞掉之后退回到无过滤。
    3. 测试:用卡面的两行 {s:'a', t:'a'}、{s:'a', t:'b'},$ne 和 $eq 配 { $field: 't' } 都必须被拒绝;对照:普通字面值的 $ne / $eq 行为不变。

    ⛔ 不做的

    为什么是 p2

    读权限被放宽是安全缺陷,但只影响用 MongoDB 驱动的部署,而目前没有这样的客户(#5499)。修法很小,改动面只有一处。


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Deferred (serial) behind PR #19947 — stays pm:queue, not dispatched this round

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-24T12:59Z. Thread read through 5807932277 (triage, ruling B).

    Why not now. Draft PR #19947 (#19886 stage 2c, domain:spec seat 5, cross-lane declared in its claim 5806396564) adds a shape-walk refusal to packages/drivers/driver-mongodb/src/mongodb-filter.ts (+43: $ne with an array comparand refused with INVALID_FILTER / 400, at any depth, $not / $nor included). This card's fix lands in the same walk of the same file. Same region ⇒ hard serial across rounds; ⛔ not folded into a PR another seat owns.

    Known pits, recorded for the taker:

    Wakes when: PR #19947 merges or closes. This seat's hot-file serial queue (seat post #6367) holds the entry.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 22
    Session: session_01Bvd69VPa6puiNzzPUroDBx
    Account: os-sales (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-19949-mongodb-field-ref-refused
    Worktree: objectstack-issue-19949
    Domain: domain:engine
    Seat: domain:engine#1
    File surface:

    Stop on breach and explain in the report. ⛔ No $field lowering (no $expr column-to-column), which ruling B excludes. ⛔ No other driver-mongodb defect: those stay closed under #5499. ⛔ Not packages/spec, not compileCelToFilter, not the RLS compiler.
    Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable; security, so the default tier)
    Clause-②: no (narrowing)
    Thread-read: 5814595680
    Serial constraints cleared: at 2026-09-27T05:44Z, the region predecessor PR #19947 (#19886 stage 2c, spec seat 5) has merged, and PR #19882 has merged. A census of the 9 open PRs finds none under packages/drivers/driver-mongodb/src. The seat runs batch:3 (the maintainer's ruling); in flight are PR #20174's review and this card.

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 19949,
    "status": "done",
    "branch": "claude/issue-19949-mongodb-field-ref-refused",
    "pr": "#20182 (draft, opened by objectstack-fleet[bot] through relay run 36299771940; the body read back byte-identical, 10678 bytes; PR assignee set to the card assignee os-sales through relay run 36299812160, read back)",
    "head_sha": "00612182e9",
    "session": "session_01Bvd69VPa6puiNzzPUroDBx. This is the dispatching seat's session, because this run is its subagent (mode:subagent).",
    "premise_still_valid": true,
    "summary": "The premise holds at origin/main 3bd28e2. mongodb-filter.ts had no $field arm, and translateFilter emitted { s: { $ne: { $field: "t" } } } verbatim. Through mingo 7.2.4 that selected both card rows, and the full RLS read path (compileCelToFilter, RLSCompiler, SecurityPlugin, ObjectQL, MongoDBDriver) returned both rows from find, 2 from count, and the s == t row from findOne. One gate was added to classifyFilterKey, on the shape walk that PR #19947 extended, and placed before the other comparand gates. It refuses a { $field }-shaped object (any $field key, so a malformed reference too) in three positions: the whole constraint (the bare form, or a list holding one), any $-operator comparand (addDays included), and any member of a list comparand. The refusal is INVALID_FILTER / 400, and its message names "field-to-field comparison" and withholds the fields, the operator and the position. At head the RLS read is refused (find, findOne and count throw 400, and the collection is asked 0 times), and every literal comparand translates to the same document as before. There is no $expr lowering, per ruling B. The pins are in a new test file of their own, and a minor BREAKING changeset declares Clause-②: no (narrowing).",
    "tests": "All at HEAD 0061218 unless stated. (1) pnpm --filter @objectstack/driver-mongodb test: "Test Files 28 passed | 5 skipped (33); Tests 630 passed | 147 skipped (777)". The 5 skipped files are the opt-in live-mongod suites (OS_TEST_MONGODB_MEMORY_SERVER_ENABLED); a live mongod is NOT MEASURED because the binary cannot be fetched here. (2) vitest run src/mongodb-field-reference-refusal.test.ts: 39 passed. (3) pnpm --filter @objectstack/driver-mongodb typecheck: exit 0, "check:test-typecheck: OK ... 0 file(s) / 0 error(s)"; tsc -p tsconfig.test.json --listFiles counts 33 test files, the new one included. (4) Ablation, run after the fix was committed (a53249d), through scripts/ablation-replace.mjs in WRAP mode under the verify lock. It deleted the gate line: anchor x1 to x0, blob 7b33578be43b to 343b8c5aa323 ("ok mutation landed"). The result was "Tests 33 failed | 6 passed (39)": every refusal case red, and the 6 controls green. Restore: blob after restore 7b33578be43b equals the blob at HEAD, and git diff HEAD is empty ("ok restored"). No dist step was needed, because the suite imports ./mongodb-filter.js from src by relative path, and the ObjectQL dist was not mutated. (5) The CJS entry dist/index.js, rebuilt at HEAD, loads and refuses { s: [ref] } with INVALID_FILTER/400. (6) Narrowed eslint on the 2 changed .ts files: eslint --no-inline-config --format json reports 2 files, 0 errors, 0 warnings. Population: --print-config gives both files a rule set. Invariance: no parserOptions.project or projectService on either file, and the config itself states it never enables type-aware linting, so the diff cannot move another file's verdict. The full pnpm lint is left to CI. (7) The H1/H2 readings came from a scratch script (scratchpad measure.mjs) over the built dists, at base 3bd28e2 and at head 0061218.",
    "mcp_calls": "0",
    "api_writes": "3 relay writes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]: (1) pr_create, which is POST /repos/objectstack-ai/objectstack/pulls (draft), run 36299771940; (2) label-write --assign os-sales, which is POST /repos//issues/20182/assignees, run 36299812160; (3) this os-dev-report comment, which is POST /repos//issues/19949/comments. git push is not a REST write. Reads, not counted: GET issues/19949, issues/19949/comments, pulls?head=..., pulls/20182, issues/comments/5806886759.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed. An observation, recorded in the PR Acceptance notes: translateFilter lowers a non-boolean $exists comparand to { $eq: null }, because the arm tests value === true. Measured on translateFilter at head: "yes", 1, null and "false" all give $eq: null. Not measured through a public door, and not compared with the other drivers, so it is not a class a/b/c card. driver-mongodb stays under the #5499 freeze."
    ],
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack, taken with no paths at HEAD 0061218, derived 59 commands (changed set: 3 paths vs merge base 3bd28e2). All 59 were run with exit codes recorded. dispatch-gates --ran: "59 derived famil(ies) accounted for — 57 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". 57 exited 0. NOT MEASURED: pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET, it needs the whole workspace built; narrowed to this package's CJS entry loading, see tests (5)); pnpm check:type-check-debt (exit 3, PREREQUISITE NOT MET, 12 ledgered-closure packages are unbuilt; this package has no DEBT entry and its own test-layer typecheck is green). Also run, all exit 0: node scripts/check-issue-citations.mjs --base 3bd28e2 (5 citations resolve), and the 5 roster gates that dispatch-gates flagged as sitting under this diff's directories (check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:object-def-param-keys, check:tenant-chokepoint). New against the PM list from 3bd28e2: check-adr-0087-registration (base and self-test), check-empty-changeset (base and self-test), check:engine-double-contract, check:objectql-double-limit, check:objectui-changeset, check:pm-changeset-deadline-census, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher, and release-rehearsal-clone --self-test. All were run. H4: check-changeset-no-major --event (a simulated pull_request payload carrying this PR's exact body) reports "LEVEL AXIS: this PR declares clause-② no (narrowing), and no package ... is graded patch", so minor is accepted. check-adr-0087-registration accepts "not-required (no-migration-prescription)". Neither should differ: there is no conversion target on this driver, so a registry entry would carry no prescription, and adding one would also edit packages/spec, which the claim excludes. CI convergence is not awaited.",
    "line_budget": "n/a: no skills/** path in the diff.",
    "deviations": [
    "This session was given a copy of os-dev.md that is older than .claude/agents/os-dev.md at 3bd28e2. Where the two differ, I followed the repo copy: the PR assignee is set through label-write --issue PR --assign LOGIN, and findings carry a reach: field. AGENTS.md was first read from the shared checkout (at 2c1011b, which is stale). The worktree copy was then diffed against it, and its current PR-assignee clause was the one followed.",
    "The gate covers more than the literal "operator value" wording. It refuses a reference as the whole constraint (the bare form and a list holding a reference, i.e. implicit equality) and as a list member ($in/$nin/$between/arrays). H1 named those positions, and the ruling says it must no longer be sent down as an ordinary value.",
    "The gate runs first among the comparand gates. Four shapes that were already refused now get the cross-field wording, with the same INVALID_FILTER/400 envelope: bare { s: ref } (before: "Unsupported filter operator $field", naming the field), $icontains: ref, $null: ref, and $ne: [ref]. At base, this package's tests had 0 hits for $field, so no pin changed. The $ne array refusal from PR #19947 is unchanged for literal arrays.",
    "The engine-level pin in the suite uses an ObjectQL middleware that AND-composes the compiled policy, standing in for SecurityPlugin, because plugin-security is not a dependency of driver-mongodb. The real SecurityPlugin path was measured outside the suite, in a scratch script."
    ],
    "files_changed": [
    "packages/drivers/driver-mongodb/src/mongodb-filter.ts (+87: gate in classifyFilterKey; isFieldReferenceShape, carriesFieldReference, holdsFieldReference, fieldReferenceUnsupportedError)",
    "packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts (+303, new)",
    ".changeset/19949-mongodb-field-reference-refused.md (+36, new; driver-mongodb minor, BREAKING, Clause-②: no (narrowing), adr-0087 not-required (no-migration-prescription))"
    ],
    "rows": {
    "proxy": "mingo 7.2.4 (driver-memory's dependency) over rows r1 {s:a,t:a} and r2 {s:a,t:b}, ref = { $field: "t" }. A live mongod is NOT MEASURED.",
    "base": "3bd28e2b2e",
    "head": "00612182e9",
    "cells": [
    "s $ne ref: base selects r1,r2 | head 400",
    "s $eq ref: base none | head 400",
    "$gt / $gte / $lt / $lte ref: base none | head 400",
    "$in [ref]: base none | head 400",
    "$nin [ref]: base selects r1,r2 | head 400",
    "$between [ref,z] / [a,ref]: base none | head 400",
    "$and [s $ne ref]: base r1,r2 | head 400",
    "$or [s $ne ref, s=zz]: base r1,r2 | head 400",
    "$or [{}, s $ne ref]: base translated {} (r1,r2) | head 400 (the gate is on the walk)",
    "$not {s $eq ref} (lowered to $nor): base r1,r2 | head 400",
    "$not {s $ne ref}: base none | head 400",
    "s $ne {$field:t, addDays:1}: base r1,r2 | head 400",
    "s $eq {$field:t, addDays:{$field:n}}: base none | head 400",
    "s $ne {$field:42} (malformed): base r1,r2 | head 400",
    "$notContains ref: base r1,r2 | head 400",
    "$contains / $startsWith / $endsWith ref: base none | head 400",
    "$exists ref: base lowered to $eq null, none | head 400",
    "bare {s: ref}: base 400 (unknown operator $field, names the field) | head 400 (cross-field wording)",
    "$icontains ref / $null ref / $ne [ref]: base 400 (their own gates) | head 400 (cross-field wording)",
    "$nor at node level: base 400 | head 400, unchanged",
    "CONTROL s $ne a: none | none, same document",
    "CONTROL s $eq a: r1,r2 | r1,r2, same document",
    "CONTROL t $ne b: r1 | r1",
    "CONTROL t $eq b: r2 | r2",
    "CONTROL t $in [b] / $nin [b] / $between [a,a] / $not {t $eq b}: r2 / r1 / r1 / r1 | identical"
    ]
    },
    "rls_path": "compileCelToFilter("record.s != record.t") and ("s != t") both return { s: { $ne: { $field: "t" } } }. RLSCompiler.compileFilter with declared {id,s,t} keeps it. Full path: SecurityPlugin with a rowLevelSecurity policy (operation all), ObjectQL, and the real MongoDBDriver over a stub Db whose collection selects with mingo. The caller is a MEMBER holding the set. Base, using "s != t": find returns [r1,r2], count 2, findOne({id:r1}) returns r1, and the server received {"s":{"$ne":{"$field":"t"}}}. The read restriction was lost. Head: find, findOne and count each throw INVALID_FILTER/400, and the qa_pair collection is asked 0 times, so the read is refused: not allowed through, not swallowed, never unfiltered. Using "s == t" (base: 0 rows) is also refused at head. Controls, identical at base and head: using t == "a" gives [r1] / count 1, using t != "a" gives [r2], using s == "a" gives [r1,r2]. The engine's find path logs at warn and rethrows. Every driver door reads where through translateFilter: find, findOne, count, updateMany, deleteMany, aggregate (buildAggregationPipeline) and explain. The suite pins six doors plus the engine rethrow.",
    "collateral": "Every literal comparand translates to the same document (the controls above and in the suite). The $ne array refusal from PR #19947 is unchanged for literal arrays: mongodb-ne-array-comparand-refusal.test.ts is green, and the new file carries a control. The #19757 equality-slot array pin still holds: { tags: ["a"] } and $eq ["a"] pass through. mongodb-equality-array-comparand-refusal.test.ts is green, and the gate fires only on a reference member. The whole driver-mongodb suite is green (630 passed, 147 skipped in the opt-in live-mongod files). check:driver-conformance, check:filter-alias-parity and check:where-matcher exit 0. No workspace consumer's tests use $field with this driver (grepped in runtime, service-datasource and cli). Exports and types are unchanged. Wording moved for 4 shapes that were already refused (see deviations).",
    "cleanup": "After this post, the worktree node_modules is removed and git worktree remove runs without --force; the outcome is reported in the final hand-off. No dev server, background process or monitor was started."
    }

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20182 at 00612182e9 (#19949)

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T06:45Z. Reviewed on GitHub against references/review-checklist.md, not from the dev's os-dev-report.

    • Shape: the first line is Fixes #19949. The body and changeset declare Clause-②: no (narrowing): BREAKING, @objectstack/driver-mongodb minor, ADR-0087 disposition not-required (no-migration-prescription).
    • Scope: 3 files, +426/−0. Not governed.
    • Contract review of record: PASS at 00612182e9 (5853495299).
      • 34 of 34 { $field } shapes are refused INVALID_FILTER / 400. That covers every operator, list members, the bare form, addDays and malformed references.
      • The 21 literal controls translate to byte-identical documents.
      • On the real RLS chain (compileCelToFilter → RLSCompiler → SecurityPlugin → ObjectQL → MongoDBDriver), a using of s != t returned every row at base. At head, find, findOne, count, aggregate, bulk update and bulk delete all refuse with 0 collection calls.
      • The message withholds field names.
      • The ablation reproduces the dev's count: 33 of 39 red.
      • A live mongod was NOT MEASURED; mingo 7.2.4 is the proxy.
    • CI at this head: 34 runs, 31 success, 3 skipped, 0 failed. All seven required contexts are success. git merge-tree against current main is clean.
    • Acceptance notes (not cards):

    Landing: ready plus auto-merge through the queue now.

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20182, verified on main

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T07:05Z.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions