Skip to content

Harden upsert_version_meta RPC against abuse#1760

Merged
riderx merged 18 commits into
mainfrom
riderx/fix-version-meta-rpc
Mar 16, 2026
Merged

Harden upsert_version_meta RPC against abuse#1760
riderx merged 18 commits into
mainfrom
riderx/fix-version-meta-rpc

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Mar 8, 2026

Summary (AI generated)

  • Restrict public.upsert_version_meta RPC execution to service_role only.
  • Replaced the function body to verify app ownership and version ownership before insert.
  • Added caller authorization for non-service callers via get_identity_org_appid and check_min_rights, while preserving existing dedupe behavior.

Test plan (AI generated)

  • Run bun lint:backend.
  • Manually exercise unauthenticated requests to POST /rest/v1/rpc/upsert_version_meta to confirm inserts are rejected.
  • Verify allowed internal service-role writes continue to record valid metadata.

Screenshots (AI generated)

  • Not applicable (backend-only migration change).

Checklist (AI generated)

  • My code follows the code style of this project and passes bun run lint:backend && bun run lint.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce my tests

Summary by CodeRabbit

  • Tests

    • Added end-to-end tests validating authorization, input validation, idempotent upsert behavior, and cleanup for version metadata operations.
  • Chores

    • Hardened version-metadata upsert: stricter auth checks, input validation, duplicate protection, and reduced execution privileges.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 8, 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: 2ecbd767-b6b8-4054-a66e-a44f25d6d977

📥 Commits

Reviewing files that changed from the base of the PR and between eba305a and d225cac.

📒 Files selected for processing (1)
  • tests/upsert-version-meta-rpc.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/upsert-version-meta-rpc.test.ts

📝 Walkthrough

Walkthrough

Adds a SECURITY DEFINER PostgreSQL function public.upsert_version_meta(p_app_id, p_version_id, p_size) with input validation, authorization checks (restricted execution to service role), idempotent insert behavior into public.version_meta, and new DB-level and end-to-end tests validating authz and idempotency.

Changes

Cohort / File(s) Summary
Migration: RPC Implementation
supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql
Creates/replaces upsert_version_meta(p_app_id, p_version_id, p_size) as SECURITY DEFINER; enforces p_size > 0, resolves app owner/org, validates app/version existence, enforces caller identity/rights (exempts service_role/postgres), performs idempotent insert into public.version_meta, handles unique_violation by returning false, and sets owner/execute privileges.
DB-level Tests
supabase/tests/28_test_new_migration_functions.sql
Adds service_role authentication in test setup and captures demo app version id via set_config / current_setting(...)::bigint to exercise upsert_version_meta in migration tests (initial insert, duplicate insert, negative-size cases).
E2E RPC Tests
tests/upsert-version-meta-rpc.test.ts
New end-to-end tests: verifies unauthenticated calls are rejected, returns false for non-existent app or version, and confirms idempotent behavior (first call inserts and returns true; identical second call returns false and does not duplicate rows).

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant RPC as upsert_version_meta()
  participant Auth as Auth/RoleCheck
  participant DB as DB (app, app_versions, version_meta)

  Client->>RPC: CALL(p_app_id, p_version_id, p_size)
  RPC->>Auth: verify caller identity & service_role/postgres exemption
  Auth-->>RPC: allow / deny
  alt allowed
    RPC->>DB: SELECT app, app_versions, owner_org
    DB-->>RPC: app/version found / not found
    alt app/version found
      RPC->>DB: check existing version_meta row (app_id, version_id, sign(size))
      DB-->>RPC: row exists / not exists
      alt not exists
        RPC->>DB: INSERT INTO version_meta (app_id, version_id, size)
        DB-->>RPC: inserted / unique_violation => error
        alt inserted
          RPC-->>Client: returns true
        else unique_violation
          RPC-->>Client: returns false
        end
      else exists
        RPC-->>Client: returns false
      end
    else not found
      RPC-->>Client: returns false
    end
  else denied
    RPC-->>Client: raise permission error
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop through schemas, nibbling bugs away,
I plant one row and promise not to stray,
Service-role cloaks me, guards the burrowed gate,
No duplicate hops — idempotent fate,
A carrot-cheer for safe inserts today 🥕🐇

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

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.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main security hardening objective of the pull request.
Description check ✅ Passed The description covers the main changes and test plan, but the checklist is incomplete with all items unchecked and only partial manual testing steps provided.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/fix-version-meta-rpc
📝 Coding Plan
  • Generate coding plan for human review comments

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

Tip

CodeRabbit can approve the review once all CodeRabbit's comments are resolved.

Enable the reviews.request_changes_workflow setting to automatically approve the review once all CodeRabbit's comments are resolved.

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: 740755153b

ℹ️ 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".

FROM public.app_versions
WHERE public.app_versions.app_id = p_app_id
AND public.app_versions.id = p_version_id
AND public.app_versions.deleted = false
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 Allow metadata upserts for soft-deleted versions

The new public.app_versions.deleted = false predicate prevents the delete path from recording compensating negative metadata entries: deleteIt in supabase/functions/_backend/triggers/on_version_update.ts calls createStatsMeta(..., -data.size) after a soft delete, when the target row is already marked deleted. With this filter, upsert_version_meta returns false and skips the insert, so version_meta no longer captures bundle removals and storage/billing aggregates that depend on it remain overstated.

Useful? React with 👍 / 👎.

@riderx riderx force-pushed the riderx/fix-version-meta-rpc branch from 9398519 to e9bf46a Compare March 8, 2026 23:52
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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/upsert-version-meta-rpc.test.ts (1)

55-117: Use it.concurrent() for this test file.

These cases use it(...); the repo test guideline expects it.concurrent(...) in *.test.ts files to maximize in-file parallelism.

🔧 Suggested fix
-  it('must reject unauthenticated (anon) execution', async () => {
+  it.concurrent('must reject unauthenticated (anon) execution', async () => {
@@
-  it('returns false for unknown app ids', async () => {
+  it.concurrent('returns false for unknown app ids', async () => {
@@
-  it('returns false for unknown version ids', async () => {
+  it.concurrent('returns false for unknown version ids', async () => {
@@
-  it('upserts only once per app/version/size sign', async () => {
+  it.concurrent('upserts only once per app/version/size sign', async () => {

As per coding guidelines: “ALL TEST FILES RUN IN PARALLEL; use it.concurrent() instead of it() to run tests in parallel within the same file.”

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

In `@tests/upsert-version-meta-rpc.test.ts` around lines 55 - 117, Replace all
synchronous Jest test declarations in this file with concurrent variants: change
each it('...') block to it.concurrent('...') so tests run in-file in parallel;
specifically update the four test cases that call anonSupabase.rpc and
serviceRoleSupabase.rpc (the tests named "must reject unauthenticated (anon)
execution", "returns false for unknown app ids", "returns false for unknown
version ids", and "upserts only once per app/version/size sign") to use
it.concurrent while keeping their bodies and assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/20260312202239_harden_upsert_version_meta_authz.sql`:
- Around line 33-37: The code checks app ownership into v_owner_org for p_app_id
but later inserts metadata for p_version_id without verifying that p_version_id
belongs to the same app; update the function to first SELECT owner_org (or
app_id) FROM public.versions WHERE version_id = p_version_id INTO a local (e.g.,
v_version_owner_org or v_version_app_id) and compare it to v_owner_org (or
p_app_id), and if they don’t match return FALSE (or raise a controlled notice
and return FALSE) before executing the INSERT that currently appears around
lines 83-88; ensure you reference p_app_id, p_version_id, v_owner_org and the
INSERT into version metadata when adding this guard.

In `@tests/upsert-version-meta-rpc.test.ts`:
- Line 18: The tests currently hardcode VERSION_ID and insert into app_versions
without capturing the created ID, causing flaky assertions; change the setup to
capture the inserted row's id (e.g., use the insert call that returns the
inserted id or query the created row) and assign it to a local variable (replace
VERSION_ID usage) so subsequent RPC calls and assertions use that actual id;
update each occurrence that uses the hardcoded VERSION_ID (the insert block and
later assertions/RPC calls around the references in the test file) to reference
the captured id variable instead.

---

Nitpick comments:
In `@tests/upsert-version-meta-rpc.test.ts`:
- Around line 55-117: Replace all synchronous Jest test declarations in this
file with concurrent variants: change each it('...') block to
it.concurrent('...') so tests run in-file in parallel; specifically update the
four test cases that call anonSupabase.rpc and serviceRoleSupabase.rpc (the
tests named "must reject unauthenticated (anon) execution", "returns false for
unknown app ids", "returns false for unknown version ids", and "upserts only
once per app/version/size sign") to use it.concurrent while keeping their bodies
and assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 168b1f42-915e-4df7-9671-fcb5fdc47617

📥 Commits

Reviewing files that changed from the base of the PR and between d49e530 and 51db7e9.

📒 Files selected for processing (2)
  • supabase/migrations/20260312202239_harden_upsert_version_meta_authz.sql
  • tests/upsert-version-meta-rpc.test.ts

Comment thread tests/upsert-version-meta-rpc.test.ts Outdated
@riderx riderx force-pushed the riderx/fix-version-meta-rpc branch from d66f989 to 8d52d3b Compare March 13, 2026 11:09
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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql`:
- Around line 30-31: The check using "p_size = 0" misses NULL and lets the
sign-based dedup logic skip both branches, leaving v_existing_count NULL and
causing an unintended insert; change the guard to explicitly reject NULLs (e.g.
check "p_size IS NULL OR p_size = 0") before the sign-based branches so the
function returns FALSE for NULL/zero sizes, and apply the same NULL-aware change
to the other identical size-check later in the function that controls the
sign-based dedup path (ensure checks reference the parameter p_size and preserve
the existing control flow around v_existing_count).
- Around line 2-7: The REVOKE statement targeting the function
upsert_version_meta currently uses the identifiers "public" and public which
refer to a regular role named public; replace both occurrences with the
PostgreSQL pseudo-role PUBLIC (unquoted, uppercase) so the statement reads
REVOKE ... FROM PUBLIC, "anon", "authenticated" to ensure it revokes from the
built-in all-roles pseudo-role rather than a literal role named public.

In `@supabase/tests/28_test_new_migration_functions.sql`:
- Around line 6-8: Add explicit assertions that the anon and authenticated roles
do NOT have execute rights on the migration function to prevent grant
regressions: after invoking tests.authenticate_as_service_role() and any
service-role checks, add has_function_privilege checks for both 'anon' and
'authenticated' against the target function (e.g.,
has_function_privilege('anon', 'public.upsert_version_meta()') = false and
has_function_privilege('authenticated', 'public.upsert_version_meta()') = false)
and fail the test if either returns true so the test covers revoked-grants
scenarios as well.

In `@tests/upsert-version-meta-rpc.test.ts`:
- Around line 86-95: The test currently only checks a non-existent p_version_id
(999999999) and should also assert the RPC rejects a real version id that
belongs to a different app; update or add a case that calls
serviceRoleSupabase.rpc('upsert_version_meta', { p_app_id: APP_ID, p_version_id:
<real_version_id_for_other_app>, p_size: 123456 }) where
<real_version_id_for_other_app> is obtained from a fixture or by inserting a
version tied to another app (e.g., create/query a version record for
OTHER_APP_ID), and assert expect(error).toBeNull() and expect(data).toBe(false)
so ownership checks are covered.
- Around line 63-125: Don't convert the "upserts only once per app/version/size
sign" test to it.concurrent(): this test mutates shared state in the
version_meta table using the shared APP_ID and versionId so it must remain
sequential or be fully isolated. Either keep the test as a normal it(...) and do
not change it to it.concurrent(), or refactor the test to create and use its own
dedicated app/version identifiers (e.g., generate new values with randomUUID and
create the corresponding app/version seed) and then call
serviceRoleSupabase.rpc('upsert_version_meta', ...) and verify via
serviceRoleSupabase.from('version_meta').select(...) against those dedicated IDs
so the test can safely run concurrently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dfeda966-57c5-44cc-8ded-a6c5c970689d

📥 Commits

Reviewing files that changed from the base of the PR and between 51db7e9 and 92095fd.

📒 Files selected for processing (3)
  • supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql
  • supabase/tests/28_test_new_migration_functions.sql
  • tests/upsert-version-meta-rpc.test.ts

Comment on lines +2 to +7
REVOKE ALL ON FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint)
FROM
"public",
public,
"anon",
"authenticated";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In PostgreSQL GRANT/REVOKE syntax, does FROM "public"refer to the specialPUBLICpseudo-role, or to a separately named rolepublic?

💡 Result:

In GRANT/REVOKE, the special grantee PUBLIC (unquoted) refers to PostgreSQL’s built-in “pseudo-role” that means every role. (postgresql.org)

But "public" is a delimited identifier, so it is treated as an ordinary role name (case-sensitive) — i.e., it means a separately named role literally called public, not the PUBLIC pseudo-role. (postgresql.org)

Citations:


🏁 Script executed:

cd /tmp && find . -name "20260313130044_harden_upsert_version_meta_authz.sql" 2>/dev/null || echo "File not in /tmp"
# Try to find the file in common locations
fd "20260313130044_harden_upsert_version_meta_authz.sql" 2>/dev/null

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

# Search for the migration file
fd "20260313130044" --type f

Repository: Cap-go/capgo

Length of output: 127


🏁 Script executed:

# List supabase migrations to find the file
ls -la supabase/migrations/ 2>/dev/null | head -20

Repository: Cap-go/capgo

Length of output: 1695


🏁 Script executed:

cat -n supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql

Repository: Cap-go/capgo

Length of output: 3577


Replace "public" and public with the PUBLIC pseudo-role in the REVOKE statement.

In PostgreSQL GRANT/REVOKE syntax, PUBLIC (unquoted, uppercase) is the built-in pseudo-role meaning "all roles". The identifiers "public" and public refer to a regular role literally named public. Unless your cluster defines such a role, this migration will fail before the function can be hardened.

Proposed fix
 REVOKE ALL ON FUNCTION "public"."upsert_version_meta"("p_app_id" character varying, "p_version_id" bigint, "p_size" bigint)
 FROM
-  "public",
-  public,
+  PUBLIC,
   "anon",
   "authenticated";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql`
around lines 2 - 7, The REVOKE statement targeting the function
upsert_version_meta currently uses the identifiers "public" and public which
refer to a regular role named public; replace both occurrences with the
PostgreSQL pseudo-role PUBLIC (unquoted, uppercase) so the statement reads
REVOKE ... FROM PUBLIC, "anon", "authenticated" to ensure it revokes from the
built-in all-roles pseudo-role rather than a literal role named public.

Comment on lines +30 to +31
IF p_size = 0 THEN
RETURN FALSE;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject NULL sizes before the sign-based dedup logic.

p_size = 0 does not catch NULL. In that case neither sign branch runs, v_existing_count stays null, and the function falls through to the insert path instead of returning FALSE.

🛡️ Proposed fix
-  IF p_size = 0 THEN
+  IF p_size IS NULL OR p_size = 0 THEN
     RETURN FALSE;
   END IF;

Also applies to: 77-101

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

In `@supabase/migrations/20260313130044_harden_upsert_version_meta_authz.sql`
around lines 30 - 31, The check using "p_size = 0" misses NULL and lets the
sign-based dedup logic skip both branches, leaving v_existing_count NULL and
causing an unintended insert; change the guard to explicitly reject NULLs (e.g.
check "p_size IS NULL OR p_size = 0") before the sign-based branches so the
function returns FALSE for NULL/zero sizes, and apply the same NULL-aware change
to the other identical size-check later in the function that controls the
sign-based dedup path (ensure checks reference the parameter p_size and preserve
the existing control flow around v_existing_count).

Comment on lines 6 to +8
-- Test upsert_version_meta function
SELECT tests.authenticate_as_service_role();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Assert the revoked grants directly in this migration test.

This setup only exercises the service-role happy path. Please add has_function_privilege(...)=false checks for both anon and authenticated; otherwise a grant regression can slip through while these tests still pass.

🧪 Suggested test additions
-SELECT plan(30);
+SELECT plan(32);

 -- Test upsert_version_meta function
+SELECT
+    is(
+        has_function_privilege(
+            'anon'::name,
+            'public.upsert_version_meta(character varying, bigint, bigint)'::regprocedure,
+            'EXECUTE'
+        ),
+        false,
+        'anon role has no execute privilege on upsert_version_meta'
+    );
+
+SELECT
+    is(
+        has_function_privilege(
+            'authenticated'::name,
+            'public.upsert_version_meta(character varying, bigint, bigint)'::regprocedure,
+            'EXECUTE'
+        ),
+        false,
+        'authenticated role has no execute privilege on upsert_version_meta'
+    );
+
 SELECT tests.authenticate_as_service_role();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
-- Test upsert_version_meta function
SELECT tests.authenticate_as_service_role();
-- Test upsert_version_meta function
SELECT
is(
has_function_privilege(
'anon'::name,
'public.upsert_version_meta(character varying, bigint, bigint)'::regprocedure,
'EXECUTE'
),
false,
'anon role has no execute privilege on upsert_version_meta'
);
SELECT
is(
has_function_privilege(
'authenticated'::name,
'public.upsert_version_meta(character varying, bigint, bigint)'::regprocedure,
'EXECUTE'
),
false,
'authenticated role has no execute privilege on upsert_version_meta'
);
SELECT tests.authenticate_as_service_role();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/tests/28_test_new_migration_functions.sql` around lines 6 - 8, Add
explicit assertions that the anon and authenticated roles do NOT have execute
rights on the migration function to prevent grant regressions: after invoking
tests.authenticate_as_service_role() and any service-role checks, add
has_function_privilege checks for both 'anon' and 'authenticated' against the
target function (e.g., has_function_privilege('anon',
'public.upsert_version_meta()') = false and
has_function_privilege('authenticated', 'public.upsert_version_meta()') = false)
and fail the test if either returns true so the test covers revoked-grants
scenarios as well.

Comment thread tests/upsert-version-meta-rpc.test.ts
Comment on lines +86 to +95
it('returns false for unknown version ids', async () => {
const { data, error } = await serviceRoleSupabase.rpc('upsert_version_meta', {
p_app_id: APP_ID,
p_version_id: 999999999,
p_size: 123456,
})

expect(error).toBeNull()
expect(data).toBe(false)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a regression case for a real version_id from another app.

999999999 only proves the version is missing. It does not protect the security fix that rejects a valid version_id belonging to a different app, so a future ownership-query regression could still pass this suite.

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

In `@tests/upsert-version-meta-rpc.test.ts` around lines 86 - 95, The test
currently only checks a non-existent p_version_id (999999999) and should also
assert the RPC rejects a real version id that belongs to a different app; update
or add a case that calls serviceRoleSupabase.rpc('upsert_version_meta', {
p_app_id: APP_ID, p_version_id: <real_version_id_for_other_app>, p_size: 123456
}) where <real_version_id_for_other_app> is obtained from a fixture or by
inserting a version tied to another app (e.g., create/query a version record for
OTHER_APP_ID), and assert expect(error).toBeNull() and expect(data).toBe(false)
so ownership checks are covered.

@sonarqubecloud
Copy link
Copy Markdown

@riderx riderx merged commit 89dbec0 into main Mar 16, 2026
15 checks passed
@riderx riderx deleted the riderx/fix-version-meta-rpc branch March 16, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant