Skip to content

fix: revoke PUBLIC execute on all SECURITY DEFINER functions - #173

Merged
ralyodio merged 2 commits into
masterfrom
fix/ad-rpc-public-grants
Jul 31, 2026
Merged

ralyodio merged 2 commits into
masterfrom
fix/ad-rpc-public-grants

Conversation

@ralyodio

@ralyodio ralyodio commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Security fix. Already applied to production — the hole was live, so I closed it before opening this.

The bug

Migrations across this codebase end their privileged functions with what looks like a lockdown:

revoke execute on function public.consume_credit(...) from anon, authenticated;
grant  execute on function public.consume_credit(...) to service_role;

That does not do what it reads as. Postgres grants EXECUTE to PUBLIC by default on every new function, and anon / authenticated inherit it. Revoking their explicit grants leaves the PUBLIC grant untouched:

acl: =X/postgres | postgres=X/postgres | service_role=X/postgres
     ^^ leading "=X" is PUBLIC
has_function_privilege('anon', 'consume_credit', 'EXECUTE') → true

Every one of these is SECURITY DEFINER, so they were reachable at POST /rest/v1/rpc/<name> by anyone holding the publishable anon key.

Impact

consume_credit(p_owner uuid, p_count integer) is the sharpest. p_count is signed — lib/promote/reconcilePromo.ts passes a negative count to refund. So any caller could run:

POST /rest/v1/rpc/consume_credit  {"p_owner": "<their own id>", "p_count": -1000000}

and mint themselves credits. Those land in credits_balance without touching promo_credits, so the ad network treats them as cash-backed and lets them be withdrawn as USDC — walking straight through the solvency work in #170.

ad_charge_click (also SECURITY DEFINER) let anyone forge valid clicks against any funded campaign — draining its credits and daily budget — while accruing publisher earnings to a slot they own. The self-deal guard permits it because slot owner and campaign owner genuinely differ in that attack.

What limited it: the ad_payouts solvency trigger caps cumulative payouts at lifetime deposits ($12.00) and no payout has ever executed, so nothing was withdrawn. Ledger accruals are unchanged at $5.30, all traceable to the 83 known self-dealt clicks from before #170. No sign of exploitation.

The fix

revoke ... from public is what actually removes it. 15 functions locked to service-role only:

Money consume_credit, credit_purchase_complete, consume_article_generation, refund_article_entitlement, consume_alert_serp_budget
Ad network ad_charge_click, ad_apply_deposit_bonus, ad_payout_solvency_guard
Other RPCs bump_autoblog_integration, lx_find_internal_links, get_public_audit, get_public_findings
Triggers handle_new_user, create_default_org_for_profile, rls_auto_enable

Every caller was traced before revoking. worker/index.ts builds its client from SUPABASE_SERVICE_ROLE_KEY, which covers generateArticle → consume/refund_article_*, generateGuestPost and processDuePromoteLists/processBrowserPost → consume_credit, and articleGen → lx_find_internal_links. Route handlers and server actions all use serviceClient(). get_public_audit is called with svc from a server component, never the browser, and get_public_findings has no caller at all — so the public share page is unaffected.

Deliberately NOT revoked

is_org_member, is_org_owner, is_org_wide_member, is_project_editor, is_project_member, project_owner_id.

These are RLS helpers, called from inside 48 policies across 21 tables. A policy expression is evaluated as the querying role, so that role needs EXECUTE on anything the policy calls — revoking would not harden RLS, it would break it outright. All 48 have polroles = '{0}' (PUBLIC), so anon is in scope too and can't be revoked either.

Residual exposure is small and bounded: each takes explicit uuids and returns a boolean, so a caller who already knows both a user id and an org/project id can probe membership. No data is returned. Closing it means rewriting the policies to inline the checks — a much larger and riskier change than the leak justifies. Flagging rather than doing it.

Verification

Applied to production and confirmed:

all 15:  anon=false  authenticated=false  service_role=true
6 RLS helpers: unchanged (intentional)

RLS smoke test as an authenticated user still resolves correctly:

projects=35  campaigns=34  slots=24  audits=2344

Found by the Supabase security advisor (anon_security_definer_function_executable).

🤖 Generated with Claude Code

Phases 4-6 each ended with "revoke execute ... from anon, authenticated;
grant execute ... to service_role", which reads as service-role-only and
is not. Postgres grants EXECUTE to PUBLIC by default on every new
function, and anon/authenticated inherit it — revoking their explicit
grants leaves the PUBLIC grant untouched. The ACL stayed
"=X/postgres | postgres=X/postgres | service_role=X/postgres", where the
leading =X is PUBLIC, and has_function_privilege('anon', …) was true.

ad_charge_click is SECURITY DEFINER, so anyone holding the publishable
anon key could POST /rest/v1/rpc/ad_charge_click and forge valid clicks
against any funded campaign — draining its credits and daily budget —
while accruing publisher earnings to a slot they own, which the
self-deal guard permits because slot owner and campaign owner differ.

The ad_payouts solvency trigger capped the cash blast radius at lifetime
deposits and no payout has ever executed, so nothing was withdrawn. But
advertiser credits and every delivery metric were forgeable.
ad_apply_deposit_bonus was reachable the same way.

Revoking from PUBLIC is what actually removes it. Both callers use the
service-role client and keep their own explicit grant, so no app access
is lost. Verified in prod: anon and authenticated now false on all three,
service_role still true.

Left alone: ad_account_series / ad_campaign_totals are SECURITY INVOKER
and scope every row to owner_id = auth.uid(), so an anon caller gets an
empty result, and authenticated needs them.

Found by the Supabase security advisor
(anon_security_definer_function_executable).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio force-pushed the fix/ad-rpc-public-grants branch from e752e72 to 8966ac3 Compare July 31, 2026 07:46
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

Sweeps the same PUBLIC-grant bug as the previous commit across the other
15 SECURITY DEFINER functions in the database.

The sharpest is consume_credit(p_owner, p_count): SECURITY DEFINER, and
p_count is signed because reconcilePromo calls it with a negative count
to refund. Any holder of the publishable anon key could POST
consume_credit('<their id>', -1000000) and mint themselves credits — and
those land in credits_balance without touching promo_credits, so the ad
network would treat them as cash-backed and let them be withdrawn as
USDC, walking straight through the solvency work.

Also locked: credit_purchase_complete, consume/refund_article_*,
consume_alert_serp_budget, bump_autoblog_integration,
lx_find_internal_links, get_public_audit/get_public_findings, and the
handle_new_user / create_default_org_for_profile / rls_auto_enable
trigger functions.

Every caller was traced before revoking. worker/index.ts builds its
client from SUPABASE_SERVICE_ROLE_KEY and the route handlers and server
actions all use serviceClient(), so nothing loses access. get_public_audit
is called with svc from a server component (never the browser) and
get_public_findings has no caller at all, so the public share page is
unaffected.

Deliberately NOT revoked: the six is_org_*/is_project_*/project_owner_id
RLS helpers. They are called from inside 48 policies across 21 tables,
and a policy is evaluated as the querying role — revoking would break RLS
rather than harden it. All 48 have polroles = '{0}' (PUBLIC), so anon is
in scope too. The residual exposure is a boolean membership probe that
requires already knowing both uuids and returns no data.

Verified in prod: all 15 now anon=false authenticated=false
service_role=true, and an authenticated smoke test still resolves RLS
correctly (projects=35 campaigns=34 slots=24 audits=2344).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio ralyodio changed the title fix(ads): revoke PUBLIC execute on the SECURITY DEFINER ad RPCs fix: revoke PUBLIC execute on all SECURITY DEFINER functions Jul 31, 2026
@ralyodio
ralyodio merged commit 217feab into master Jul 31, 2026
8 checks passed
@ralyodio
ralyodio deleted the fix/ad-rpc-public-grants branch July 31, 2026 08:07
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