Skip to content

fix: restrict manifest reads and event org spoofing#1811

Merged
riderx merged 4 commits into
mainfrom
fix/advisory-ghsa-4rqj-manifest-rls
Mar 19, 2026
Merged

fix: restrict manifest reads and event org spoofing#1811
riderx merged 4 commits into
mainfrom
fix/advisory-ghsa-4rqj-manifest-rls

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Mar 17, 2026

Summary (AI generated)

  • restrict public.manifest reads to org/app-scoped callers instead of USING (true)
  • add pgTAP coverage proving users can only read manifest rows for accessible app versions
  • harden /private/events so callers cannot broadcast notifyConsole events for a different org than the referenced app
  • add regression coverage for cross-org notifyConsole spoof attempts

Motivation (AI generated)

Two triaged security advisories were low-risk to fix together. public.manifest exposed cross-organization bundle metadata through an over-permissive RLS policy, and /private/events still allowed org targeting that was broader than the app being referenced in the event payload.

Business Impact (AI generated)

This removes a cross-tenant metadata leak and prevents fake cross-org operational events in the dashboard. That reduces customer trust risk and closes two externally reported security findings with a small, reviewable patch.

Test Plan (AI generated)

  • bun scripts/supabase-worktree.ts test db
  • bun run supabase:with-env -- bunx vitest run tests/events.test.ts
  • bun test:backend
  • bun test:all currently fails in pre-existing local CLI upload tests (tests/cli*.test.ts) with local fetch/upload errors unrelated to this patch

Generated with AI

Summary by CodeRabbit

  • Bug Fixes

    • Tightened event tracking and broadcast authorization so events are dispatched using a resolved tracking user and validated permissions, preventing cross-org spoofing.
  • New Features

    • Manifest reads now respect app-access rules via a new row-level security policy, limiting visibility to authorized apps.
  • Tests

    • Added and updated tests covering manifest visibility across organizations and broadcast/notifyConsole permission scenarios.

@riderx riderx added the codex label Mar 17, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 28987134-d39c-4ebb-a211-a49f9ffa659d

📥 Commits

Reviewing files that changed from the base of the PR and between 8747937 and fd362af.

📒 Files selected for processing (1)
  • supabase/functions/_backend/private/events.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • supabase/functions/_backend/private/events.ts

📝 Walkthrough

Walkthrough

Server-side resolution of a tracking user with RBAC checks was added to event handling; manifest SELECT access is restricted via a new per-row RLS policy; tests updated to cover manifest visibility and event broadcast permission scenarios.

Changes

Cohort / File(s) Summary
Event handling & permission checks
supabase/functions/_backend/private/events.ts
Added resolveTrackingUserId to compute effective trackingUserId from body.user_id, body.tags['app-id'], and authenticated identity with checkPermission and a Supabase apps lookup; materializes trackedBody/trackingUserId/appId and updates notifyConsole, logging, PostHog, onboarding lookup, and event dispatch to use the resolved context.
Manifest RLS migration
supabase/migrations/20260317040310_restrict_manifest_read_access.sql
Replaced the previous manifest read policy with a per-row SELECT policy that permits reads only when the requester has read rights for the associated app_version via an EXISTS subquery invoking check_min_rights.
Manifest policy tests & scenarios
supabase/tests/26_test_rls_policies.sql, supabase/tests/27_test_rls_scenarios.sql
Adjusted policy description, added app_versions and manifest seed rows, and added tests verifying manifest visibility for accessible apps and denial for apps in other organizations (plan expanded from 7→9).
Event tests & utilities
tests/events.test.ts, tests/test-utils.ts
Added exported constants (ORG_ID, NON_OWNER_ORG_ID), updated test payloads/headers/tags, changed cases to use notifyConsole, and updated assertions for cross‑org rejection (403) and caller‑org acceptance (200).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant EventsFn as Events Function
  participant Auth as Auth/Permission
  participant DB as Supabase DB
  participant Notifier as Notifier (notifyConsole/PostHog)

  Client->>EventsFn: POST /events (body, headers)
  EventsFn->>Auth: resolveTrackingUserId(requester, body.user_id, body.tags['app-id'])
  Auth->>DB: lookup app (by app-id) and verify owner_org / check_min_rights
  DB-->>Auth: permission result
  Auth-->>EventsFn: trackingUserId or deny
  alt allowed
    EventsFn->>DB: fetch org/app context (trackedBody.user_id, appId)
    EventsFn->>Notifier: dispatch notifyConsole/posthog with trackedBody & trackingUserId
    Notifier-->>EventsFn: ack
    EventsFn-->>Client: 200 OK
  else denied
    EventsFn-->>Client: 403 no_permission
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through logs and chased each id,
Checked apps and owners so no spoof could hide.
Manifests guarded, broadcasts set aright,
I nibble stale bugs and vanish in moonlight. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the core changes (manifest RLS, events endpoint hardening) and includes test plan results, but lacks explicit manual testing steps and checklist completion as required by the template. Complete the PR checklist and clarify the manual testing steps performed to verify the org spoofing prevention works as intended.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main security fixes: restricting manifest reads via RLS and preventing event org spoofing in the events endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/advisory-ghsa-4rqj-manifest-rls
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/events.test.ts (1)

17-17: Consider using it.concurrent() for parallelism.

The coding guidelines recommend using it.concurrent() instead of it() to maximize test parallelism. This applies to both the existing and new tests in this file.

♻️ Example refactor for one test
-  it('track event with apikey', async () => {
+  it.concurrent('track event with apikey', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/events.test.ts` at line 17, Replace the synchronous test declarations
with concurrent ones to enable parallel test execution: change the test
invocation for "track event with apikey" from it(...) to it.concurrent(...), and
apply the same replacement for other tests in this file (tests/events.test.ts)
so functions like the "track event with apikey" test and any other it(...)
declarations become it.concurrent(...).
supabase/functions/_backend/private/events.ts (1)

93-99: Variable shadowing: appId is redeclared inside the block.

Line 94 declares const appId which shadows the appId declared at line 59. While functionally correct (the inner scope uses trackedBody.tags['app-id'] which should match), this can cause confusion.

♻️ Consider renaming to avoid shadowing
-  if (trackedBody.user_id && trackedBody.tags && typeof trackedBody.tags['app-id'] === 'string' && trackedBody.event === 'onboarding-step-done') {
-    const appId = trackedBody.tags['app-id']
+  if (trackedBody.user_id && trackedBody.tags && typeof trackedBody.tags['app-id'] === 'string' && trackedBody.event === 'onboarding-step-done') {
+    const onboardingAppId = trackedBody.tags['app-id']
     await backgroundTask(c, Promise.all([
       supabase
         .from('orgs')
         .select('*')
         .eq('id', trackedBody.user_id)
         .single(),
       supabase
         .from('apps')
         .select('*')
-        .eq('app_id', appId)
+        .eq('app_id', onboardingAppId)
         .single(),
     ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/events.ts` around lines 93 - 99, There is
a variable shadowing issue: the inner const appId declared in the if block (the
block that checks trackedBody.tags['app-id'] and event ===
'onboarding-step-done' and then calls backgroundTask with supabase.from('orgs'))
reuses the same name as an earlier appId (declared around line 59). Rename the
inner variable to something unique (e.g., trackedAppId or appIdFromTags) and
update all uses inside that if block (including the backgroundTask call and any
subsequent references within the block) so you don’t shadow the outer appId.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@supabase/functions/_backend/private/events.ts`:
- Around line 93-99: There is a variable shadowing issue: the inner const appId
declared in the if block (the block that checks trackedBody.tags['app-id'] and
event === 'onboarding-step-done' and then calls backgroundTask with
supabase.from('orgs')) reuses the same name as an earlier appId (declared around
line 59). Rename the inner variable to something unique (e.g., trackedAppId or
appIdFromTags) and update all uses inside that if block (including the
backgroundTask call and any subsequent references within the block) so you don’t
shadow the outer appId.

In `@tests/events.test.ts`:
- Line 17: Replace the synchronous test declarations with concurrent ones to
enable parallel test execution: change the test invocation for "track event with
apikey" from it(...) to it.concurrent(...), and apply the same replacement for
other tests in this file (tests/events.test.ts) so functions like the "track
event with apikey" test and any other it(...) declarations become
it.concurrent(...).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 583ff23c-9e45-4662-a587-5d1c5b230cf1

📥 Commits

Reviewing files that changed from the base of the PR and between 877c35b and 80814e9.

📒 Files selected for processing (5)
  • supabase/functions/_backend/private/events.ts
  • supabase/migrations/20260317040310_restrict_manifest_read_access.sql
  • supabase/tests/26_test_rls_policies.sql
  • supabase/tests/27_test_rls_scenarios.sql
  • tests/events.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
supabase/functions/_backend/private/events.ts (1)

64-65: Consider the empty string case for trackingUserId.

When c.get('auth')?.userId is undefined, authUserId defaults to an empty string (line 24). If no requestedUserId is provided, resolveTrackingUserId returns this empty string, making the if (trackingUserId) check falsy and skipping the broadcast.

This is likely the intended defensive behavior for edge cases where auth context is incomplete, but consider logging this scenario for observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/events.ts` around lines 64 - 65, The
check around trackedBody.notifyConsole and trackingUserId currently treats an
empty-string authUserId (from c.get('auth')?.userId) as falsy and silently skips
broadcasting; update the logic in resolveTrackingUserId / the caller where
trackingUserId is used to detect the empty-string case and emit a debug/warn log
(including context like authUserId and requestedUserId) before skipping the
broadcast so the skipped-broadcast scenario is observable; reference
resolveTrackingUserId, authUserId (c.get('auth')?.userId),
trackedBody.notifyConsole, and trackingUserId to locate where to add the
conditional log and return early.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@supabase/functions/_backend/private/events.ts`:
- Around line 64-65: The check around trackedBody.notifyConsole and
trackingUserId currently treats an empty-string authUserId (from
c.get('auth')?.userId) as falsy and silently skips broadcasting; update the
logic in resolveTrackingUserId / the caller where trackingUserId is used to
detect the empty-string case and emit a debug/warn log (including context like
authUserId and requestedUserId) before skipping the broadcast so the
skipped-broadcast scenario is observable; reference resolveTrackingUserId,
authUserId (c.get('auth')?.userId), trackedBody.notifyConsole, and
trackingUserId to locate where to add the conditional log and return early.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 306814c4-67e1-4bfd-92ed-f6d68672d8aa

📥 Commits

Reviewing files that changed from the base of the PR and between 80814e9 and c848154.

📒 Files selected for processing (2)
  • supabase/functions/_backend/private/events.ts
  • tests/events.test.ts

@riderx
Copy link
Copy Markdown
Member Author

riderx commented Mar 19, 2026

Merged origin/main and resolved the conflicts in supabase/functions/_backend/private/events.ts by keeping your resolveTrackingUserId logic while adding the new canAccessRequestedOrg guard for notifyConsole. tests/events.test.ts was rewritten to capture both the notifyConsole happy path + cross-org rejection plus the jwt/api-key cases so the merged branch still exercises the same auth expectations.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
supabase/functions/_backend/private/events.ts (1)

67-79: Consider removing redundant permission check.

The new resolveTrackingUserId (line 78) now covers all permission validation including the notifyConsole case, making the canAccessRequestedOrg check (lines 73-74) redundant. Both paths perform similar permission queries.

However, keeping both provides defense-in-depth and maintains backward compatibility during the transition.

♻️ Optional: Remove redundant check if confident in new validation
 app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
   const body = await parseBody<TrackOptions & { notifyConsole?: boolean }>(c)
-  const requestedOrgId = body.notifyConsole && typeof body.user_id === 'string' && body.user_id.length > 0
-    ? body.user_id
-    : undefined
-
-  if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId)))
-    return c.json({ error: 'Forbidden' }, 403)
-
   const requestedUserId = typeof body.user_id === 'string' ? body.user_id : undefined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/events.ts` around lines 67 - 79, Remove
the redundant org-access check by deleting the conditional that calls
canAccessRequestedOrg and returns 403; rely on resolveTrackingUserId to perform
the consolidated permission validation for the notifyConsole/requestedOrgId
path. Ensure the existing logic that computes requestedOrgId, requestedUserId,
appId, calls resolveTrackingUserId(c, requestedUserId, appId), and sets
trackedBody remains unchanged so permission handling and user_id substitution
flow through resolveTrackingUserId only.
tests/events.test.ts (1)

53-76: Consider using it.concurrent() for parallel execution.

Per coding guidelines, test files run in parallel and should use it.concurrent() instead of it() to maximize parallelism. This test modifies no shared state and can safely run concurrently.

The test logic itself is correct and properly validates the cross-org spoofing protection.

♻️ Suggested refactor
-  it('rejects notifyConsole broadcasts for foreign organizations', async () => {
+  it.concurrent('rejects notifyConsole broadcasts for foreign organizations', async () => {

As per coding guidelines: "ALL TEST FILES RUN IN PARALLEL; use it.concurrent() instead of it() to maximize parallelism"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/events.test.ts` around lines 53 - 76, Replace the synchronous test
declaration with a concurrent one: change the call to the test titled 'rejects
notifyConsole broadcasts for foreign organizations' from it(...) to
it.concurrent(...). Specifically update the test function surrounding the
fetch/asserts in tests/events.test.ts (the block asserting 403 and 'Forbidden')
so it uses it.concurrent to allow parallel execution; no other logic or
shared-state handling needs modification.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@supabase/functions/_backend/private/events.ts`:
- Around line 67-79: Remove the redundant org-access check by deleting the
conditional that calls canAccessRequestedOrg and returns 403; rely on
resolveTrackingUserId to perform the consolidated permission validation for the
notifyConsole/requestedOrgId path. Ensure the existing logic that computes
requestedOrgId, requestedUserId, appId, calls resolveTrackingUserId(c,
requestedUserId, appId), and sets trackedBody remains unchanged so permission
handling and user_id substitution flow through resolveTrackingUserId only.

In `@tests/events.test.ts`:
- Around line 53-76: Replace the synchronous test declaration with a concurrent
one: change the call to the test titled 'rejects notifyConsole broadcasts for
foreign organizations' from it(...) to it.concurrent(...). Specifically update
the test function surrounding the fetch/asserts in tests/events.test.ts (the
block asserting 403 and 'Forbidden') so it uses it.concurrent to allow parallel
execution; no other logic or shared-state handling needs modification.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fee098ba-b4c1-4f81-9fb3-36e45bb7484f

📥 Commits

Reviewing files that changed from the base of the PR and between c848154 and 8747937.

📒 Files selected for processing (2)
  • supabase/functions/_backend/private/events.ts
  • tests/events.test.ts

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 874793756e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +49 to +50
if (await checkPermission(c, 'org.read', { orgId: requestedUserId })) {
return requestedUserId
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce API-key org limits when validating user_id

resolveTrackingUserId() now uses checkPermission(c, 'org.read', { orgId: requestedUserId }) when the payload has a user_id but no tags['app-id']. For legacy API keys, checkPermission() routes through rbac_check_permission_direct(), whose org-scoped fallback drops p_apikey and only checks the owning user's membership, so a key limited to org A can still post events as org B if that user belongs to both orgs. This means the spoofing fix is incomplete for non-app-id event payloads; using the existing org-aware API-key check here is necessary to actually block cross-org attribution.

Useful? React with 👍 / 👎.

@sonarqubecloud
Copy link
Copy Markdown

@riderx
Copy link
Copy Markdown
Member Author

riderx commented Mar 19, 2026

@riderx We just need you. Thank you for the pull request. We just need you to reply or fix your pull request according to the AI comments. When the AI reviewer is done and the build passes in the CI, we will merge your pull request.

@riderx riderx merged commit 17a543e into main Mar 19, 2026
15 checks passed
@riderx riderx deleted the fix/advisory-ghsa-4rqj-manifest-rls branch March 19, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant