Skip to main content

SEC.PRIV.ROUTINE_DEFINER_MUTABLE_PATH — EXECUTE means "run code as the owner"

  • Category: security
  • Severity: critical
  • Level: 0
  • Confidence: deterministic
  • Downtime class: none — the finding is about a routine's privileges, not about a statement
  • Stability: stable
  • Suites: audit
  • Applies to: PostgreSQL 18

The routine calls somebody else's function, as you

A SECURITY DEFINER routine runs with the privileges of its owner rather than its caller. That is deliberate and useful — it is how a low-privilege application gets one narrow, audited path into something it otherwise could not touch.

Inside the routine, an unqualified name — now(), users, crypt() — is resolved through whichever search_path is in effect. Unless the routine pins its own, that is the caller's.

So a caller who can create a schema does this:

CREATE SCHEMA evil;
CREATE FUNCTION evil.now() RETURNS timestamptz AS $$
-- runs as the routine's owner
GRANT ALL ON ALL TABLES IN SCHEMA public TO attacker;
SELECT clock_timestamp();
$$ LANGUAGE sql;

SET search_path = evil, public;
SELECT app.grant_access(); -- calls evil.now(), as app_owner

EXECUTE on such a routine is therefore not "may run this function". It is "may run arbitrary code as the owner" — and the owner is usually the role that owns the schema, which on an ordinary Laravel deployment owns everything.

Bad

CREATE FUNCTION app.grant_access(uid bigint) RETURNS void
LANGUAGE sql
SECURITY DEFINER
AS $$
INSERT INTO memberships (user_id, granted_at) VALUES (uid, now());
$$;

Good

CREATE FUNCTION app.grant_access(uid bigint) RETURNS void
LANGUAGE sql
SECURITY DEFINER
-- one clause, and the whole path is closed
SET search_path = pg_catalog
AS $$
INSERT INTO public.memberships (user_id, granted_at) VALUES (uid, pg_catalog.now());
$$;

Three spellings are safe, and SQLens accepts any of them: an explicit schema list, pg_catalog, or the empty string — which forces every name in the body to be qualified and is the strictest.

ALTER FUNCTION app.grant_access(bigint) SET search_path = '';

Check what your database currently has:

SELECT n.nspname, p.proname, p.proconfig
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prosecdef AND p.proconfig IS NULL
AND n.nspname NOT IN ('pg_catalog', 'information_schema');
  • SEC.PRIV.ROUTINE_DEFINER — the same construction done right. It is still reported, at low, because EXECUTE on it means more than EXECUTE usually does.