SEC.PRIV.ROUTINE_DEFINER — A routine that runs as its owner, correctly
- Category: security
- Severity: low
- 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
This is the construction done right
The routine runs with its owner's privileges (SECURITY DEFINER) and pins its own search_path,
so the substitution attack SEC.PRIV.ROUTINE_DEFINER_MUTABLE_PATH
reports is closed. If you deliberately built a narrow, audited path for a low-privilege role, this is
what it looks like, and you have done it correctly.
So why is it a finding at all?
Because of what it does to every other judgment about your database.
EXECUTE on this routine is not an ordinary privilege — it is a bounded loan of the owner's rights. A
reviewer reading a grant list cannot see that from the grant. Neither can the least-privilege check: a
runtime role holding nothing but EXECUTE looks minimal, and may reach considerably further than any
of its other grants allow.
This entry exists so that nobody is surprised. When somebody asks what can the application actually do, these routines are part of the answer, and a report listing only the grants would be missing the part that matters most.
What to do with it
Read the grant list on the routine once and confirm it is the one you meant:
SELECT grantee, privilege_type
FROM information_schema.routine_privileges
WHERE specific_schema = 'app' AND routine_name = 'grant_access';
If it is, you are done. If your project has decided it already knows about its definer routines, raise
security.min_severity above low in config/sqlens.php — that is a decision made once rather than a
finding dismissed on every run.
Bad
There is no bad way to write this routine — the rule reports a correct arrangement, and the one
that is wrong is SEC.PRIV.ROUTINE_DEFINER_MUTABLE_PATH
beside it. What can still be wrong is the grant, which is the thing this entry asks you to read:
-- The routine is right. This is what makes it reach further than anybody meant it to.
GRANT EXECUTE ON FUNCTION app.grant_access(bigint) TO PUBLIC;
PUBLIC is the implicit membership of every role in the database, including every role created from
now on — so the loan of the owner's rights is handed to accounts nobody has made yet.
Good
CREATE FUNCTION app.grant_access(uid bigint) RETURNS void
LANGUAGE sql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
INSERT INTO public.memberships (user_id, granted_at) VALUES (uid, pg_catalog.now());
$$;
-- and the grant that goes with it: one role, one routine, nothing wider
GRANT EXECUTE ON FUNCTION app.grant_access(bigint) TO app_runtime;
Related
SEC.PRIV.ROUTINE_DEFINER_MUTABLE_PATH— the same routine without theSETclause, which is a privilege escalation rather than an inventory entry.