commit 0ae3d2dfdbfe38aa32b7acb02784df4ba9c6708b
Author: Christoph Berg <myon@debian.org>
Date:   Wed Jul 8 16:34:33 2026 +0200

    Reject substituting extension schemas or owners matching ["$'\].

    The extwlist.custom_path script mechanism was vulnerable to SQL
    injection via crafted schema and user names. The same problem was fixed
    in August 2023 in PostgreSQL (cd5f2a3570), and is now fixed in
    pgextwlist as well. Substitutions that attempt to insert any of "$'\ are
    now simply rejected. (We don't try to quote the values as we don't know
    which quoting context we are in.)

    Reported by Guillaume Winter.

    Security: CVE-2023-39417

--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ long_ver = $(shell (git describe --tags
 MODULE_big = pgextwlist
 OBJS       = utils.o pgextwlist.o
 DOCS       = README.md
-REGRESS    = setup pgextwlist errors crossuser hooks
+REGRESS    = setup pgextwlist errors crossuser hooks substitution_safety
 RPM_MINOR_VERSION_SUFFIX ?=

 PG_CONFIG = pg_config
--- /dev/null
+++ b/expected/substitution_safety.out
@@ -0,0 +1,56 @@
+-- CVE-2023-39417 mitigation: @-substitution values containing characters
+-- that could enable SQL injection (" $ ' \) must be rejected before the
+-- replacement is inserted into the script. Verify each forbidden char.
+-- pg_stat_statements is whitelisted in setup.sql and has a custom after-create
+-- script under test-scripts/, so CREATE EXTENSION always reaches
+-- execute_custom_script and the substitution check.
+-- 1. Single quote in schema name.
+CREATE SCHEMA "weird'name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird'name";
+ERROR:  invalid value for "@extschema@" substitution
+DETAIL:  Value must not contain any of: "$'\
+HINT:  Rename the object referenced by @extschema@.
+RESET ROLE;
+DROP SCHEMA "weird'name";
+-- 2. Dollar sign in schema name.
+CREATE SCHEMA "weird$name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird$name";
+ERROR:  invalid value for "@extschema@" substitution
+DETAIL:  Value must not contain any of: "$'\
+HINT:  Rename the object referenced by @extschema@.
+RESET ROLE;
+DROP SCHEMA "weird$name";
+-- 3. Backslash in schema name.
+CREATE SCHEMA "weird\name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird\name";
+ERROR:  invalid value for "@extschema@" substitution
+DETAIL:  Value must not contain any of: "$'\
+HINT:  Rename the object referenced by @extschema@.
+RESET ROLE;
+DROP SCHEMA "weird\name";
+-- 4. Double quote in schema name (embedded via SQL "" doubling).
+CREATE SCHEMA "weird""name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird""name";
+ERROR:  invalid value for "@extschema@" substitution
+DETAIL:  Value must not contain any of: "$'\
+HINT:  Rename the object referenced by @extschema@.
+RESET ROLE;
+DROP SCHEMA "weird""name";
+-- Sanity: a plain schema name should not trip the CVE check. Clean up any
+-- leftovers from earlier regressions in the same cluster first so the
+-- after-create script can run cleanly.
+DROP EXTENSION IF EXISTS pg_stat_statements;
+NOTICE:  extension "pg_stat_statements" does not exist, skipping
+DROP ROLE IF EXISTS stat_resetters;
+NOTICE:  role "stat_resetters" does not exist, skipping
+CREATE SCHEMA ok_schema;
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA ok_schema;
+RESET ROLE;
+DROP EXTENSION pg_stat_statements;
+DROP ROLE stat_resetters;
+DROP SCHEMA ok_schema;
--- /dev/null
+++ b/sql/substitution_safety.sql
@@ -0,0 +1,48 @@
+-- CVE-2023-39417 mitigation: @-substitution values containing characters
+-- that could enable SQL injection (" $ ' \) must be rejected before the
+-- replacement is inserted into the script. Verify each forbidden char.
+
+-- pg_stat_statements is whitelisted in setup.sql and has a custom after-create
+-- script under test-scripts/, so CREATE EXTENSION always reaches
+-- execute_custom_script and the substitution check.
+
+-- 1. Single quote in schema name.
+CREATE SCHEMA "weird'name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird'name";
+RESET ROLE;
+DROP SCHEMA "weird'name";
+
+-- 2. Dollar sign in schema name.
+CREATE SCHEMA "weird$name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird$name";
+RESET ROLE;
+DROP SCHEMA "weird$name";
+
+-- 3. Backslash in schema name.
+CREATE SCHEMA "weird\name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird\name";
+RESET ROLE;
+DROP SCHEMA "weird\name";
+
+-- 4. Double quote in schema name (embedded via SQL "" doubling).
+CREATE SCHEMA "weird""name";
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA "weird""name";
+RESET ROLE;
+DROP SCHEMA "weird""name";
+
+-- Sanity: a plain schema name should not trip the CVE check. Clean up any
+-- leftovers from earlier regressions in the same cluster first so the
+-- after-create script can run cleanly.
+DROP EXTENSION IF EXISTS pg_stat_statements;
+DROP ROLE IF EXISTS stat_resetters;
+CREATE SCHEMA ok_schema;
+SET ROLE mere_mortal;
+CREATE EXTENSION pg_stat_statements WITH SCHEMA ok_schema;
+RESET ROLE;
+DROP EXTENSION pg_stat_statements;
+DROP ROLE stat_resetters;
+DROP SCHEMA ok_schema;
--- a/utils.c
+++ b/utils.c
@@ -539,6 +539,34 @@ get_current_database_owner_name()
 }

 /*
+ * Reject @-substitution values containing characters that could be used to
+ * break out of quoting in the resulting script (CVE-2023-39417; upstream
+ * fix cd5f2a3570). The check is applied to the raw, unquoted name because
+ * quote_identifier() output always contains '"', which would trip the
+ * check spuriously.
+ */
+static void
+check_substitution_value(const char *placeholder, const char *value)
+{
+	static const char forbidden[] = "\"$'\\";
+
+	if (value == NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
+				 errmsg("invalid value for \"%s\" substitution",
+						placeholder)));
+
+	if (strpbrk(value, forbidden) != NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
+				 errmsg("invalid value for \"%s\" substitution",
+						placeholder),
+				 errdetail("Value must not contain any of: %s", forbidden),
+				 errhint("Rename the object referenced by %s.",
+						 placeholder)));
+}
+
+/*
  * Execute given script
  */
 void
@@ -546,7 +574,6 @@ execute_custom_script(const char *filena
 {
 	int			save_nestlevel;
 	StringInfoData pathbuf;
-	const char *qSchemaName = quote_identifier(schemaName);

 	elog(DEBUG1, "Executing custom script \"%s\"", filename);

@@ -631,35 +658,40 @@ execute_custom_script(const char *filena
 		/*
 		 * substitute the target schema name for occurrences of @extschema@.
 		 */
+		check_substitution_value("@extschema@", schemaName);
 		t_sql = DirectFunctionCall3Coll(replace_text,
 									C_COLLATION_OID,
 									t_sql,
 									CStringGetTextDatum("@extschema@"),
-									CStringGetTextDatum(qSchemaName));
+									CStringGetTextDatum(schemaName));

 		/*
 		 * substitute the current user name for occurrences of @current_user@
 		 */
-		t_sql = DirectFunctionCall3Coll(replace_text,
-									C_COLLATION_OID,
-									t_sql,
-									CStringGetTextDatum("@current_user@"),
-									CStringGetTextDatum(
-										GetUserNameFromId(GetUserId()
-#if PG_MAJOR_VERSION >= 905
-														  , false
-#endif
-										)));
+		{
+			char	   *cur_user = GetUserNameFromId(GetUserId(), false);
+
+			check_substitution_value("@current_user@", cur_user);
+			t_sql = DirectFunctionCall3Coll(replace_text,
+											C_COLLATION_OID,
+											t_sql,
+											CStringGetTextDatum("@current_user@"),
+											CStringGetTextDatum(cur_user));
+		}

 		/*
 		 * substitute the database owner for occurrences of @database_owner@
 		 */
-		t_sql = DirectFunctionCall3Coll(replace_text,
-									C_COLLATION_OID,
-									t_sql,
-									CStringGetTextDatum("@database_owner@"),
-									CStringGetTextDatum(
-										get_current_database_owner_name()));
+		{
+			char	   *db_owner = get_current_database_owner_name();
+
+			check_substitution_value("@database_owner@", db_owner);
+			t_sql = DirectFunctionCall3Coll(replace_text,
+											C_COLLATION_OID,
+											t_sql,
+											CStringGetTextDatum("@database_owner@"),
+											CStringGetTextDatum(db_owner));
+		}

 		/* And now back to C string */
 		c_sql = text_to_cstring(DatumGetTextPP(t_sql));
