Skip to main content

SEC.PRIV.ROUTINE_DEFINER_UNSAFE_PATH — a pinned search_path is not automatically a safe one

  • Category: security
  • Severity: medium
  • 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 clause is there. It is pointing at the wrong place​

SEC.PRIV.ROUTINE_DEFINER_MUTABLE_PATH asks whether a SECURITY DEFINER routine pins its own search_path. This rule asks what is in the path it pinned, and the two are not the same question.

A routine with SET search_path = public passes the first check completely. It is also exactly as exploitable as a routine with no clause at all, the moment somebody other than its owner may create objects in public — because the substitution does not need the caller to control the path. It only needs a schema in the path that the caller can write to.

-- public is writable by anyone here, which was PostgreSQL's own default before 15
GRANT CREATE ON SCHEMA public TO PUBLIC;

CREATE FUNCTION app.grant_access(uid bigint) RETURNS void
LANGUAGE sql
SECURITY DEFINER
-- pinned, and still open: someone else can define now() in public
SET search_path = public
AS $$
INSERT INTO memberships (user_id, granted_at) VALUES (uid, now());
$$;

If public grants CREATE to anyone else, that role defines its own now() there, and the routine calls it — as the owner of app.grant_access.

Why this matters more on an older database than a new one​

PostgreSQL changed the default in 15: before that, every database was created with CREATE on public granted to PUBLIC. A database that grew through those versions usually still carries that grant, because removing it is a deliberate act nobody is prompted to take.

So the routine that reads as hardened — somebody added a SET clause on purpose — can be the one that is still open.

pg_temp is the second way, and it needs no grant at all​

The caller's own temporary schema is searched for tables, views and types before the path unless the path names it last, and a path that leaves it out does not name it. So only a path that ends in pg_temp keeps it out. Anywhere else, or missing, the caller creates a temporary table with a name the routine uses unqualified, and the routine reads and writes the caller's table instead of its own:

SET search_path = app -- pg_temp is searched first, before app
SET search_path = '' -- the same: pg_temp first, then the catalog
SET search_path = pg_temp, app -- the caller's schema, named first
SET search_path = app, pg_temp -- closed: reached only if nothing else matched

Nobody has to grant the attacker anything for the first three lines to work. It is their schema. PostgreSQL never searches pg_temp for functions or operators, so what the caller substitutes is a table, a view or a type.

What this rule measures, and what it refuses to judge​

It does not keep a list of paths that count as safe. It asks the catalog one question per schema in the routine's own path:

may anybody other than this routine's owner create objects here?

pg_namespace.nspacl answers it. A path of pg_catalog, pg_temp, or of schemas only the owner may write to followed by pg_temp, produces nothing at all — and the owner's own CREATE never counts, because a routine whose owner can write to its own path is not exposed by that.

That is why the finding names the schema rather than the path: the schema is the thing you change.

Fixing it​

Any one of these closes it:

CREATE FUNCTION app.grant_access(uid bigint) RETURNS void
LANGUAGE sql
SECURITY DEFINER
-- a path only the owner can write to, ending with pg_temp
SET search_path = pg_catalog, pg_temp
AS $$
INSERT INTO app.memberships (user_id, granted_at) VALUES (uid, pg_catalog.now());
$$;

Or take the grant away, if public was never meant to be writable:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

The last one is worth considering on its own merits: on a database created before PostgreSQL 15 it is usually a leftover rather than a decision, and it widens far more than this one routine.

Why medium and not critical​

Both this and its sibling end with code running as the owner. This one needs a second condition that an operator can see and remove — somebody else holds CREATE on a schema in the path. The unpinned case needs no such grant: any caller who can create a schema is already there.