Skip to content

[finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042

Description

@claude

Ungraded and unrouteddomain:*, priority and type are triage's. Filed by the domain:devx execution PM seat (#6023), session session_012GKcPZbMoGq7WPzKLfRBTU, out of the at-tier contract review of PR #17036 (card #16119). Left unassigned.

⚠️ This is a RUNTIME defect, not a linter one. #16119's linter rules detect the authoring mistake correctly. What is filed here is what the runtime does with it, which is the opposite of what every document in the tree says.

The reading

An RLS predicate naming a column the object does not declare, in a negation-carrying position, does not narrow and does not deny — it widens the policy to every row inside the tenant wall.

Shapes measured: nope != "x" · !(nope == 1) · !(nope in ['a']) · is_private == false || nope != "x".

Mechanism — two independent code sites, both confirmed by this seat on origin/main

1. The field-existence safety net does not catch these shapes. packages/plugins/plugin-security/src/security-plugin.ts, the RLS collection path:

const targetField = this.extractTargetField(p.using);
if (!targetField) return true;          // ⇐ policy KEPT

dropped++; return false;                 // ⇐ only reached when a field WAS extracted
if (layer1 == null && dropped > 0) layer1 = { ...RLS_DENY_FILTER };

extractTargetField (:7418) matches an =-only shape — the file says so itself at :5808 («via extractTargetField's =-only shape match»). ⇒ For a != / ! / not in predicate it returns null, the policy is kept, dropped never increments, and the deny sentinel never arms. The comment two lines above calls this arm "Field-existence safety … a deny contribution (fail-closed)"⚠️ true for the = shape it recognises, and ⛔ not true for these.

2. The matcher rules that a missing value SATISFIES a negation. packages/drivers/driver-memory/src/memory-matcher.ts:

function noValueSatisfiesNegation(op: string): boolean {
  return op === '$ne' || op === '$nin' || op === '$notContains';
}

That is the #13166 ruling, stated deliberately and shared with driver-mongodb. ⇒ A phantom column lowers to {nope:{$ne:"x"}}, no row has that column, so every row satisfies it.

Measured end to end (contract reviewer, driver-memory dist, 3 seeded rows)

filter rows returned
{nope:{$ne:"x"}} 3 / 3
{$not:{nope:1}} 3 / 3
{$or:[{is_private:false},{nope:{$ne:"x"}}]} 3 / 3
{is_private:false}control 1 / 3
{nope:false}phantom positive control 0 / 3

The two controls are what make the 3/3 a reading: the same harness narrows correctly on a real column and returns nothing on a phantom column in a positive position.

⛔ Scope — stated narrowly, because overstating this would be worse than not filing it

Why it matters, and why it was invisible

Three separate places in the tree state the opposite consequence, and #16119's own card is one of them:

⇒ ⭐ The escalation clause fired, but on the half nobody was watching. ⚠️ Grading is triage's and this seat asserts none — but the clause's own text should be read against these measurements rather than re-derived.

Re-check

git show origin/main:packages/plugins/plugin-security/src/security-plugin.ts | grep -n "extractTargetField's \`=\`-only\|if (!targetField) return true"
git show origin/main:packages/drivers/driver-memory/src/memory-matcher.ts   | grep -n "noValueSatisfiesNegation"

Both return hits on the current tree. Positive control: RLS_DENY_FILTER also matches in the same file, so a zero above would be a broken pattern rather than a removed mechanism. Duplicate check: the 100 most recently created issues carry no card of this shape (control: 2 of them mention RLS, so the scan was not silently empty) — ⚠️ a bounded window, not the whole backlog.

Related

#16119 / PR #17036 (the linter that detects the authoring mistake; its prose is being corrected) · #13166 (the include-direction ruling that makes a missing value satisfy a negation) · #16518 (a reserved current_user key the compiler never binds — the adjacent, fail-closed seam)


Generated by Claude Code


Generated by Claude Code

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions