Hi solai, On 22.09.26 06:13, solai v wrote:
[...] > Overall, the patch worked as expected in my testing. The extension > script now selects the trusted candidate instead of the attacker-owned > candidate when both overloads are present. Thank you so much for the review. While testing the patch further, I found a gap with schema-qualified names. In v1, the trust check for relations and types was applied only when the script used an unqualified name and the lookup went through RelnameGetRelid() or TypenameGetTypidExtended(). A reference such as @[email protected] took the qualified branch of RangeVarGetRelidExtended() or LookupTypeNameExtended() and was not checked at all. Functions and operators were not affected, since their candidate lookup handles both versions in one place. v2 adds the check to those two branches, so an untrusted match is treated as nonexistent there too. I also added tests for this case; I slightly adjusted the commit message and rebased the patch set against master. Best regards Jan -- Jan Nidzwetzki PlanetScale Postgres Core Team
From e3709df506ec0a2d18c6390c4fba2723631374d0 Mon Sep 17 00:00:00 2001 From: Jan Nidzwetzki <[email protected]> Date: Wed, 5 Aug 2026 15:21:09 +0200 Subject: [PATCH v2 1/2] Propagate extension-script state to parallel workers creating_extension and CurrentExtensionObject are backend-local, so a parallel worker doing work for a CREATE/ALTER EXTENSION script (for example, executing a parallel-safe function's body) behaved as though no script were in progress. No visible effect on its own; preparation for the following commit. Author: Jan Nidzwetzki <[email protected]> Reviewed-by: Osama Abdul Qader <[email protected]> Reviewed-by: solai v <[email protected]> Discussion: https://postgr.es/m/[email protected] --- src/backend/access/transam/parallel.c | 9 +++++++++ src/backend/commands/extension.c | 24 ++++++++++++++++++++++++ src/include/commands/extension.h | 3 +++ 3 files changed, 36 insertions(+) diff --git a/src/backend/access/transam/parallel.c b/src/backend/access/transam/parallel.c index e1806a9a28a..6efa656d399 100644 --- a/src/backend/access/transam/parallel.c +++ b/src/backend/access/transam/parallel.c @@ -26,6 +26,7 @@ #include "catalog/pg_enum.h" #include "catalog/storage.h" #include "commands/async.h" +#include "commands/extension.h" #include "commands/vacuum.h" #include "executor/execParallel.h" #include "libpq/libpq.h" @@ -90,9 +91,11 @@ typedef struct FixedParallelState Oid current_user_id; Oid temp_namespace_id; Oid temp_toast_namespace_id; + Oid current_extension_object; int sec_context; bool session_user_is_superuser; bool role_is_superuser; + bool creating_extension; PGPROC *parallel_leader_pgproc; pid_t parallel_leader_pid; ProcNumber parallel_leader_proc_number; @@ -348,6 +351,8 @@ InitializeParallelDSM(ParallelContext *pcxt) fps->role_is_superuser = current_role_is_superuser; GetTempNamespaceState(&fps->temp_namespace_id, &fps->temp_toast_namespace_id); + GetExtensionCreationState(&fps->creating_extension, + &fps->current_extension_object); fps->parallel_leader_pgproc = MyProc; fps->parallel_leader_pid = MyProcPid; fps->parallel_leader_proc_number = MyProcNumber; @@ -1534,6 +1539,10 @@ ParallelWorkerMain(Datum main_arg) SetTempNamespaceState(fps->temp_namespace_id, fps->temp_toast_namespace_id); + /* Restore extension-script state, so the worker matches the leader. */ + SetExtensionCreationState(fps->creating_extension, + fps->current_extension_object); + /* Restore uncommitted enums. */ uncommittedenumsspace = shm_toc_lookup(toc, PARALLEL_KEY_UNCOMMITTEDENUMS, false); diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c index 5855668c5ca..f8f6b179631 100644 --- a/src/backend/commands/extension.c +++ b/src/backend/commands/extension.c @@ -1509,6 +1509,30 @@ execute_extension_script(Oid extensionOid, ExtensionControlFile *control, SetUserIdAndSecContext(save_userid, save_sec_context); } +/* + * GetExtensionCreationState - report the current extension-script state + * + * Used by the parallel-query machinery to carry creating_extension and + * CurrentExtensionObject to workers, so a worker sees the same + * extension-script state as the leader. + */ +void +GetExtensionCreationState(bool *creating, Oid *extensionObject) +{ + *creating = creating_extension; + *extensionObject = CurrentExtensionObject; +} + +/* + * SetExtensionCreationState - restore extension-script state in a worker + */ +void +SetExtensionCreationState(bool creating, Oid extensionObject) +{ + creating_extension = creating; + CurrentExtensionObject = extensionObject; +} + /* * Find or create an ExtensionVersionInfo for the specified version name * diff --git a/src/include/commands/extension.h b/src/include/commands/extension.h index 7a76bdebcfa..8eaec2d4f68 100644 --- a/src/include/commands/extension.h +++ b/src/include/commands/extension.h @@ -32,6 +32,9 @@ extern PGDLLIMPORT char *Extension_control_path; extern PGDLLIMPORT bool creating_extension; extern PGDLLIMPORT Oid CurrentExtensionObject; +extern void GetExtensionCreationState(bool *creating, Oid *extensionObject); +extern void SetExtensionCreationState(bool creating, Oid extensionObject); + extern ObjectAddress CreateExtension(ParseState *pstate, CreateExtensionStmt *stmt); -- 2.47.3
From 3bfc26ddf25a6eba6fd9ef3f6d6caf6eeae2884e Mon Sep 17 00:00:00 2001 From: Jan Nidzwetzki <[email protected]> Date: Wed, 5 Aug 2026 15:21:09 +0200 Subject: [PATCH v2 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. Neither pinning search_path nor writing @[email protected] helps: the plant is in the script's own schema, so a qualified name reaches it just as a search_path lookup does. 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, for schema-qualified names as well as names resolved through search_path; the last two apply it 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. Reported-by: Mehmet Ince (@mdisec) Author: Jan Nidzwetzki <[email protected]> Reviewed-by: Osama Abdul Qader <[email protected]> Reviewed-by: solai v <[email protected]> Discussion: https://postgr.es/m/[email protected] --- src/backend/catalog/namespace.c | 308 +++++++++++++--- src/backend/catalog/pg_operator.c | 18 + src/backend/commands/extension.c | 51 +++ src/backend/commands/functioncmds.c | 4 +- src/backend/parser/parse_func.c | 21 +- src/backend/parser/parse_oper.c | 33 +- src/backend/parser/parse_relation.c | 6 +- src/backend/parser/parse_type.c | 32 +- src/backend/utils/cache/plancache.c | 29 ++ src/include/catalog/namespace.h | 8 + src/include/commands/extension.h | 1 + src/include/parser/parse_type.h | 1 + src/include/utils/plancache.h | 1 + src/test/modules/test_extensions/Makefile | 20 + .../expected/test_extensions.out | 348 ++++++++++++++++++ src/test/modules/test_extensions/meson.build | 22 ++ .../test_extensions/sql/test_extensions.sql | 194 ++++++++++ .../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--10.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--7.0.sql | 12 + .../test_ext_overload_strict--8.0.sql | 8 + .../test_ext_overload_strict--9.0.sql | 7 + .../test_ext_overload_strict.control | 3 + 39 files changed, 1250 insertions(+), 55 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--10.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--7.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--8.0.sql create mode 100644 src/test/modules/test_extensions/test_ext_overload_strict--9.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..e0569438e43 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,7 @@ 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 void recomputeNamespacePath(void); static void AccessTempTableNamespace(bool force); static void InitTempTableNamespace(void); @@ -533,6 +535,14 @@ RangeVarGetRelidExtended(const RangeVar *relation, LOCKMODE lockmode, relId = InvalidOid; else relId = get_relname_relid(relation->relname, namespaceId); + + /* + * Treat an untrusted match as nonexistent while an extension + * script runs, as RelnameGetRelid does for unqualified names. + */ + if (OidIsValid(relId) && creating_extension && + !RelationIsTrustedInExtensionScript(relId)) + relId = InvalidOid; } else { @@ -632,12 +642,14 @@ RangeVarGetRelidExtended(const RangeVar *relation, LOCKMODE lockmode, ereport(elevel, (errcode(ERRCODE_UNDEFINED_TABLE), errmsg("relation \"%s.%s\" does not exist", - relation->schemaname, relation->relname))); + relation->schemaname, relation->relname), + errdetail_untrusted_relation(relation))); else ereport(elevel, (errcode(ERRCODE_UNDEFINED_TABLE), errmsg("relation \"%s\" does not exist", - relation->relname))); + relation->relname), + errdetail_untrusted_relation(relation))); } return relId; } @@ -896,13 +908,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 +1064,42 @@ 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. + * + * Also used by LookupTypeName, which resolves qualified names itself. + */ +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 +1347,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 +1675,118 @@ 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); +} + +/* + * errdetail_untrusted_relation + * Explain a failed relation lookup during an extension script, if a + * relation of that name exists but is not trusted. + */ +int +errdetail_untrusted_relation(const RangeVar *relation) +{ + Oid relid = InvalidOid; + + if (!creating_extension) + return 0; + + if (relation->schemaname) + { + Oid namespaceId = get_namespace_oid(relation->schemaname, true); + + if (OidIsValid(namespaceId)) + relid = get_relname_relid(relation->relname, namespaceId); + } + else + { + ListCell *l; + + recomputeNamespacePath(); + foreach(l, activeSearchPath) + { + relid = get_relname_relid(relation->relname, lfirst_oid(l)); + if (OidIsValid(relid)) + break; + } + } + + if (!OidIsValid(relid) || RelationIsTrustedInExtensionScript(relid)) + return 0; + + errdetail("A relation 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."); +} + +/* + * errdetail_untrusted_type + * Likewise for a type, given its schema (or NULL) and name. + */ +int +errdetail_untrusted_type(const char *schemaname, const char *typname) +{ + Oid typid = InvalidOid; + + if (!creating_extension) + return 0; + + if (schemaname) + { + Oid namespaceId = get_namespace_oid(schemaname, true); + + if (OidIsValid(namespaceId)) + typid = GetSysCacheOid2(TYPENAMENSP, Anum_pg_type_oid, + PointerGetDatum(typname), + ObjectIdGetDatum(namespaceId)); + } + else + { + ListCell *l; + + recomputeNamespacePath(); + foreach(l, activeSearchPath) + { + typid = GetSysCacheOid2(TYPENAMENSP, Anum_pg_type_oid, + PointerGetDatum(typname), + ObjectIdGetDatum(lfirst_oid(l))); + if (OidIsValid(typid)) + break; + } + } + + if (!OidIsValid(typid) || TypeIsTrustedInExtensionScript(typid)) + return 0; + + errdetail("A type 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."); +} + /* * MatchNamedCall * Given a pg_proc heap tuple and a call's list of argument names, @@ -1861,6 +2057,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 +2110,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 +2245,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 f8f6b179631..4874bf596b7 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/commands/functioncmds.c b/src/backend/commands/functioncmds.c index 4d46013d7de..b13f1e37bc1 100644 --- a/src/backend/commands/functioncmds.c +++ b/src/backend/commands/functioncmds.c @@ -140,7 +140,8 @@ compute_return_type(TypeName *returnType, Oid languageOid, ereport(ERROR, (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("type \"%s\" does not exist", - TypeNameToString(returnType)))); + TypeNameToString(returnType)), + errdetail_untrusted_typename(returnType))); } else { @@ -282,6 +283,7 @@ interpret_function_parameter_list(ParseState *pstate, (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("type %s does not exist", TypeNameToString(t)), + errdetail_untrusted_typename(t), parser_errposition(pstate, t->location))); toid = InvalidOid; /* keep compiler quiet */ } diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c index f75f5f2fbba..df181a3e9a7 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/parser/parse_relation.c b/src/backend/parser/parse_relation.c index 8aeb3e219ac..857652488f8 100644 --- a/src/backend/parser/parse_relation.c +++ b/src/backend/parser/parse_relation.c @@ -1467,7 +1467,8 @@ parserOpenTable(ParseState *pstate, const RangeVar *relation, LOCKMODE lockmode) ereport(ERROR, (errcode(ERRCODE_UNDEFINED_TABLE), errmsg("relation \"%s.%s\" does not exist", - relation->schemaname, relation->relname))); + relation->schemaname, relation->relname), + errdetail_untrusted_relation(relation))); else { /* @@ -1488,7 +1489,8 @@ parserOpenTable(ParseState *pstate, const RangeVar *relation, LOCKMODE lockmode) ereport(ERROR, (errcode(ERRCODE_UNDEFINED_TABLE), errmsg("relation \"%s\" does not exist", - relation->relname))); + relation->relname), + errdetail_untrusted_relation(relation))); } } cancel_parser_errposition_callback(&pcbstate); diff --git a/src/backend/parser/parse_type.c b/src/backend/parser/parse_type.c index 262f2742b02..ab34617ec4e 100644 --- a/src/backend/parser/parse_type.c +++ b/src/backend/parser/parse_type.c @@ -17,6 +17,7 @@ #include "access/htup_details.h" #include "catalog/namespace.h" #include "catalog/pg_type.h" +#include "commands/extension.h" #include "lib/stringinfo.h" #include "nodes/makefuncs.h" #include "parser/parse_type.h" @@ -178,9 +179,19 @@ LookupTypeNameExtended(ParseState *pstate, namespaceId = LookupExplicitNamespace(schemaname, missing_ok); if (OidIsValid(namespaceId)) + { typoid = GetSysCacheOid2(TYPENAMENSP, Anum_pg_type_oid, PointerGetDatum(typname), ObjectIdGetDatum(namespaceId)); + + /* + * Treat an untrusted match as nonexistent, as + * TypenameGetTypidExtended does for unqualified names. + */ + if (OidIsValid(typoid) && creating_extension && + !TypeIsTrustedInExtensionScript(typoid)) + typoid = InvalidOid; + } else typoid = InvalidOid; @@ -216,6 +227,22 @@ LookupTypeNameExtended(ParseState *pstate, return (Type) tup; } +/* + * errdetail_untrusted_type for a TypeName; %TYPE references name a column, + * not a type, so they get no detail. + */ +int +errdetail_untrusted_typename(const TypeName *typeName) +{ + char *schemaname; + char *typname; + + if (typeName->pct_type || !creating_extension) + return 0; + DeconstructQualifiedName(typeName->names, &schemaname, &typname); + return errdetail_untrusted_type(schemaname, typname); +} + /* * LookupTypeNameOid * Given a TypeName object, lookup the pg_type syscache entry of the type. @@ -242,6 +269,7 @@ LookupTypeNameOid(ParseState *pstate, const TypeName *typeName, bool missing_ok) (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("type \"%s\" does not exist", TypeNameToString(typeName)), + errdetail_untrusted_typename(typeName), parser_errposition(pstate, typeName->location))); return InvalidOid; @@ -271,6 +299,7 @@ typenameType(ParseState *pstate, const TypeName *typeName, int32 *typmod_p) (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("type \"%s\" does not exist", TypeNameToString(typeName)), + errdetail_untrusted_typename(typeName), parser_errposition(pstate, typeName->location))); if (!((Form_pg_type) GETSTRUCT(tup))->typisdefined) ereport(ERROR, @@ -799,7 +828,8 @@ parseTypeString(const char *str, Oid *typeid_p, int32 *typmod_p, ereturn(escontext, false, (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("type \"%s\" does not exist", - TypeNameToString(typeName)))); + TypeNameToString(typeName)), + errdetail_untrusted_typename(typeName))); } else { diff --git a/src/backend/utils/cache/plancache.c b/src/backend/utils/cache/plancache.c index a1b406cee29..a7322db2dc9 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..d9e5f53c9eb 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,12 @@ 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 TypeIsTrustedInExtensionScript(Oid typid); +extern int errdetail_untrusted_relation(const RangeVar *relation); +extern int errdetail_untrusted_type(const char *schemaname, + const char *typname); 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/parser/parse_type.h b/src/include/parser/parse_type.h index 79f10b5b5b2..f81a81ec6ba 100644 --- a/src/include/parser/parse_type.h +++ b/src/include/parser/parse_type.h @@ -29,6 +29,7 @@ extern Oid LookupTypeNameOid(ParseState *pstate, const TypeName *typeName, extern Type typenameType(ParseState *pstate, const TypeName *typeName, int32 *typmod_p); extern Oid typenameTypeId(ParseState *pstate, const TypeName *typeName); +extern int errdetail_untrusted_typename(const TypeName *typeName); extern void typenameTypeIdAndMod(ParseState *pstate, const TypeName *typeName, Oid *typeid_p, int32 *typmod_p); 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..5fe678f4be0 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,22 @@ 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_strict--7.0.sql \ + test_ext_overload_strict--8.0.sql \ + test_ext_overload_strict--9.0.sql \ + test_ext_overload_strict--10.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..392de3598b7 100644 --- a/src/test/modules/test_extensions/expected/test_extensions.out +++ b/src/test/modules/test_extensions/expected/test_extensions.out @@ -667,3 +667,351 @@ 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); +-- A planted table and domain, for the relation and type lookups. +CREATE TABLE test_overload.cfg (who text); +CREATE DOMAIN test_overload.planted_dom AS text; +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 +-- A planted relation or type is refused whether the script qualifies the +-- name with the schema or leaves it to search_path. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '7.0'; -- fails +ERROR: relation "test_overload.cfg" does not exist +LINE 1: INSERT INTO test_overload.cfg VALUES ('extension') + ^ +DETAIL: A relation 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: INSERT INTO test_overload.cfg VALUES ('extension') +CONTEXT: extension script file "test_ext_overload_strict--7.0.sql", near line 12 +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '8.0'; -- fails +ERROR: type test_overload.planted_dom does not exist +LINE 1: CREATE FUNCTION test_overload.g(test_overload.planted_dom) R... + ^ +DETAIL: A type 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 FUNCTION test_overload.g(test_overload.planted_dom) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE +CONTEXT: extension script file "test_ext_overload_strict--8.0.sql", near line 7 +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '9.0'; -- fails +ERROR: relation "cfg" does not exist +LINE 1: INSERT INTO cfg VALUES ('extension') + ^ +DETAIL: A relation 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: INSERT INTO cfg VALUES ('extension') +CONTEXT: extension script file "test_ext_overload_strict--9.0.sql", near line 7 +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '10.0'; -- fails +ERROR: type planted_dom does not exist +LINE 1: CREATE FUNCTION test_overload.g(planted_dom) RETURNS text + ^ +DETAIL: A type 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 FUNCTION test_overload.g(planted_dom) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE +CONTEXT: extension script file "test_ext_overload_strict--10.0.sql", near line 8 +-- The plants are untouched. +SELECT count(*) AS rows_in_plant FROM test_overload.cfg; + rows_in_plant +--------------- + 0 +(1 row) + +DROP EXTENSION test_ext_overload; +DROP SCHEMA test_overload CASCADE; +NOTICE: drop cascades to 11 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 cascades to table test_overload.cfg +drop cascades to type test_overload.planted_dom +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..13530a0530a 100644 --- a/src/test/modules/test_extensions/meson.build +++ b/src/test/modules/test_extensions/meson.build @@ -36,6 +36,28 @@ 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--7.0.sql', + 'test_ext_overload_strict--8.0.sql', + 'test_ext_overload_strict--9.0.sql', + 'test_ext_overload_strict--10.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..7d00317b18a 100644 --- a/src/test/modules/test_extensions/sql/test_extensions.sql +++ b/src/test/modules/test_extensions/sql/test_extensions.sql @@ -303,3 +303,197 @@ 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); +-- A planted table and domain, for the relation and type lookups. +CREATE TABLE test_overload.cfg (who text); +CREATE DOMAIN test_overload.planted_dom AS text; +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 +-- A planted relation or type is refused whether the script qualifies the +-- name with the schema or leaves it to search_path. +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '7.0'; -- fails +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '8.0'; -- fails +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '9.0'; -- fails +CREATE EXTENSION test_ext_overload_strict SCHEMA test_overload VERSION '10.0'; -- fails +-- The plants are untouched. +SELECT count(*) AS rows_in_plant FROM test_overload.cfg; +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 @[email protected](varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE FUNCTION @[email protected](varchar, varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE OPERATOR @extschema@.### (leftarg = varchar, rightarg = varchar, + function = @[email protected]); + +CREATE TABLE @[email protected] AS + SELECT @[email protected]('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 @[email protected] 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 @[email protected](varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE TABLE @[email protected]_nosuper AS + SELECT @[email protected]('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 @[email protected](varchar) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; + +CREATE FUNCTION @[email protected]() RETURNS text + LANGUAGE plpgsql PARALLEL SAFE AS $$ BEGIN RETURN probe('x'); END $$; + +CREATE TABLE @[email protected] AS SELECT @[email protected]() 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 @[email protected] 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 @[email protected] USING btree; +ALTER OPERATOR FAMILY @[email protected] 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 @[email protected](int) RETURNS text + AS $$ SELECT 'required'::text $$ LANGUAGE sql IMMUTABLE; + +CREATE FUNCTION @[email protected](int, int) RETURNS boolean + AS $$ SELECT true $$ LANGUAGE sql IMMUTABLE; + +CREATE DOMAIN @[email protected] AS text; + +CREATE TABLE @[email protected](t text); + +-- Parsed at run time, so a call before the dependent script caches a +-- resolution made without trust checks. +CREATE FUNCTION @[email protected]() RETURNS text + LANGUAGE plpgsql AS $$ BEGIN RETURN reqcall(1); END $$; + +CREATE OPERATOR @extschema@.=== (leftarg = integer, rightarg = integer, + function = @[email protected]); 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 @[email protected] AS + SELECT @[email protected]_only('abc') AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--10.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--10.0.sql new file mode 100644 index 00000000000..accce6106a7 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--10.0.sql @@ -0,0 +1,9 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--10.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 8.0, unqualified: resolved through search_path by +-- TypenameGetTypidExtended. +CREATE FUNCTION @[email protected](planted_dom) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; 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 @[email protected] 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 = @[email protected], + 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 @[email protected] AS + SELECT @[email protected](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 @[email protected] 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 @[email protected] AS + SELECT ('a' &&& 'b') AS r; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--7.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--7.0.sql new file mode 100644 index 00000000000..44803f02f02 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--7.0.sql @@ -0,0 +1,12 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--7.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 + +-- A planted table reached by its schema-qualified name, as an upgrade script +-- would reach a table it expects from an earlier version. (CREATE ... IF NOT +-- EXISTS over the plant is already refused on membership grounds, so the +-- reference must be a bare one.) The INSERT would write the extension's data +-- into the attacker's table; the qualified lookup must refuse it as "does not +-- exist", exactly as the unqualified one does. +INSERT INTO @[email protected] VALUES ('extension'); diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--8.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--8.0.sql new file mode 100644 index 00000000000..a01aa1ddab4 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--8.0.sql @@ -0,0 +1,8 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--8.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 + +-- A planted domain reached by its schema-qualified name in a signature. +CREATE FUNCTION @[email protected](@[email protected]_dom) RETURNS text + AS $$ SELECT 'extension'::text $$ LANGUAGE sql IMMUTABLE; diff --git a/src/test/modules/test_extensions/test_ext_overload_strict--9.0.sql b/src/test/modules/test_extensions/test_ext_overload_strict--9.0.sql new file mode 100644 index 00000000000..be796a24211 --- /dev/null +++ b/src/test/modules/test_extensions/test_ext_overload_strict--9.0.sql @@ -0,0 +1,7 @@ +/* src/test/modules/test_extensions/test_ext_overload_strict--9.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 7.0, unqualified: resolved through search_path by RelnameGetRelid. +INSERT INTO cfg VALUES ('extension'); 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
