Skip to content

fix(security): enforce referenced-object RLS/FLS on $expand (#2850) - #2961

Merged
os-zhuang merged 2 commits into
mainfrom
claude/expand-rls-fls-bypass-ogj4wf
Jul 15, 2026
Merged

fix(security): enforce referenced-object RLS/FLS on $expand (#2850)#2961
os-zhuang merged 2 commits into
mainfrom
claude/expand-rls-fls-bypass-ogj4wf

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#2850.

Problem

ObjectQL.expandRelatedRecords (packages/objectql/src/engine.ts) resolved lookup / master_detail / user references by calling the driver directly (driver.find), never re-entering the security middleware for the referenced object. Only tenantId was threaded, so the referenced object's RLS, FLS, and CRUD gate were skipped — cross-tenant was the only surviving boundary. The middleware's post-pass FLS masking covers only the top-level object; field-masker does not recurse into expanded records.

Impact: any API-key/session caller who could read a base row could ?expand=<fk> and receive the full referenced record — including rows the referenced object's owner-only RLS would hide and fields FLS would mask. Reachable via GET /data/:object/:id?expand=… and POST /data/:object/query (both funnel through engine.find/findOne).

Fix

Route the expand batch through the engine's own this.find for the referenced object instead of the raw driver, so the security middleware applies that object's RLS + FLS to the collected id $in [...] batch. This re-enters the middleware once per level (no N+1) and preserves the existing batched load and depth guard (nested expand still recurses manually, so MAX_EXPAND_DEPTH is unaffected).

The sub-read is tagged with a server-set __expandRead context marker (executionContext is server-built, never client-supplied — consistent with the existing internal __readScope/__writeScope markers). The security middleware uses it to implement the surgical policy agreed on the issue:

  • PUBLIC referenced object → waive only the object-level CRUD / requiredPermissions gate (the row is already broadly readable via the '*' wildcard grant, so applying that gate to an expansion would over-block common status/owner lookups without adding protection). RLS injection + FLS masking still run.
  • PRIVATE referenced object → keep the full gate (RLS + FLS + CRUD). Expansion may reveal only rows the caller could have read directly; a private object the caller can't read gracefully retains the bare FK id.

No per-field escape hatch is introduced (option A on the issue).

Changes

  • packages/objectql/src/engine.tsexpandRelatedRecords resolves via this.find(referenceObject, …, { context: { __expandRead: true } }).
  • packages/plugins/plugin-security/src/security-plugin.ts — compute expandSkipCrud (find + __expandRead + public), and AND it into the requiredPermissions (step 1.5) and CRUD (step 2) gates only.

Tests

  • engine.test.ts — new: the expand sub-read routes through the middleware for the referenced object tagged __expandRead; three existing expand assertions updated to reflect the secured path (the referenced driver.find now carries the query options/context instead of undefined).
  • security-plugin.test.ts — new: __expandRead waives the CRUD gate for a public referenced object, does not waive it for a private one, and still injects RLS on the expand sub-read.

All @objectstack/objectql (853) and @objectstack/plugin-security (172) tests pass; both packages typecheck; the real-$expandrest/export-integration test passes.


Generated by Claude Code

`expandRelatedRecords` resolved lookup/master_detail/user references by
calling the driver directly, so the REFERENCED object's row- and
field-level security never ran — only tenant isolation survived. Any
API/session caller who could read a base row could `?expand=` a foreign
key and receive the full referenced record, including RLS-hidden rows and
FLS-masked fields.
Route the expand batch through the engine's own `find` so the security
middleware applies the referenced object's RLS + FLS to the `id $in [...]`
batch (one query, no N+1). The read is tagged with a server-set
`__expandRead` context marker; the security middleware waives ONLY the
object-level CRUD / requiredPermissions gate for PUBLIC referenced objects
(already broadly readable — avoids over-blocking common status/owner
lookups), while PRIVATE referenced objects keep the full gate. RLS
injection and FLS masking run for both. Covers the list and single-record
REST/protocol surfaces (find + findOne).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vh6cpe9MV7bMiXvooeWLH9
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specCanceledCanceledJul 15, 2026 1:28pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-security.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/objectql, @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql, @objectstack/plugin-security)
  • content/docs/protocol/objectos/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql, @objectstack/plugin-security)
  • content/docs/releases/v9.mdx(via @objectstack/objectql)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vh6cpe9MV7bMiXvooeWLH9
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: $expand bypasses RLS/FLS on the referenced object (data leak on lookups)

2 participants

@os-zhuang@zhuangjianguo