From 8a0e1f1ba43218b1a8d3685170dc0fde58324792 Mon Sep 17 00:00:00 2001 From: Jan Nidzwetzki Date: Wed, 5 Aug 2026 15:21:09 +0200 Subject: [PATCH 2/2] Prefer trusted candidates when resolving names in extension scripts An extension script runs with superuser privileges, so a user who can create objects in the extension's schema can plant one that captures a reference the script makes, f(text) beside the extension's f(varchar) or a domain shadowing a required extension's, and run code with those privileges. Pinning search_path does not help: the plant is in the script's own first schema. Ignore untrusted objects while a script runs. Trusted means in pg_catalog, owned by a superuser, owned by the role running the script, or a member of the extension being installed or of one it requires. The relation, type, function and operator lookups all apply the test, the last two as they gather candidates so a plant cannot displace a trusted match by search-path position. Resolution otherwise fails as if the object did not exist, with a detail saying why. Cached plans record whether they were analyzed inside a script, since a script's search_path can match one the session already had. --- src/backend/catalog/namespace.c | 210 +++++++++--- src/backend/catalog/pg_operator.c | 18 ++ src/backend/commands/extension.c | 51 +++ src/backend/parser/parse_func.c | 21 +- src/backend/parser/parse_oper.c | 33 +- src/backend/utils/cache/plancache.c | 29 ++ src/include/catalog/namespace.h | 4 + src/include/commands/extension.h | 1 + src/include/utils/plancache.h | 1 + src/test/modules/test_extensions/Makefile | 16 + .../expected/test_extensions.out | 300 ++++++++++++++++++ src/test/modules/test_extensions/meson.build | 18 ++ .../test_extensions/sql/test_extensions.sql | 183 +++++++++++ .../test_ext_overload--1.0.sql | 19 ++ .../test_extensions/test_ext_overload.control | 3 + .../test_ext_overload_nosuper--1.0--2.0.sql | 9 + .../test_ext_overload_nosuper--1.0.sql | 12 + .../test_ext_overload_nosuper.control | 4 + .../test_ext_overload_parallel--1.0.sql | 15 + .../test_ext_overload_parallel.control | 3 + .../test_ext_overload_req--1.0.sql | 20 ++ .../test_ext_overload_req.control | 4 + .../test_ext_overload_req_dep--1.0.sql | 23 ++ .../test_ext_overload_req_dep.control | 4 + .../test_ext_overload_strict--1.0.sql | 9 + .../test_ext_overload_strict--2.0.sql | 9 + .../test_ext_overload_strict--3.0.sql | 10 + .../test_ext_overload_strict--4.0.sql | 9 + .../test_ext_overload_strict--5.0.sql | 8 + .../test_ext_overload_strict--6.0.sql | 8 + .../test_ext_overload_strict.control | 3 + 31 files changed, 1008 insertions(+), 49 deletions(-) create mode 100644 src/test/modules/test_extensions/test_ext_overload--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload.control create mode 100644 src/test/modules/test_extensions/test_ext_overload_nosuper--1.0--2.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_nosuper--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_nosuper.control create mode 100644 src/test/modules/test_extensions/test_ext_overload_parallel--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_parallel.control create mode 100644 src/test/modules/test_extensions/test_ext_overload_req--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_req.control create mode 100644 src/test/modules/test_extensions/test_ext_overload_req_dep--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_req_dep.control create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--1.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--2.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--3.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--4.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--5.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--6.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict.control diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c index 0647a198dea..737d03e939f 100644 --- a/src/backend/catalog/namespace.c +++ b/src/backend/catalog/namespace.c @@ -41,6 +41,7 @@ #include "catalog/pg_ts_parser.h" #include "catalog/pg_ts_template.h" #include "catalog/pg_type.h" +#include "commands/extension.h" #include "common/hashfn_unstable.h" #include "funcapi.h" #include "mb/pg_wchar.h" @@ -225,6 +226,8 @@ static bool TSParserIsVisibleExt(Oid prsId, bool *is_missing); static bool TSDictionaryIsVisibleExt(Oid dictId, bool *is_missing); static bool TSTemplateIsVisibleExt(Oid tmplId, bool *is_missing); static bool TSConfigIsVisibleExt(Oid cfgid, bool *is_missing); +static bool RelationIsTrustedInExtensionScript(Oid relid); +static bool TypeIsTrustedInExtensionScript(Oid typid); static void recomputeNamespacePath(void); static void AccessTempTableNamespace(bool force); static void InitTempTableNamespace(void); @@ -896,13 +899,41 @@ RelnameGetRelid(const char *relname) relid = get_relname_relid(relname, namespaceId); if (OidIsValid(relid)) + { + /* Skip untrusted matches while an extension script runs */ + if (creating_extension && + !RelationIsTrustedInExtensionScript(relid)) + continue; return relid; + } } /* Not found in path */ return InvalidOid; } +/* + * RelationIsTrustedInExtensionScript + * ObjectIsTrustedInExtensionScript for a relation, by OID. + */ +static bool +RelationIsTrustedInExtensionScript(Oid relid) +{ + HeapTuple tp; + Form_pg_class form; + bool result; + + tp = SearchSysCache1(RELOID, ObjectIdGetDatum(relid)); + if (!HeapTupleIsValid(tp)) + return true; + form = (Form_pg_class) GETSTRUCT(tp); + result = ObjectIsTrustedInExtensionScript(RelationRelationId, relid, + form->relnamespace, + form->relowner); + ReleaseSysCache(tp); + return result; +} + /* * RelationIsVisible @@ -1024,13 +1055,40 @@ TypenameGetTypidExtended(const char *typname, bool temp_ok) PointerGetDatum(typname), ObjectIdGetDatum(namespaceId)); if (OidIsValid(typid)) + { + /* Skip untrusted matches while an extension script runs */ + if (creating_extension && !TypeIsTrustedInExtensionScript(typid)) + continue; return typid; + } } /* Not found in path */ return InvalidOid; } +/* + * TypeIsTrustedInExtensionScript + * ObjectIsTrustedInExtensionScript for a type, by OID. + */ +static bool +TypeIsTrustedInExtensionScript(Oid typid) +{ + HeapTuple tp; + Form_pg_type form; + bool result; + + tp = SearchSysCache1(TYPEOID, ObjectIdGetDatum(typid)); + if (!HeapTupleIsValid(tp)) + return true; + form = (Form_pg_type) GETSTRUCT(tp); + result = ObjectIsTrustedInExtensionScript(TypeRelationId, typid, + form->typnamespace, + form->typowner); + ReleaseSysCache(tp); + return result; +} + /* * TypeIsVisible * Determine whether a type (identified by OID) is visible in the @@ -1278,6 +1336,21 @@ FuncnameGetCandidates(List *names, int nargs, List *argnames, continue; /* proc is not in search path */ } + /* + * During an extension script, skip untrusted candidates before any + * further flags are set, so the remaining flags describe trusted + * candidates only (see ObjectIsTrustedInExtensionScript). + */ + if (creating_extension && + !ObjectIsTrustedInExtensionScript(ProcedureRelationId, + procform->oid, + procform->pronamespace, + procform->proowner)) + { + *fgc_flags |= FGC_UNTRUSTED_SKIP; + continue; + } + *fgc_flags |= FGC_NAME_VISIBLE; /* routine is in the right schema */ /* @@ -1591,6 +1664,35 @@ FuncnameGetCandidates(List *names, int nargs, List *argnames, return resultList; } +/* + * ObjectIsTrustedInExtensionScript + * May an extension script safely resolve a name to this object? + * + * Trusted means in pg_catalog, owned by a superuser, owned by the role running + * the script, or a member of the extension being installed or of one it + * requires. The membership rule lets a script reach objects that an earlier + * version of itself, or a "superuser = false" required extension, created + * under some other role. + */ +bool +ObjectIsTrustedInExtensionScript(Oid classId, Oid objectId, + Oid namespaceId, Oid ownerId) +{ + Oid extensionId; + + if (namespaceId == PG_CATALOG_NAMESPACE || + superuser_arg(ownerId) || + ownerId == GetUserId()) + return true; + + extensionId = getExtensionOfObject(classId, objectId); + if (!OidIsValid(extensionId)) + return false; + + return extensionId == CurrentExtensionObject || + CurrentExtensionRequires(extensionId); +} + /* * MatchNamedCall * Given a pg_proc heap tuple and a call's list of argument names, @@ -1861,6 +1963,14 @@ OpernameGetOprid(List *names, Oid oprleft, Oid oprright) Form_pg_operator operclass = (Form_pg_operator) GETSTRUCT(opertup); Oid result = operclass->oid; + /* Reject an untrusted match while an extension script runs */ + if (creating_extension && + !ObjectIsTrustedInExtensionScript(OperatorRelationId, + result, + operclass->oprnamespace, + operclass->oprowner)) + result = InvalidOid; + ReleaseSysCache(opertup); return result; } @@ -1906,6 +2016,14 @@ OpernameGetOprid(List *names, Oid oprleft, Oid oprright) { Oid result = operform->oid; + /* Skip untrusted matches while an extension script runs */ + if (creating_extension && + !ObjectIsTrustedInExtensionScript(OperatorRelationId, + result, + operform->oprnamespace, + operform->oprowner)) + continue; + ReleaseSysCacheList(catlist); return result; } @@ -2033,53 +2151,63 @@ OpernameGetCandidates(List *names, char oprkind, bool missing_schema_ok, } if (nsp == NULL) continue; /* oper is not in search path */ + } - /* - * Okay, it's in the search path, but does it have the same - * arguments as something we already accepted? If so, keep only - * the one that appears earlier in the search path. - * - * If we have an ordered list from SearchSysCacheList (the normal - * case), then any conflicting oper must immediately adjoin this - * one in the list, so we only need to look at the newest result - * item. If we have an unordered list, we have to scan the whole - * result list. - */ - if (resultList) - { - FuncCandidateList prevResult; + /* Likewise skip untrusted candidates, as in FuncnameGetCandidates */ + if (creating_extension && + !ObjectIsTrustedInExtensionScript(OperatorRelationId, + operform->oid, + operform->oprnamespace, + operform->oprowner)) + { + *fgc_flags |= FGC_UNTRUSTED_SKIP; + continue; + } - if (catlist->ordered) - { - if (operform->oprleft == resultList->args[0] && - operform->oprright == resultList->args[1]) - prevResult = resultList; - else - prevResult = NULL; - } + /* + * Okay, it's in the search path, but does it have the same arguments + * as something we already accepted? If so, keep only the one that + * appears earlier in the search path. + * + * If we have an ordered list from SearchSysCacheList (the normal + * case), then any conflicting oper must immediately adjoin this one + * in the list, so we only need to look at the newest result item. If + * we have an unordered list, we have to scan the whole result list. + */ + if (!OidIsValid(namespaceId) && resultList) + { + FuncCandidateList prevResult; + + if (catlist->ordered) + { + if (operform->oprleft == resultList->args[0] && + operform->oprright == resultList->args[1]) + prevResult = resultList; else + prevResult = NULL; + } + else + { + for (prevResult = resultList; + prevResult; + prevResult = prevResult->next) { - for (prevResult = resultList; - prevResult; - prevResult = prevResult->next) - { - if (operform->oprleft == prevResult->args[0] && - operform->oprright == prevResult->args[1]) - break; - } - } - if (prevResult) - { - /* We have a match with a previous result */ - Assert(pathpos != prevResult->pathpos); - if (pathpos > prevResult->pathpos) - continue; /* keep previous result */ - /* replace previous result */ - prevResult->pathpos = pathpos; - prevResult->oid = operform->oid; - continue; /* args are same, of course */ + if (operform->oprleft == prevResult->args[0] && + operform->oprright == prevResult->args[1]) + break; } } + if (prevResult) + { + /* We have a match with a previous result */ + Assert(pathpos != prevResult->pathpos); + if (pathpos > prevResult->pathpos) + continue; /* keep previous result */ + /* replace previous result */ + prevResult->pathpos = pathpos; + prevResult->oid = operform->oid; + continue; /* args are same, of course */ + } } *fgc_flags |= FGC_NAME_VISIBLE; /* operator is in the right schema */ diff --git a/src/backend/catalog/pg_operator.c b/src/backend/catalog/pg_operator.c index 6b90c774c18..37c7d2e6c5c 100644 --- a/src/backend/catalog/pg_operator.c +++ b/src/backend/catalog/pg_operator.c @@ -29,6 +29,7 @@ #include "catalog/pg_operator.h" #include "catalog/pg_proc.h" #include "catalog/pg_type.h" +#include "commands/extension.h" #include "miscadmin.h" #include "parser/parse_oper.h" #include "utils/acl.h" @@ -643,6 +644,23 @@ get_other_operator(List *otherOp, Oid otherLeftTypeId, Oid otherRightTypeId, otherNamespace = QualifiedNameGetCreationNamespace(otherOp, &otherName); + /* + * If the lookup failed only because the operator is untrusted during an + * extension script, say so rather than colliding with it below. + */ + if (creating_extension && + OidIsValid(OperatorGet(otherName, otherNamespace, + otherLeftTypeId, otherRightTypeId, + &otherDefined))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_FUNCTION), + errmsg("operator does not exist: %s", + op_signature_string(otherOp, + otherLeftTypeId, + otherRightTypeId)), + errdetail("An operator of that name exists, but it is not trusted while an extension script runs."), + errhint("Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted."))); + if (strcmp(otherName, operatorName) == 0 && otherNamespace == operatorNamespace && otherLeftTypeId == leftTypeId && diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c index 4e3b4494759..3618be78751 100644 --- a/src/backend/commands/extension.c +++ b/src/backend/commands/extension.c @@ -1533,6 +1533,57 @@ SetExtensionCreationState(bool creating, Oid extensionObject) CurrentExtensionObject = extensionObject; } +/* + * CurrentExtensionRequires - does the running script's extension require this + * extension? + * + * Only direct requirements count; those are the ones whose schemas + * execute_extension_script puts into the script's search path. + */ +bool +CurrentExtensionRequires(Oid extensionId) +{ + Relation depRel; + ScanKeyData key[2]; + SysScanDesc depScan; + HeapTuple depTup; + bool result = false; + + if (!OidIsValid(CurrentExtensionObject)) + return false; + + depRel = table_open(DependRelationId, AccessShareLock); + + ScanKeyInit(&key[0], + Anum_pg_depend_classid, + BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(ExtensionRelationId)); + ScanKeyInit(&key[1], + Anum_pg_depend_objid, + BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(CurrentExtensionObject)); + + depScan = systable_beginscan(depRel, DependDependerIndexId, true, + NULL, 2, key); + + while (HeapTupleIsValid(depTup = systable_getnext(depScan))) + { + Form_pg_depend pg_depend = (Form_pg_depend) GETSTRUCT(depTup); + + if (pg_depend->refclassid == ExtensionRelationId && + pg_depend->refobjid == extensionId) + { + result = true; + break; + } + } + + systable_endscan(depScan); + table_close(depRel, AccessShareLock); + + return result; +} + /* * Find or create an ExtensionVersionInfo for the specified version name * diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c index c87804f5d41..f5ec5ffa516 100644 --- a/src/backend/parser/parse_func.c +++ b/src/backend/parser/parse_func.c @@ -1003,7 +1003,15 @@ func_lookup_failure_details(int fgc_flags, List *argnames, bool proc_call) */ if (!(fgc_flags & FGC_NAME_VISIBLE)) { - if (fgc_flags & FGC_SCHEMA_GIVEN) + if (fgc_flags & FGC_UNTRUSTED_SKIP) + { + if (proc_call) + (void) errdetail("A procedure of that name exists, but it is not trusted while an extension script runs."); + else + (void) errdetail("A function of that name exists, but it is not trusted while an extension script runs."); + return errhint("Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted."); + } + else if (fgc_flags & FGC_SCHEMA_GIVEN) return 0; /* schema-qualified name */ else if (!(fgc_flags & FGC_NAME_EXISTS)) { @@ -1021,6 +1029,15 @@ func_lookup_failure_details(int fgc_flags, List *argnames, bool proc_call) } } + /* A trusted candidate was visible; mention any skipped one as a hint */ + if (fgc_flags & FGC_UNTRUSTED_SKIP) + { + if (proc_call) + (void) errhint("A procedure of that name was ignored because it is not trusted while an extension script runs."); + else + (void) errhint("A function of that name was ignored because it is not trusted while an extension script runs."); + } + /* * Next, complain if nothing had the right number of arguments. (This * takes precedence over wrong-argnames cases because we won't even look @@ -1076,6 +1093,8 @@ func_lookup_failure_details(int fgc_flags, List *argnames, bool proc_call) (void) errdetail("No procedure of that name accepts the given argument types."); else (void) errdetail("No function of that name accepts the given argument types."); + if (fgc_flags & FGC_UNTRUSTED_SKIP) + return 0; /* keep the hint set above */ return errhint("You might need to add explicit type casts."); } diff --git a/src/backend/parser/parse_oper.c b/src/backend/parser/parse_oper.c index dc0f047ca25..a97422786d3 100644 --- a/src/backend/parser/parse_oper.c +++ b/src/backend/parser/parse_oper.c @@ -16,8 +16,10 @@ #include "postgres.h" #include "access/htup_details.h" +#include "catalog/namespace.h" #include "catalog/pg_operator.h" #include "catalog/pg_type.h" +#include "commands/extension.h" #include "lib/stringinfo.h" #include "nodes/nodeFuncs.h" #include "parser/parse_coerce.h" @@ -388,6 +390,13 @@ oper(ParseState *pstate, List *opname, Oid ltypeId, Oid rtypeId, */ key_ok = make_oper_cache_key(pstate, &key, opname, ltypeId, rtypeId, location); + /* + * Skip the lookaside cache during an extension script, so the trust + * checks below see the catalog state. + */ + if (creating_extension) + key_ok = false; + if (key_ok) { operOid = find_oper_cache_entry(&key); @@ -540,6 +549,10 @@ left_oper(ParseState *pstate, List *op, Oid arg, bool noError, int location) */ key_ok = make_oper_cache_key(pstate, &key, op, InvalidOid, arg, location); + /* Skip the lookaside cache during an extension script; see oper() */ + if (creating_extension) + key_ok = false; + if (key_ok) { operOid = find_oper_cache_entry(&key); @@ -672,7 +685,12 @@ oper_lookup_failure_details(int fgc_flags, bool is_unary_op) */ if (!(fgc_flags & FGC_NAME_VISIBLE)) { - if (fgc_flags & FGC_SCHEMA_GIVEN) + if (fgc_flags & FGC_UNTRUSTED_SKIP) + { + (void) errdetail("An operator of that name exists, but it is not trusted while an extension script runs."); + return errhint("Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted."); + } + else if (fgc_flags & FGC_SCHEMA_GIVEN) return 0; /* schema-qualified name */ else if (!(fgc_flags & FGC_NAME_EXISTS)) return errdetail("There is no operator of that name."); @@ -681,18 +699,19 @@ oper_lookup_failure_details(int fgc_flags, bool is_unary_op) } /* - * Otherwise, the problem must be incorrect argument type(s). + * Otherwise, the problem must be incorrect argument type(s); mention any + * skipped untrusted candidate in place of the usual hint. */ if (is_unary_op) - { (void) errdetail("No operator of that name accepts the given argument type."); - return errhint("You might need to add an explicit type cast."); - } else - { (void) errdetail("No operator of that name accepts the given argument types."); + if (fgc_flags & FGC_UNTRUSTED_SKIP) + return errhint("An operator of that name was ignored because it is not trusted while an extension script runs."); + else if (is_unary_op) + return errhint("You might need to add an explicit type cast."); + else return errhint("You might need to add explicit type casts."); - } } /* diff --git a/src/backend/utils/cache/plancache.c b/src/backend/utils/cache/plancache.c index fb3b38ffbbf..8b4d5e5990b 100644 --- a/src/backend/utils/cache/plancache.c +++ b/src/backend/utils/cache/plancache.c @@ -61,6 +61,7 @@ #include "access/transam.h" #include "catalog/namespace.h" +#include "commands/extension.h" #include "executor/executor.h" #include "miscadmin.h" #include "nodes/nodeFuncs.h" @@ -243,6 +244,7 @@ CreateCachedPlan(const RawStmt *raw_parse_tree, plansource->rewriteRoleId = InvalidOid; plansource->rewriteRowSecurity = false; plansource->dependsOnRLS = false; + plansource->parsedInExtensionScript = false; plansource->gplan = NULL; plansource->is_oneshot = false; plansource->is_complete = false; @@ -342,6 +344,7 @@ CreateOneShotCachedPlan(RawStmt *raw_parse_tree, plansource->rewriteRoleId = InvalidOid; plansource->rewriteRowSecurity = false; plansource->dependsOnRLS = false; + plansource->parsedInExtensionScript = false; plansource->gplan = NULL; plansource->is_oneshot = true; plansource->is_complete = false; @@ -462,6 +465,9 @@ CompleteCachedPlan(CachedPlanSource *plansource, plansource->rewriteRoleId = GetUserId(); plansource->rewriteRowSecurity = row_security; + /* Remember whether an extension script was running. */ + plansource->parsedInExtensionScript = creating_extension; + /* * Also save the current search_path in the query_context. (This * should not generate much extra cruft either, since almost certainly @@ -733,6 +739,20 @@ RevalidateCachedQuery(CachedPlanSource *plansource, } } + /* + * Name resolution applies extra trust checks while an extension script + * runs, so a tree analyzed outside one must not be reused inside it or + * vice versa. The search_path check above need not have caught this. + */ + if (plansource->is_valid && + plansource->parsedInExtensionScript != creating_extension) + { + /* Invalidate the querytree and generic plan */ + plansource->is_valid = false; + if (plansource->gplan) + plansource->gplan->is_valid = false; + } + /* * If the query rewrite phase had a possible RLS dependency, we must redo * it if either the role or the row_security setting has changed. @@ -918,6 +938,9 @@ RevalidateCachedQuery(CachedPlanSource *plansource, plansource->rewriteRoleId = GetUserId(); plansource->rewriteRowSecurity = row_security; + /* Remember whether an extension script was running. */ + plansource->parsedInExtensionScript = creating_extension; + /* * Also save the current search_path in the query_context. (This should * not generate much extra cruft either, since almost certainly the path @@ -1498,6 +1521,7 @@ CachedPlanAllowsSimpleValidityCheck(CachedPlanSource *plansource, Assert(plan == plansource->gplan); Assert(plansource->search_path != NULL); Assert(SearchPathMatchesCurrentEnvironment(plansource->search_path)); + Assert(plansource->parsedInExtensionScript == creating_extension); /* We don't support oneshot plans here. */ if (plansource->is_oneshot) @@ -1623,6 +1647,10 @@ CachedPlanIsSimplyValid(CachedPlanSource *plansource, CachedPlan *plan, if (!SearchPathMatchesCurrentEnvironment(plansource->search_path)) return false; + /* Are we in the same extension-script context as when we made it? */ + if (plansource->parsedInExtensionScript != creating_extension) + return false; + /* It's still good. Bump refcount if requested. */ if (owner) { @@ -1743,6 +1771,7 @@ CopyCachedPlan(CachedPlanSource *plansource) newsource->rewriteRoleId = plansource->rewriteRoleId; newsource->rewriteRowSecurity = plansource->rewriteRowSecurity; newsource->dependsOnRLS = plansource->dependsOnRLS; + newsource->parsedInExtensionScript = plansource->parsedInExtensionScript; newsource->gplan = NULL; diff --git a/src/include/catalog/namespace.h b/src/include/catalog/namespace.h index 9453a3e4932..be8410c3e99 100644 --- a/src/include/catalog/namespace.h +++ b/src/include/catalog/namespace.h @@ -56,6 +56,8 @@ typedef struct _FuncCandidateList #define FGC_ARGNAMES_VALID 0x0100 /* Found a fully-valid use of argnames */ /* These bits are actually filled by func_get_detail: */ #define FGC_VARIADIC_FAIL 0x0200 /* Disallowed VARIADIC with named args */ +/* This bit is set only while an extension script is running: */ +#define FGC_UNTRUSTED_SKIP 0x0400 /* Ignored an untrusted candidate */ /* * Result of checkTempNamespaceStatus @@ -122,6 +124,8 @@ extern FuncCandidateList FuncnameGetCandidates(List *names, bool include_out_arguments, bool missing_ok, int *fgc_flags); +extern bool ObjectIsTrustedInExtensionScript(Oid classId, Oid objectId, + Oid namespaceId, Oid ownerId); extern bool FunctionIsVisible(Oid funcid); extern Oid OpernameGetOprid(List *names, Oid oprleft, Oid oprright); diff --git a/src/include/commands/extension.h b/src/include/commands/extension.h index 8eaec2d4f68..327502f9311 100644 --- a/src/include/commands/extension.h +++ b/src/include/commands/extension.h @@ -34,6 +34,7 @@ extern PGDLLIMPORT Oid CurrentExtensionObject; extern void GetExtensionCreationState(bool *creating, Oid *extensionObject); extern void SetExtensionCreationState(bool creating, Oid extensionObject); +extern bool CurrentExtensionRequires(Oid extensionId); extern ObjectAddress CreateExtension(ParseState *pstate, CreateExtensionStmt *stmt); diff --git a/src/include/utils/plancache.h b/src/include/utils/plancache.h index a0355e79c28..042ea128866 100644 --- a/src/include/utils/plancache.h +++ b/src/include/utils/plancache.h @@ -129,6 +129,7 @@ typedef struct CachedPlanSource Oid rewriteRoleId; /* Role ID we did rewriting for */ bool rewriteRowSecurity; /* row_security used during rewrite */ bool dependsOnRLS; /* is rewritten query specific to the above? */ + bool parsedInExtensionScript; /* creating_extension at parse */ /* If we have a generic plan, this is a reference-counted link to it: */ struct CachedPlan *gplan; /* generic plan, or NULL if not valid */ /* Some state flags: */ diff --git a/src/test/modules/test_extensions/Makefile b/src/test/modules/test_extensions/Makefile index d1b0b81e5fd..71e489eaf58 100644 --- a/src/test/modules/test_extensions/Makefile +++ b/src/test/modules/test_extensions/Makefile @@ -9,6 +9,10 @@ EXTENSION = test_ext1 test_ext2 test_ext3 test_ext4 test_ext5 test_ext6 \ test_ext_cyclic1 test_ext_cyclic2 \ test_ext_extschema \ test_ext_evttrig \ + test_ext_overload test_ext_overload_strict \ + test_ext_overload_nosuper \ + test_ext_overload_parallel \ + test_ext_overload_req test_ext_overload_req_dep \ test_ext_set_schema \ test_ext_req_schema1 test_ext_req_schema2 test_ext_req_schema3 @@ -25,6 +29,18 @@ DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \ test_ext_cyclic1--1.0.sql test_ext_cyclic2--1.0.sql \ test_ext_extschema--1.0.sql \ test_ext_evttrig--1.0.sql test_ext_evttrig--1.0--2.0.sql \ + test_ext_overload--1.0.sql \ + test_ext_overload_strict--1.0.sql \ + test_ext_overload_strict--2.0.sql \ + test_ext_overload_strict--3.0.sql \ + test_ext_overload_strict--4.0.sql \ + test_ext_overload_strict--5.0.sql \ + test_ext_overload_strict--6.0.sql \ + test_ext_overload_nosuper--1.0.sql \ + test_ext_overload_nosuper--1.0--2.0.sql \ + test_ext_overload_parallel--1.0.sql \ + test_ext_overload_req--1.0.sql \ + test_ext_overload_req_dep--1.0.sql \ test_ext_set_schema--1.0.sql \ test_ext_req_schema1--1.0.sql \ test_ext_req_schema2--1.0.sql \ diff --git a/src/test/modules/test_extensions/expected/test_extensions.out b/src/test/modules/test_extensions/expected/test_extensions.out index 1b5debdeeb1..b44f7aade1b 100644 --- a/src/test/modules/test_extensions/expected/test_extensions.out +++ b/src/test/modules/test_extensions/expected/test_extensions.out @@ -667,3 +667,303 @@ SELECT test_s_dep.dep_req2(); DROP EXTENSION test_ext_req_schema1 CASCADE; NOTICE: drop cascades to extension test_ext_req_schema2 +-- Verify that name resolution during an extension script cannot be captured +-- by objects an unprivileged user planted in the extension's schema. +CREATE ROLE regress_ext_user; +CREATE SCHEMA test_overload; +GRANT CREATE, USAGE ON SCHEMA test_overload TO regress_ext_user; +-- As the unprivileged user, plant differently-typed siblings and the sole +-- definition of helper_only(). +SET ROLE regress_ext_user; +CREATE FUNCTION test_overload.f(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_overload.opimpl_bad(text, text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.### (leftarg = text, rightarg = text, + function = test_overload.opimpl_bad); +CREATE FUNCTION test_overload.helper_only(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_overload.opimpl_only(text, text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.@@@ (leftarg = text, rightarg = text, + function = test_overload.opimpl_only); +CREATE FUNCTION test_overload.opimpl_vc(varchar, varchar) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.<<< (leftarg = varchar, rightarg = varchar, + function = test_overload.opimpl_vc); +RESET ROLE; +-- A schema outside the script's search path, holding a planted operator. +CREATE SCHEMA test_overload_other; +GRANT CREATE, USAGE ON SCHEMA test_overload_other TO regress_ext_user; +SET ROLE regress_ext_user; +CREATE OPERATOR test_overload_other.&&& (leftarg = text, rightarg = text, + function = test_overload.opimpl_only); +RESET ROLE; +-- Installing the extension resolves f('abc') and the ### operator to the +-- extension's own (trusted) objects, not the planted ones. +CREATE EXTENSION test_ext_overload SCHEMA test_overload; +SELECT fn, op FROM test_overload.captured; + fn | op +-----------+----------- + extension | extension +(1 row) + +-- Outside of extension scripts, ordinary resolution rules are unchanged: the +-- same calls reach the planted objects. +SELECT test_overload.f('abc') AS fn, + ('a' OPERATOR(test_overload.###) 'b') AS op; + fn | op +----------+---------- + attacker | attacker +(1 row) + +-- When only an untrusted candidate exists, the script refuses to call it. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload; -- fails +ERROR: function test_overload.helper_only(unknown) does not exist +LINE 2: SELECT test_overload.helper_only('abc') AS r + ^ +DETAIL: A function of that name exists, but it is not trusted while an extension script runs. +HINT: Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted. +QUERY: CREATE TABLE test_overload.captured AS + SELECT test_overload.helper_only('abc') AS r +CONTEXT: extension script file "test_ext_overload_strict--1.0.sql", near line 8 +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '2.0'; -- fails +ERROR: operator does not exist: unknown test_overload.@@@ unknown +LINE 2: SELECT ('a' OPERATOR(test_overload.@@@) 'b') AS r + ^ +DETAIL: An operator of that name exists, but it is not trusted while an extension script runs. +HINT: Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted. +QUERY: CREATE TABLE test_overload.captured AS + SELECT ('a' OPERATOR(test_overload.@@@) 'b') AS r +CONTEXT: extension script file "test_ext_overload_strict--2.0.sql", near line 8 +-- A planted operator named as COMMUTATOR is refused as well. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '3.0'; -- fails +ERROR: operator does not exist: character varying <<< character varying +DETAIL: An operator of that name exists, but it is not trusted while an extension script runs. +HINT: Only objects in pg_catalog, owned by a superuser, or belonging to the extension or one it requires are trusted. +CONTEXT: SQL statement "CREATE OPERATOR test_overload.>>> (leftarg = varchar, rightarg = varchar, + function = test_overload.opimpl, + commutator = <<<)" +extension script file "test_ext_overload_strict--3.0.sql", near line 8 +-- Trusted candidate visible but arguments don't match: argument error, with +-- the untrusted candidate as a hint. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '4.0'; -- fails +ERROR: function test_overload.f(integer) does not exist +LINE 2: SELECT test_overload.f(1) AS r + ^ +DETAIL: No function of that name accepts the given argument types. +HINT: A function of that name was ignored because it is not trusted while an extension script runs. +QUERY: CREATE TABLE test_overload.captured AS + SELECT test_overload.f(1) AS r +CONTEXT: extension script file "test_ext_overload_strict--4.0.sql", near line 8 +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '5.0'; -- fails +ERROR: operator does not exist: integer test_overload.### integer +LINE 2: SELECT (1 OPERATOR(test_overload.###) 2) AS r + ^ +DETAIL: No operator of that name accepts the given argument types. +HINT: An operator of that name was ignored because it is not trusted while an extension script runs. +QUERY: CREATE TABLE test_overload.captured AS + SELECT (1 OPERATOR(test_overload.###) 2) AS r +CONTEXT: extension script file "test_ext_overload_strict--5.0.sql", near line 7 +-- Untrusted candidate outside the search path: ordinary not-in-path error. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '6.0'; -- fails +ERROR: operator does not exist: unknown &&& unknown +LINE 2: SELECT ('a' &&& 'b') AS r + ^ +DETAIL: An operator of that name exists, but it is not in the search_path. +QUERY: CREATE TABLE test_overload.captured AS + SELECT ('a' &&& 'b') AS r +CONTEXT: extension script file "test_ext_overload_strict--6.0.sql", near line 7 +DROP EXTENSION test_ext_overload; +DROP SCHEMA test_overload CASCADE; +NOTICE: drop cascades to 9 other objects +DETAIL: drop cascades to function test_overload.f(text) +drop cascades to function test_overload.opimpl_bad(text,text) +drop cascades to operator test_overload.###(text,text) +drop cascades to function test_overload.helper_only(text) +drop cascades to function test_overload.opimpl_only(text,text) +drop cascades to operator test_overload_other.&&&(text,text) +drop cascades to operator test_overload.@@@(text,text) +drop cascades to function test_overload.opimpl_vc(character varying,character varying) +drop cascades to operator test_overload.<<<(character varying,character varying) +DROP SCHEMA test_overload_other CASCADE; +DROP ROLE regress_ext_user; +-- A "superuser = false" script runs as the invoking user, so its objects are +-- owned by that role. They must still be trusted, and another user's plant +-- must not be. +CREATE ROLE regress_ext_owner; +CREATE ROLE regress_ext_attacker; +DO $$ BEGIN + EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_ext_owner', + current_database()); +END $$; +CREATE SCHEMA test_nosuper AUTHORIZATION regress_ext_owner; +GRANT CREATE, USAGE ON SCHEMA test_nosuper TO regress_ext_attacker; +-- Attacker plants a preferred-type (text) sibling of the extension's g(). +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_nosuper.g(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +RESET ROLE; +-- The script runs as regress_ext_owner and must resolve g('abc') to its own +-- varchar function, not the attacker's text one. +SET ROLE regress_ext_owner; +CREATE EXTENSION test_ext_overload_nosuper SCHEMA test_nosuper; +SELECT fn FROM test_nosuper.captured_nosuper; + fn +----------- + extension +(1 row) + +RESET ROLE; +-- An update run by another role (here the superuser) must still reach the +-- extension's own g(), which the install left owned by regress_ext_owner. +ALTER EXTENSION test_ext_overload_nosuper UPDATE TO '2.0'; +SELECT fn FROM test_nosuper.updated; + fn +----------- + extension +(1 row) + +DROP EXTENSION test_ext_overload_nosuper; +DROP SCHEMA test_nosuper CASCADE; +NOTICE: drop cascades to function test_nosuper.g(text) +DO $$ BEGIN + EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_ext_owner', + current_database()); +END $$; +DROP ROLE regress_ext_owner; +DROP ROLE regress_ext_attacker; +-- Resolution in a parallel worker must apply the same check, so +-- creating_extension must reach the worker. Force wrap() into a worker with +-- debug_parallel_query. +CREATE ROLE regress_ext_attacker NOSUPERUSER; +CREATE SCHEMA test_parallel; +GRANT CREATE, USAGE ON SCHEMA test_parallel TO regress_ext_attacker; +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_parallel.probe(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; +RESET ROLE; +SET debug_parallel_query = on; +CREATE EXTENSION test_ext_overload_parallel SCHEMA test_parallel; +RESET debug_parallel_query; +-- The worker resolved probe('x') to the extension's own probe(varchar), not +-- the planted probe(text). +SELECT who FROM test_parallel.captured; + who +----------- + extension +(1 row) + +DROP EXTENSION test_ext_overload_parallel; +DROP SCHEMA test_parallel CASCADE; +NOTICE: drop cascades to function test_parallel.probe(text) +DROP ROLE regress_ext_attacker; +-- A plant in the extension's own schema must not shadow a required +-- extension's object, for any kind of reference: call, DDL by name, type, +-- relation, or a resolution cached before the script. +CREATE ROLE regress_ext_attacker; +CREATE ROLE regress_ext_reqowner; +DO $$ BEGIN + EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_ext_reqowner', + current_database()); +END $$; +CREATE SCHEMA test_reqdep AUTHORIZATION regress_ext_reqowner; +CREATE SCHEMA test_req; +GRANT CREATE, USAGE ON SCHEMA test_req TO regress_ext_attacker; +-- The required extension is "superuser = false" and installed by an ordinary +-- role, so its objects are reachable only through required-extension +-- membership. +SET ROLE regress_ext_reqowner; +CREATE EXTENSION test_ext_overload_req_dep SCHEMA test_reqdep; +RESET ROLE; +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_req.reqcall(int) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_req.reqeq(int, int) RETURNS boolean + AS $$ SELECT false $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_req.=== (leftarg = integer, rightarg = integer, + function = test_req.reqeq); +-- Resolving to this domain would run pwn() with the script's privileges. +CREATE FUNCTION test_req.pwn(text) RETURNS boolean + AS $$ BEGIN RAISE EXCEPTION 'attacker code executed'; END $$ LANGUAGE plpgsql; +CREATE DOMAIN test_req.reqdom AS text CHECK (test_req.pwn(VALUE)); +CREATE TABLE test_req.reqtab(t text); +RESET ROLE; +-- Cache a resolution made outside any script, under the search_path the +-- script will pin; that plan must not be reused inside the script. +SET search_path = test_req, test_reqdep, pg_temp; +SELECT test_reqdep.reqplpgsql() AS warmed_outside_script; + warmed_outside_script +----------------------- + attacker +(1 row) + +RESET search_path; +CREATE EXTENSION test_ext_overload_req SCHEMA test_req; +-- Every reference resolved to the required extension's objects (in +-- test_reqdep), not the planted ones in test_req. +SELECT c.who, c.who_cached, n.nspname AS domain_schema + FROM test_req.captured c + JOIN pg_type t ON t.oid = c.dom + JOIN pg_namespace n ON n.oid = t.typnamespace; + who | who_cached | domain_schema +----------+------------+--------------- + required | required | test_reqdep +(1 row) + +SELECT n.nspname AS operator_func_schema + FROM pg_operator o + JOIN pg_proc p ON p.oid = o.oprcode + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE o.oprname = '###' AND o.oprnamespace = 'test_req'::regnamespace; + operator_func_schema +---------------------- + test_reqdep +(1 row) + +SELECT 'test_reqdep.reqtab' AS tbl, count(*) FROM test_reqdep.reqtab +UNION ALL +SELECT 'test_req.reqtab', count(*) FROM test_req.reqtab; + tbl | count +--------------------+------- + test_reqdep.reqtab | 1 + test_req.reqtab | 0 +(2 rows) + +SELECT n.nspname AS opfamily_member_schema + FROM pg_amop a + JOIN pg_opfamily f ON f.oid = a.amopfamily + JOIN pg_operator o ON o.oid = a.amopopr + JOIN pg_namespace n ON n.oid = o.oprnamespace + WHERE f.opfname = 'reqfam'; + opfamily_member_schema +------------------------ + test_reqdep +(1 row) + +-- Outside the script the cached plan is good again. +SET search_path = test_req, test_reqdep, pg_temp; +SELECT test_reqdep.reqplpgsql() AS after_script; + after_script +-------------- + attacker +(1 row) + +RESET search_path; +DROP EXTENSION test_ext_overload_req; +DROP EXTENSION test_ext_overload_req_dep; +DROP SCHEMA test_req CASCADE; +NOTICE: drop cascades to 6 other objects +DETAIL: drop cascades to function test_req.reqcall(integer) +drop cascades to function test_req.reqeq(integer,integer) +drop cascades to operator test_req.===(integer,integer) +drop cascades to function test_req.pwn(text) +drop cascades to type test_req.reqdom +drop cascades to table test_req.reqtab +DROP SCHEMA test_reqdep CASCADE; +DO $$ BEGIN + EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_ext_reqowner', + current_database()); +END $$; +DROP ROLE regress_ext_attacker; +DROP ROLE regress_ext_reqowner; diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build index 2c7cea189e2..8aeca48f32e 100644 --- a/src/test/modules/test_extensions/meson.build +++ b/src/test/modules/test_extensions/meson.build @@ -36,6 +36,24 @@ test_install_data += files( 'test_ext_evttrig--1.0--2.0.sql', 'test_ext_evttrig--1.0.sql', 'test_ext_evttrig.control', + 'test_ext_overload--1.0.sql', + 'test_ext_overload.control', + 'test_ext_overload_strict--1.0.sql', + 'test_ext_overload_strict--2.0.sql', + 'test_ext_overload_strict--3.0.sql', + 'test_ext_overload_strict--4.0.sql', + 'test_ext_overload_strict--5.0.sql', + 'test_ext_overload_strict--6.0.sql', + 'test_ext_overload_strict.control', + 'test_ext_overload_nosuper--1.0--2.0.sql', + 'test_ext_overload_nosuper--1.0.sql', + 'test_ext_overload_nosuper.control', + 'test_ext_overload_parallel--1.0.sql', + 'test_ext_overload_parallel.control', + 'test_ext_overload_req--1.0.sql', + 'test_ext_overload_req.control', + 'test_ext_overload_req_dep--1.0.sql', + 'test_ext_overload_req_dep.control', 'test_ext_req_schema1--1.0.sql', 'test_ext_req_schema1.control', 'test_ext_req_schema2--1.0.sql', diff --git a/src/test/modules/test_extensions/sql/test_extensions.sql b/src/test/modules/test_extensions/sql/test_extensions.sql index b5878f6f80f..1a09e98c115 100644 --- a/src/test/modules/test_extensions/sql/test_extensions.sql +++ b/src/test/modules/test_extensions/sql/test_extensions.sql @@ -303,3 +303,186 @@ ALTER EXTENSION test_ext_req_schema1 SET SCHEMA test_s_dep2; -- now ok SELECT test_s_dep2.dep_req1(); SELECT test_s_dep.dep_req2(); DROP EXTENSION test_ext_req_schema1 CASCADE; + +-- Verify that name resolution during an extension script cannot be captured +-- by objects an unprivileged user planted in the extension's schema. +CREATE ROLE regress_ext_user; +CREATE SCHEMA test_overload; +GRANT CREATE, USAGE ON SCHEMA test_overload TO regress_ext_user; +-- As the unprivileged user, plant differently-typed siblings and the sole +-- definition of helper_only(). +SET ROLE regress_ext_user; +CREATE FUNCTION test_overload.f(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_overload.opimpl_bad(text, text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.### (leftarg = text, rightarg = text, + function = test_overload.opimpl_bad); +CREATE FUNCTION test_overload.helper_only(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_overload.opimpl_only(text, text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.@@@ (leftarg = text, rightarg = text, + function = test_overload.opimpl_only); +CREATE FUNCTION test_overload.opimpl_vc(varchar, varchar) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_overload.<<< (leftarg = varchar, rightarg = varchar, + function = test_overload.opimpl_vc); +RESET ROLE; +-- A schema outside the script's search path, holding a planted operator. +CREATE SCHEMA test_overload_other; +GRANT CREATE, USAGE ON SCHEMA test_overload_other TO regress_ext_user; +SET ROLE regress_ext_user; +CREATE OPERATOR test_overload_other.&&& (leftarg = text, rightarg = text, + function = test_overload.opimpl_only); +RESET ROLE; +-- Installing the extension resolves f('abc') and the ### operator to the +-- extension's own (trusted) objects, not the planted ones. +CREATE EXTENSION test_ext_overload SCHEMA test_overload; +SELECT fn, op FROM test_overload.captured; +-- Outside of extension scripts, ordinary resolution rules are unchanged: the +-- same calls reach the planted objects. +SELECT test_overload.f('abc') AS fn, + ('a' OPERATOR(test_overload.###) 'b') AS op; +-- When only an untrusted candidate exists, the script refuses to call it. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload; -- fails +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '2.0'; -- fails +-- A planted operator named as COMMUTATOR is refused as well. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '3.0'; -- fails +-- Trusted candidate visible but arguments don't match: argument error, with +-- the untrusted candidate as a hint. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '4.0'; -- fails +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '5.0'; -- fails +-- Untrusted candidate outside the search path: ordinary not-in-path error. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '6.0'; -- fails +DROP EXTENSION test_ext_overload; +DROP SCHEMA test_overload CASCADE; +DROP SCHEMA test_overload_other CASCADE; +DROP ROLE regress_ext_user; + +-- A "superuser = false" script runs as the invoking user, so its objects are +-- owned by that role. They must still be trusted, and another user's plant +-- must not be. +CREATE ROLE regress_ext_owner; +CREATE ROLE regress_ext_attacker; +DO $$ BEGIN + EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_ext_owner', + current_database()); +END $$; +CREATE SCHEMA test_nosuper AUTHORIZATION regress_ext_owner; +GRANT CREATE, USAGE ON SCHEMA test_nosuper TO regress_ext_attacker; +-- Attacker plants a preferred-type (text) sibling of the extension's g(). +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_nosuper.g(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +RESET ROLE; +-- The script runs as regress_ext_owner and must resolve g('abc') to its own +-- varchar function, not the attacker's text one. +SET ROLE regress_ext_owner; +CREATE EXTENSION test_ext_overload_nosuper SCHEMA test_nosuper; +SELECT fn FROM test_nosuper.captured_nosuper; +RESET ROLE; +-- An update run by another role (here the superuser) must still reach the +-- extension's own g(), which the install left owned by regress_ext_owner. +ALTER EXTENSION test_ext_overload_nosuper UPDATE TO '2.0'; +SELECT fn FROM test_nosuper.updated; +DROP EXTENSION test_ext_overload_nosuper; +DROP SCHEMA test_nosuper CASCADE; +DO $$ BEGIN + EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_ext_owner', + current_database()); +END $$; +DROP ROLE regress_ext_owner; +DROP ROLE regress_ext_attacker; + +-- Resolution in a parallel worker must apply the same check, so +-- creating_extension must reach the worker. Force wrap() into a worker with +-- debug_parallel_query. +CREATE ROLE regress_ext_attacker NOSUPERUSER; +CREATE SCHEMA test_parallel; +GRANT CREATE, USAGE ON SCHEMA test_parallel TO regress_ext_attacker; +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_parallel.probe(text) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; +RESET ROLE; +SET debug_parallel_query = on; +CREATE EXTENSION test_ext_overload_parallel SCHEMA test_parallel; +RESET debug_parallel_query; +-- The worker resolved probe('x') to the extension's own probe(varchar), not +-- the planted probe(text). +SELECT who FROM test_parallel.captured; +DROP EXTENSION test_ext_overload_parallel; +DROP SCHEMA test_parallel CASCADE; +DROP ROLE regress_ext_attacker; + +-- A plant in the extension's own schema must not shadow a required +-- extension's object, for any kind of reference: call, DDL by name, type, +-- relation, or a resolution cached before the script. +CREATE ROLE regress_ext_attacker; +CREATE ROLE regress_ext_reqowner; +DO $$ BEGIN + EXECUTE format('GRANT CREATE ON DATABASE %I TO regress_ext_reqowner', + current_database()); +END $$; +CREATE SCHEMA test_reqdep AUTHORIZATION regress_ext_reqowner; +CREATE SCHEMA test_req; +GRANT CREATE, USAGE ON SCHEMA test_req TO regress_ext_attacker; +-- The required extension is "superuser = false" and installed by an ordinary +-- role, so its objects are reachable only through required-extension +-- membership. +SET ROLE regress_ext_reqowner; +CREATE EXTENSION test_ext_overload_req_dep SCHEMA test_reqdep; +RESET ROLE; +SET ROLE regress_ext_attacker; +CREATE FUNCTION test_req.reqcall(int) RETURNS text + AS $$ SELECT 'attacker'::text $$ LANGUAGE sql IMMUTABLE; +CREATE FUNCTION test_req.reqeq(int, int) RETURNS boolean + AS $$ SELECT false $$ LANGUAGE sql IMMUTABLE; +CREATE OPERATOR test_req.=== (leftarg = integer, rightarg = integer, + function = test_req.reqeq); +-- Resolving to this domain would run pwn() with the script's privileges. +CREATE FUNCTION test_req.pwn(text) RETURNS boolean + AS $$ BEGIN RAISE EXCEPTION 'attacker code executed'; END $$ LANGUAGE plpgsql; +CREATE DOMAIN test_req.reqdom AS text CHECK (test_req.pwn(VALUE)); +CREATE TABLE test_req.reqtab(t text); +RESET ROLE; +-- Cache a resolution made outside any script, under the search_path the +-- script will pin; that plan must not be reused inside the script. +SET search_path = test_req, test_reqdep, pg_temp; +SELECT test_reqdep.reqplpgsql() AS warmed_outside_script; +RESET search_path; +CREATE EXTENSION test_ext_overload_req SCHEMA test_req; +-- Every reference resolved to the required extension's objects (in +-- test_reqdep), not the planted ones in test_req. +SELECT c.who, c.who_cached, n.nspname AS domain_schema + FROM test_req.captured c + JOIN pg_type t ON t.oid = c.dom + JOIN pg_namespace n ON n.oid = t.typnamespace; +SELECT n.nspname AS operator_func_schema + FROM pg_operator o + JOIN pg_proc p ON p.oid = o.oprcode + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE o.oprname = '###' AND o.oprnamespace = 'test_req'::regnamespace; +SELECT 'test_reqdep.reqtab' AS tbl, count(*) FROM test_reqdep.reqtab +UNION ALL +SELECT 'test_req.reqtab', count(*) FROM test_req.reqtab; +SELECT n.nspname AS opfamily_member_schema + FROM pg_amop a + JOIN pg_opfamily f ON f.oid = a.amopfamily + JOIN pg_operator o ON o.oid = a.amopopr + JOIN pg_namespace n ON n.oid = o.oprnamespace + WHERE f.opfname = 'reqfam'; +-- Outside the script the cached plan is good again. +SET search_path = test_req, test_reqdep, pg_temp; +SELECT test_reqdep.reqplpgsql() AS after_script; +RESET search_path; +DROP EXTENSION test_ext_overload_req; +DROP EXTENSION test_ext_overload_req_dep; +DROP SCHEMA test_req CASCADE; +DROP SCHEMA test_reqdep CASCADE; +DO $$ BEGIN + EXECUTE format('REVOKE CREATE ON DATABASE %I FROM regress_ext_reqowner', + current_database()); +END $$; +DROP ROLE regress_ext_attacker; +DROP ROLE regress_ext_reqowner; diff --git a/src/test/modules/test_extensions/test_ext_overload--1.0.sql b/src/test/modules/test_extensions/test_ext_overload--1.0.sql new file mode 100644 index 00000000000..63e60a1e817 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload--1.0.sql @@ -0,0 +1,19 @@ +/* src/test/modules/test_extensions/test_ext_overload--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload" to load this file. \quit + +-- f() and ### take varchar. Resolving f('abc') during this script must +-- reach them, not a planted f(text) sibling. +CREATE FUNCTION @extschema@.f(varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE FUNCTION @extschema@.opimpl(varchar, varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE OPERATOR @extschema@.### (leftarg = varchar, rightarg = varchar, + function = @extschema@.opimpl); + +CREATE TABLE @extschema@.captured AS + SELECT @extschema@.f('abc') AS fn, + ('a' OPERATOR(@extschema@.###) 'b') AS op; diff --git a/src/test/modules/test_extensions/test_ext_overload.control b/src/test/modules/test_extensions/test_ext_overload.control new file mode 100644 index 00000000000..efaef1cb554 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload.control @@ -0,0 +1,3 @@ +comment = 'Test protection against overload capture during extension scripts' +default_version = '1.0' +relocatable = false diff --git a/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0--2.0.sql b/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0--2.0.sql new file mode 100644 index 00000000000..f63cfcc02a6 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0--2.0.sql @@ -0,0 +1,9 @@ +/* src/test/modules/test_extensions/test_ext_overload_nosuper--1.0--2.0.sql */ + +-- complain if script is sourced in psql, rather than via ALTER EXTENSION +\echo Use "ALTER EXTENSION test_ext_overload_nosuper UPDATE" to load this file. \quit + +-- g() belongs to this extension but is owned by the non-superuser who +-- installed 1.0, so an update run by any other role reaches it only by +-- extension membership. +CREATE TABLE @extschema@.updated AS SELECT g('abc') AS fn; diff --git a/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0.sql b/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0.sql new file mode 100644 index 00000000000..c3e93110825 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_nosuper--1.0.sql @@ -0,0 +1,12 @@ +/* src/test/modules/test_extensions/test_ext_overload_nosuper--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_nosuper" to load this file. \quit + +-- The script runs as the invoking non-superuser, so g() is owned by that +-- role. g('abc') must still resolve to it, not to a planted g(text). +CREATE FUNCTION @extschema@.g(varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE TABLE @extschema@.captured_nosuper AS + SELECT @extschema@.g('abc') AS fn; diff --git a/src/test/modules/test_extensions/test_ext_overload_nosuper.control b/src/test/modules/test_extensions/test_ext_overload_nosuper.control new file mode 100644 index 00000000000..eb748089e70 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_nosuper.control @@ -0,0 +1,4 @@ +comment = 'Test overload-capture protection for a superuser = false extension' +default_version = '1.0' +relocatable = false +superuser = false diff --git a/src/test/modules/test_extensions/test_ext_overload_parallel--1.0.sql b/src/test/modules/test_extensions/test_ext_overload_parallel--1.0.sql new file mode 100644 index 00000000000..14a46bca590 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_parallel--1.0.sql @@ -0,0 +1,15 @@ +/* src/test/modules/test_extensions/test_ext_overload_parallel--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_parallel" to load this file. \quit + +-- wrap() is parallel safe and its body is parsed at run time, so under +-- debug_parallel_query the worker resolves probe('x'). It must reach +-- probe(varchar), not a planted probe(text). +CREATE FUNCTION @extschema@.probe(varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; + +CREATE FUNCTION @extschema@.wrap() RETURNS text + LANGUAGE plpgsql PARALLEL SAFE AS $$ BEGIN RETURN probe('x'); END $$; + +CREATE TABLE @extschema@.captured AS SELECT @extschema@.wrap() AS who; diff --git a/src/test/modules/test_extensions/test_ext_overload_parallel.control b/src/test/modules/test_extensions/test_ext_overload_parallel.control new file mode 100644 index 00000000000..ba3cbae2162 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_parallel.control @@ -0,0 +1,3 @@ +comment = 'Test overload-capture protection when resolution runs in a parallel worker' +default_version = '1.0' +relocatable = false diff --git a/src/test/modules/test_extensions/test_ext_overload_req--1.0.sql b/src/test/modules/test_extensions/test_ext_overload_req--1.0.sql new file mode 100644 index 00000000000..2e7d82a0e34 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_req--1.0.sql @@ -0,0 +1,20 @@ +/* src/test/modules/test_extensions/test_ext_overload_req--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_req" to load this file. \quit + +-- Every reference below is to a required extension's object. A same-named +-- plant in @extschema@ must not capture any of them. +CREATE TABLE @extschema@.captured AS + SELECT reqcall(1) AS who, + reqplpgsql() AS who_cached, + pg_catalog.pg_typeof('abc'::reqdom) AS dom; + +INSERT INTO reqtab VALUES ('from script'); + +CREATE OPERATOR @extschema@.### (leftarg = integer, rightarg = integer, + function = reqeq); + +CREATE OPERATOR FAMILY @extschema@.reqfam USING btree; +ALTER OPERATOR FAMILY @extschema@.reqfam USING btree ADD + OPERATOR 3 === (integer, integer); diff --git a/src/test/modules/test_extensions/test_ext_overload_req.control b/src/test/modules/test_extensions/test_ext_overload_req.control new file mode 100644 index 00000000000..947556db238 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_req.control @@ -0,0 +1,4 @@ +comment = 'extension whose script references a required extension''s objects' +default_version = '1.0' +relocatable = false +requires = 'test_ext_overload_req_dep' diff --git a/src/test/modules/test_extensions/test_ext_overload_req_dep--1.0.sql b/src/test/modules/test_extensions/test_ext_overload_req_dep--1.0.sql new file mode 100644 index 00000000000..be42553a86d --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_req_dep--1.0.sql @@ -0,0 +1,23 @@ +/* src/test/modules/test_extensions/test_ext_overload_req_dep--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_req_dep" to load this file. \quit + +-- Objects the dependent extension's script references by unqualified name. +CREATE FUNCTION @extschema@.reqcall(int) RETURNS text + AS $$ SELECT 'required'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE FUNCTION @extschema@.reqeq(int, int) RETURNS boolean + AS $$ SELECT true $$ LANGUAGE sql IMMUTABLE; + +CREATE DOMAIN @extschema@.reqdom AS text; + +CREATE TABLE @extschema@.reqtab(t text); + +-- Parsed at run time, so a call before the dependent script caches a +-- resolution made without trust checks. +CREATE FUNCTION @extschema@.reqplpgsql() RETURNS text + LANGUAGE plpgsql AS $$ BEGIN RETURN reqcall(1); END $$; + +CREATE OPERATOR @extschema@.=== (leftarg = integer, rightarg = integer, + function = @extschema@.reqeq); diff --git a/src/test/modules/test_extensions/test_ext_overload_req_dep.control b/src/test/modules/test_extensions/test_ext_overload_req_dep.control new file mode 100644 index 00000000000..f6dfb5fa874 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_req_dep.control @@ -0,0 +1,4 @@ +comment = 'required extension providing objects for the overload-capture test' +default_version = '1.0' +relocatable = false +superuser = false diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--1.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--1.0.sql new file mode 100644 index 00000000000..13b4a8982ac --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--1.0.sql @@ -0,0 +1,9 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- helper_only() exists only as a planted definition, so the script must +-- fail with "function does not exist". +CREATE TABLE @extschema@.captured AS + SELECT @extschema@.helper_only('abc') AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--2.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--2.0.sql new file mode 100644 index 00000000000..f80b2d00c25 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--2.0.sql @@ -0,0 +1,9 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--2.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- As for helper_only() in 1.0, but for an operator: the only definition of +-- @@@ is one an unprivileged user planted, so the script must refuse it. +CREATE TABLE @extschema@.captured AS + SELECT ('a' OPERATOR(@extschema@.@@@) 'b') AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--3.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--3.0.sql new file mode 100644 index 00000000000..edd01344236 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--3.0.sql @@ -0,0 +1,10 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--3.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- The only <<< (varchar, varchar) is a plant; naming it as COMMUTATOR must +-- fail as "does not exist", not collide with it when making a shell. +CREATE OPERATOR @extschema@.>>> (leftarg = varchar, rightarg = varchar, + function = @extschema@.opimpl, + commutator = <<<); diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--4.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--4.0.sql new file mode 100644 index 00000000000..5b7a26db851 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--4.0.sql @@ -0,0 +1,9 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--4.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- Trusted f(varchar) and planted f(text) are visible; f(1) matches neither, +-- so the error is about the argument types. +CREATE TABLE @extschema@.captured AS + SELECT @extschema@.f(1) AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--5.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--5.0.sql new file mode 100644 index 00000000000..1ef3115eb1b --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--5.0.sql @@ -0,0 +1,8 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--5.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- As in 4.0, for an operator: integer operands match neither ###. +CREATE TABLE @extschema@.captured AS + SELECT (1 OPERATOR(@extschema@.###) 2) AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--6.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--6.0.sql new file mode 100644 index 00000000000..195e6d0e421 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--6.0.sql @@ -0,0 +1,8 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--6.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_ext_overload_strict" to load this file. \quit + +-- The only &&& is outside the search path; report that, not trust. +CREATE TABLE @extschema@.captured AS + SELECT ('a' &&& 'b') AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict.control b/src/test/modules/test_extensions/test_ext_overload_strict.control new file mode 100644 index 00000000000..d537687c91f --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict.control @@ -0,0 +1,3 @@ +comment = 'Test refusal to call an untrusted function during extension scripts' +default_version = '1.0' +relocatable = false -- 2.47.3