Hi Jan, I'm interested in looking into this issue, I'll sure to reproduce it in my machine tomorrow and let you know about it.
Best Regards, Osama Abdul Qader On Mon, 7 Sept, 2026, 8:09 pm Jan Nidzwetzki, <[email protected]> wrote: > 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 > @[email protected]('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 >
