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');
Related
SEC.PRIV.ROUTINE_DEFINER— the same construction done right. It is still reported, atlow, becauseEXECUTEon it means more thanEXECUTEusually does.