Rebounder Tech Blog

Written by the people who actually run these systems in production.

SECURITY DEFINER Functions Are PUBLIC EXECUTE

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgreSQL grants PUBLIC EXECUTE by default on CREATE FUNCTION, so a SECURITY DEFINER write function created without a revoke is directly callable by the anon role through Supabase's PostgREST layer.

The short version

PostgreSQL grants EXECUTE to the PUBLIC role by default the moment CREATE FUNCTION runs. SECURITY DEFINER is the modifier that makes a function run with the privileges of the role that owns it rather than the role that called it. Create a write function with that modifier and leave it without an explicit REVOKE, and it becomes directly callable from the anon (unauthenticated) or authenticated role through Supabase’s PostgREST layer.

  • Even with no GRANT written anywhere, EXECUTE for PUBLIC is there from the start
  • RLS policies on the table are irrelevant, because a SECURITY DEFINER function runs as the owner
  • Closing it means writing REVOKE ALL ... FROM PUBLIC explicitly and re-GRANTing only to the roles that need it

What it looks like

We created a function to push records for asynchronous delivery into a table called sync_outbox. Its definition pins search_path. search_path is the schema search path deciding which schema an unqualified object name resolves from inside a function, and pinning it to public is standard practice to stop a caller rewriting it so that a fake same-named object outside public resolves first.

create table if not exists public.sync_outbox (
  id            uuid primary key default gen_random_uuid(),
  kind          text not null,
  ref_id        uuid,
  payload       jsonb not null default '{}'::jsonb,
  status        text not null default 'pending'
                check (status in ('pending','sent','failed','dead')),
  attempts      int  not null default 0,
  -- (omitted)
);

alter table public.sync_outbox enable row level security;
create policy sync_outbox_staff_read on public.sync_outbox
  for select using (public.is_staff());

create or replace function public.enqueue_sync_outbox(p_kind text, p_ref_id uuid, p_payload jsonb)
returns uuid language sql security definer set search_path = public as $$
  insert into public.sync_outbox (kind, ref_id, payload)
  values (p_kind, p_ref_id, coalesce(p_payload, '{}'::jsonb))
  returning id;
$$;

The table’s RLS says only “allow SELECT for the staff role”. There is no policy anywhere permitting INSERT for anon. The function was written on the premise that only the app’s backend (a cron worker) calls it. Pinning search_path was handled, but that guards a different kind of attack and does nothing for the problem here.

But this function is SECURITY DEFINER, and EXECUTE for PUBLIC is granted by default the instant CREATE FUNCTION runs. Supabase’s PostgREST automatically exposes functions in the public schema at /rest/v1/rpc/<name>. It connects as anon or authenticated depending on the JWT the caller sends, and both roles inherit PUBLIC’s privileges, so any function not explicitly revoked can be called directly by anyone.

That is what the review flagged. The table’s RLS correctly narrowed reads to staff, and the function’s execute privilege had never been narrowed at all.

Why

Three facts stack up.

SECURITY DEFINER runs with the function owner’s privileges

Not the calling role’s privileges but those of the role that owns the function. That much is as specified.

CREATE FUNCTION grants EXECUTE to PUBLIC by default

Even with no GRANT ever written, the function is callable by anyone the moment it is created. This is the opposite of tables, whose GRANT is closed by default (nobody can touch them without explicit permission): functions are open by default.

③ PostgREST automatically exposes public schema functions as RPCs

anon / authenticated are the connection roles Supabase provides, and they inherit PUBLIC’s privileges as they are. With not a single GRANT on the table, one forgotten REVOKE on a SECURITY DEFINER function leaves a write path open that bypasses RLS entirely.

table GRANT        → closed by default → nobody touches it without explicit permission
function EXECUTE   → open by default   → anyone calls it unless explicitly revoked

This asymmetry is the cause of the oversight. Look only at the table and conclude “RLS protects this”, and the hole on the function side goes unseen.

Fixing it

Strip the default PUBLIC privilege and re-allow only the roles that need it.

-- Close the SECURITY DEFINER write function to direct calls from anon/authenticated
-- (via PostgREST). Strip the default PUBLIC EXECUTE, allow only the backend (service_role).
revoke all on function public.enqueue_sync_outbox(text, uuid, jsonb) from public;
grant execute on function public.enqueue_sync_outbox(text, uuid, jsonb) to service_role;

REVOKE ALL ... FROM PUBLIC cuts off every inheritance source including anon / authenticated, and GRANT EXECUTE returns it to service_role alone, which actually needs to call it. The function definition itself is unchanged. All that happened is an open door was closed, with no effect on the intended calling path from the backend.

Checking it

Joining pg_proc and pg_roles gives you a function’s owner and its execute privileges.

select p.proname, r.rolname as owner, p.proacl
from pg_proc p
join pg_roles r on r.oid = p.proowner
where p.proname = 'enqueue_sync_outbox';

Look for EXECUTE for PUBLIC remaining in proacl (notation like =X/<owner>). Unless this check is run on every new SECURITY DEFINER write function, the same path opens once per function whose revoke was forgotten.

Making it not recur

This fix went in as an addition of revoke / grant to the existing migration file rather than recreating the function. Unless writing REVOKE ALL ... FROM PUBLIC paired with every SECURITY DEFINER function becomes the rule, the next write function added opens the same path.

  • Write REVOKE / GRANT directly beneath the creation SQL for any SECURITY DEFINER write function, so that adding only one of them is structurally impossible
  • Include the function’s EXECUTE privilege in the same review item as the table’s RLS review (on the premise of the asymmetry: tables closed by default, functions open by default)
  • Verify whether something is anonymously writable by actually querying pg_proc.proacl, not by reading the wording of a policy

However carefully the RLS policies are written, it means nothing if another door — the function’s execute privilege — stands open. Another case shaped as an overlooked SECURITY DEFINER is in The function owner and the migration role. That one is about an intended bypass ceasing to work; this one about an unintended bypass working. Both start from the same property: SECURITY DEFINER behaviour is decided by who created the function, not by who called it.

よくある質問

Q1Why can a SECURITY DEFINER function be called by anon at all?

PostgreSQL grants EXECUTE to the PUBLIC role by default the moment CREATE FUNCTION runs. Supabase's PostgREST connects as anon or authenticated depending on the request's JWT, and both roles inherit PUBLIC's privileges, so any function not explicitly revoked is callable from anon via /rest/v1/rpc/.

Q2Why is it executable when no GRANT was written?

GRANT is a statement for adding privileges, not for restricting them. PostgreSQL functions get EXECUTE for PUBLIC by default at CREATE time, so restricting requires writing REVOKE explicitly. The cause is not a missing GRANT — it is a missing REVOKE.

Q3Doesn't RLS on the table prevent it?

It does not. A SECURITY DEFINER function runs with the owner's privileges, not the caller's. The sync_outbox table's RLS allowed only SELECT for the staff role, but enqueue_sync_outbox is an INSERT running as the owner, so it writes regardless of the table's policy.

Q4What is the fix?

REVOKE ALL ON FUNCTION <name>(<arg types>) FROM PUBLIC; to strip the default execute privilege, then GRANT EXECUTE ON FUNCTION <name>(<arg types>) TO service_role; to re-allow only the roles that actually need to call it.

Q5Are other SECURITY DEFINER functions at the same risk?

Each needs checking. Joining pg_proc and pg_roles and reading proacl surfaces the functions still holding EXECUTE for PUBLIC. Unless every SECURITY DEFINER write function is created paired with a REVOKE, the same hole multiplies with the number of functions.

確認した環境

  • PostgreSQL (Supabase) / @supabase/supabase-js ^2.106.2
  • 2026-06-10, appended to migration 0025 in response to a Phase 4 review finding (High)

この記事の根拠

  • SQLファイル 8〜50行目コミット 3018abf

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。