Skip to content

[finding] a caller-supplied rlsMembership bag CAN overwrite a reserved kernel key whenever the kernel value is absent — and it turns a fail-closed deny into a satisfiable filter #17202

Description

@os-trump

Found while implementing #16518 (PR #17200). ⛔ Not fixed there — out of that card's scope, and the repair shape is not settled.

The contract

packages/spec/src/contracts/rls-membership-resolver.ts:87 states it as the reason the list exists:

Context keys a membership resolver may never supply — they are resolved by the kernel and carry authorization meaning an app must not be able to redefine.

and stageRlsMembership (packages/plugins/plugin-security/src/security-plugin.ts) repeats the guarantee in its own doc comment:

Reserved kernel keys can never be overwritten, so an app cannot redefine the org wall's own vocabulary.

The measurement

That guarantee holds only for the keys the kernel actually populated on this request. stageRlsMembership screens RESERVED_RLS_MEMBERSHIP_KEYS out of the resolver's answer, but it seeds the bag from an already-present one and never screens that:

const bag: Record<string, string[]> = { ...(context.rlsMembership ?? {}) };
for (const [key, value] of Object.entries(resolved)) {
  if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) { /* warn, skip */ }

RLSCompiler.compileFilter then merges the bag under the rule "a membership key never clobbers a NAMED field" — which is keyed on userCtx[key] === undefined, i.e. on whether the kernel had a value, not on whether the key is reserved.

Probe on plugin-security at fb05fc119, compiler seam, three cells:

predicate: owner_email == current_user.email
  ctx { rlsMembership: { email: ['victim@e.example'] } }            -> {"owner_email":["victim@e.example"]}   BAG WINS
  ctx { email: 'real@e.example', rlsMembership: { email: [...] } }  -> {"owner_email":"real@e.example"}       kernel wins, correct

predicate: employer_org IN (current_user.accessible_org_ids)
  ctx { rlsMembership: { accessible_org_ids: ['org_victim'] } }     -> {"employer_org":{"$in":["org_victim"]}}  BAG WINS

The middle cell is the control: with the kernel value present the rule works, so the first and third are not an artifact of the rig.

⚠️ The direction is widening. With the kernel value absent, the policy would otherwise have found an unresolved variable and failed closed (deny sentinel, zero rows). The bag instead produces a satisfiable filter, so a reserved-key entry turns a denial into a match on attacker-chosen values.

Evidence boundary

⛔ Measured at the compiler/plugin seam only. I did not establish that a request-path caller can get rlsMembership onto the ExecutionContext — it is a declared ExecutionContext field that core normally populates, and whether any inbound face copies a client-supplied value into it is unmeasured. That reachability question is what decides whether this is a hardening or a live hole, and it should be answered before grading.

Not covered by the neighbours

⛔ Not #17170 / #17172 (both are the ADR-0058 field guard / phantom columns). ⛔ Not #16607 (the missing write-side stageRlsMembership call site). ⛔ Not #16518, which fills accessible_org_ids and, as a side effect, closes the third cell above only for requests where the kernel resolved the set — the general gap over the other five reserved keys is untouched.

Two candidate shapes, ⛔ not chosen here

  1. Screen the seeded bag in stageRlsMembership the same way the resolver's answer is screened — one line, but it only covers the one producer.
  2. Make the compiler's merge refuse RESERVED_RLS_MEMBERSHIP_KEYS by name rather than by "was the field already defined" — covers every producer, at the seam both faces pass through.

Shape 2 looks right, since the compiler is the choke point, but it changes a security boundary and belongs to whoever owns that call.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions