Prevent object capture in CREATE/ALTER EXTENSION scripts

From: Jan Nidzwetzki <jan(at)planetscale(dot)com>
To: pgsql-hackers(at)lists(dot)postgresql(dot)org
Subject: Prevent object capture in CREATE/ALTER EXTENSION scripts
Date: 2026-09-07 14:39:29
Message-ID: 28f66580-5d6b-4997-95f6-7d9e7917c585@planetscale.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi Hackers,

We would like to propose hardening name resolution during extension
installation and upgrade scripts, so that a script cannot be made to
reach an object that another role planted in the extension's
installation schema.

Problem
=======

A CREATE EXTENSION or ALTER EXTENSION ... UPDATE script runs as the
invoking role, or as the bootstrap superuser for a trusted extension
installed by a non-superuser. Either way a captured reference runs
another role's code with elevated rights.

Say the script calls f('abc'). The literal has type "unknown", so if
the extension defines f(varchar) but another role has already created
f(text) in the same schema, both are candidates. They sit in the same
schema, so search-path position cannot break the tie, and
func_select_candidate() falls back to type preference: text is the
preferred type of the string category, so f(text) wins, and the script
runs that role's function with its own privileges. Writing
@extschema(at)(dot)f('abc') changes nothing: qualification pins the schema the
plant sits in, not the signature.

Neither existing defense covers this. The search_path pinning from
7eeb1d9861b ("Make contrib modules' installation scripts more
secure.") only keeps the plant out of the other schemas on the path,
not out of the extension's own target schema, which is necessarily
first. And the ownership checks from b9b21acc766 ("In extensions,
don't replace objects not belonging to the extension.") guard what a
script creates, not what it references.

The same applies to operators: CREATE OPERATOR only requires a function
the creating role can execute. Nor is it limited to overloads: a domain
or table planted under the name of a required extension's type or config
table captures the reference outright, and its CHECK constraint or
triggers then run with the script's privileges.

The only precondition is that some other role can create objects in
the installation schema. Hosted platforms that run whitelisted
extension scripts as superuser on a user's behalf make that the normal
case, not an odd configuration.

This hazard is documented: "Security Considerations for Extension
Scripts" describes these trojan objects and tells authors to
schema-qualify every name and add explicit casts [1]. That advice is
correct, but it puts the whole burden on the author getting every call
site right, and one uncast call is enough, so a resolution-time
backstop seems worthwhile.

Proposed change
===============

Two patches attached:

0001 - propagates the extension-script state to parallel workers.
creating_extension and CurrentExtensionObject are backend-local
globals, so a worker never saw them. Work that a worker does on behalf
of a script, such as parse-analyzing a parallel-safe function's body at
run time, therefore ran as though no script were in progress and would
skip the check that 0002 adds. Both values now ride through
FixedParallelState the same way the current user id and the
temp-namespace state already do. No visible effect on its own.

0002 - makes name resolution ignore untrusted objects while
creating_extension is set. An object is trusted if it is in pg_catalog,
owned by a superuser, owned by the role the script is running as, or a
member of the extension being installed or of one it requires; the last
rule reads pg_depend, and only where the cheaper tests fail.

The search-path lookups for relations, types, functions and operators
all apply the test. For functions and operators it happens as
candidates are gathered, not in the callers that resolve the
ambiguity: the gather loop collapses duplicate signatures to the one
earliest on the path, so a plant in the extension's own schema would
otherwise displace a required extension's identical function. It is
also the single place every caller passes through, including
LookupFuncNameInternal() and regprocedure input; the operator test in
OpernameGetOprid() likewise covers binary_oper_exact(),
LookupOperName(), and so CREATE OPERATOR CLASS, and regoperator.

Caches need the same treatment. oper() and left_oper() skip the
operator lookaside cache while creating_extension, and CachedPlanSource
records whether it was analyzed inside a script, including on the
plpgsql simple-expression fast path, since a plan analyzed beforehand
in a session whose search_path matches the one the script pins would
otherwise be reused inside it.

If nothing trusted remains, resolution fails as though the object did
not exist, with a detail saying a candidate was ignored. If a trusted
candidate exists but does not match, the usual argument-type error is
raised and the ignored candidate is mentioned in a hint. Outside
extension scripts every object is trusted, so ordinary parsing is
unchanged.

0002 adds regression tests for planted overloads, references with no
trusted candidate, "superuser = false" extensions, updates run by
another role, required extensions, cached plans and parallel workers.

What this does not cover
========================

The filtering only applies while the script runs. An extension's own
function bodies resolve names when they execute, with
creating_extension false again, so a planted overload can still
capture those calls after installation, usually with the caller's
privileges, though SECURITY DEFINER puts the elevated case back on the
table. Covering that wants a different mechanism; script time seems
worth doing on its own, being the window where a captured reference is
most likely to run as a superuser.

Trust is by ownership, not by name. A script that references an object
owned by another ordinary role, including one that belongs to an
extension outside its direct requires list, now fails even with a
schema-qualified name. Such references should be rare, but this is a
behavior change for existing scripts.

Operator classes and families, collations, text search objects,
conversions, statistics objects and casts still take unfiltered lookup
paths; none looked like a route to running an unprivileged role's code,
but we may have missed one. The lookups that resolve by exact name also
have no flags word to carry the "candidate ignored" detail.

Related work
============

Jelte Fennema-Nio's "extensions with an owned schema" [2] starts from
the same observation and gives the extension a fresh schema, removing
the precondition. It is opt-in and only for new extensions, so the two
look complementary.

The "sandboxing untrusted code" thread [3] makes a point we tried to
honor: a check that rejects what a human reads as harmless gets
switched off. A script references built-ins, its own objects and its
required extensions', and the trust rule accepts all three regardless
of who owns them.

Does this approach seem reasonable?

[1]
https://www.postgresql.org/docs/current/extend-extensions.html#EXTEND-EXTENSIONS-SECURITY-SCRIPTS
[2]
https://www.postgresql.org/message-id/flat/CAGECzQQzDqDzakBkR71ZkQ1N1ffTjAaruRSqppQAKu3WF%2B6rNQ%40mail.gmail.com
[3]
https://www.postgresql.org/message-id/flat/CA%2BTgmoYiumw-yR8nUUX_8qdihPd0ZmT29ch0VR_r%2Bkw%2Bo7QJvQ%40mail.gmail.com

Best regards
Jan Nidzwetzki
Fabrízio de Royes Mello

--
Jan Nidzwetzki
PlanetScale Postgres Core Team

Attachment Content-Type Size
0001-Propagate-extension-script-state-to-parallel-workers.patch text/plain 4.1 KB
0002-Prefer-trusted-candidates-when-resolving-names-in-ex.patch text/plain 66.7 KB

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Grigorev Jurij 2026-09-07 14:52:59 Missing dshash cleanup in pgstat_read_statsfile() after OOM
Previous Message Andrei Lepikhov 2026-09-07 14:28:16 Re: Make the transition state of avg(int2)/avg(int4)/sum(int2)/sum(int4) internal