fix: revoke PUBLIC execute on all SECURITY DEFINER functions - #173
Merged
Merged
Conversation
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
force-pushed
the
fix/ad-rpc-public-grants
branch
from
July 31, 2026 07:46
e752e72 to
8966ac3
Compare
vu1nz Security Review0 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
That does not do what it reads as. Postgres grants
EXECUTEtoPUBLICby default on every new function, andanon/authenticatedinherit it. Revoking their explicit grants leaves thePUBLICgrant untouched:Every one of these is
SECURITY DEFINER, so they were reachable atPOST /rest/v1/rpc/<name>by anyone holding the publishable anon key.Impact
consume_credit(p_owner uuid, p_count integer)is the sharpest.p_countis signed —lib/promote/reconcilePromo.tspasses a negative count to refund. So any caller could run:and mint themselves credits. Those land in
credits_balancewithout touchingpromo_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(alsoSECURITY 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_payoutssolvency 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 publicis what actually removes it. 15 functions locked to service-role only:consume_credit,credit_purchase_complete,consume_article_generation,refund_article_entitlement,consume_alert_serp_budgetad_charge_click,ad_apply_deposit_bonus,ad_payout_solvency_guardbump_autoblog_integration,lx_find_internal_links,get_public_audit,get_public_findingshandle_new_user,create_default_org_for_profile,rls_auto_enableEvery caller was traced before revoking.
worker/index.tsbuilds its client fromSUPABASE_SERVICE_ROLE_KEY, which coversgenerateArticle→consume/refund_article_*,generateGuestPostandprocessDuePromoteLists/processBrowserPost→consume_credit, andarticleGen→lx_find_internal_links. Route handlers and server actions all useserviceClient().get_public_auditis called withsvcfrom a server component, never the browser, andget_public_findingshas 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
EXECUTEon anything the policy calls — revoking would not harden RLS, it would break it outright. All 48 havepolroles = '{0}'(PUBLIC), soanonis 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:
RLS smoke test as an authenticated user still resolves correctly:
Found by the Supabase security advisor (
anon_security_definer_function_executable).🤖 Generated with Claude Code