From bf3842a64f5df489fab31b82aaeb9067ac938fde Mon Sep 17 00:00:00 2001 From: Jacob Champion Date: Mon, 10 Aug 2026 06:38:36 -0700 Subject: [PATCH] Add an output_plugin_libraries GUC to bless trusted output plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REPLICATION users were not previously subject to restrictions on output plugin paths, so they were able to bypass LOAD-time protections during logical decoding. Unfortunately, adding the standard LOAD restrictions now would retroactively require all third-party output plugins to be installed under the $libdir/plugins directory. This would prevent the use of dynamic_library_path, introduce a wire incompatibility for clients, and require all plugin authors to check that their libraries are safe for use by any unprivileged user; we want to avoid that. Instead, introduce an output_plugin_libraries GUC so that DBAs can specify the output plugins that are trusted for use in logical decoding. For simplicity, superusers are subject to the restriction as well (though they're free to modify the GUC at will during a session, so no power is actually lost). The default setting is 'pgoutput, test_decoding'. If other third-party plugins are in use, DBAs will need to modify this parameter after they update. Some pointers have been added to the documentation to assist with this. Author: Jacob Champion Reported-by: Vladimir Tokarev Reported-by: Yu Kunpeng Reviewed-by: Álvaro Herrera Reviewed-by: Noah Misch Reviewed-by: Robert Haas Reviewed-by: Tom Lane Backpatch-through: 14 Security: CVE-2026-6471 --- .../test_decoding/expected/permissions.out | 11 +++ contrib/test_decoding/expected/slot.out | 3 + contrib/test_decoding/sql/permissions.sql | 8 ++ contrib/test_decoding/sql/slot.sql | 3 + doc/src/sgml/config.sgml | 57 ++++++++++++++ doc/src/sgml/logical-replication.sgml | 9 +++ src/backend/replication/logical/logical.c | 76 ++++++++++++++++++- src/backend/utils/misc/guc.c | 12 +++ src/backend/utils/misc/postgresql.conf.sample | 1 + src/bin/pg_dump/dumputils.c | 1 + src/include/replication/logical.h | 3 + src/test/subscription/t/100_bugs.pl | 35 ++++++++- 12 files changed, 215 insertions(+), 4 deletions(-) diff --git a/contrib/test_decoding/expected/permissions.out b/contrib/test_decoding/expected/permissions.out index ed97f81dda5..9b0670268ee 100644 --- a/contrib/test_decoding/expected/permissions.out +++ b/contrib/test_decoding/expected/permissions.out @@ -29,6 +29,17 @@ SELECT pg_drop_replication_slot('regression_slot'); (1 row) RESET ROLE; +-- no users can load an untrusted plugin +SET output_plugin_libraries = pgoutput; +SET ROLE regress_lr_replication; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); +ERROR: library "test_decoding" may not be used as an output plugin +HINT: If it is safe for all REPLICATION users to use this library as an output plugin, add it to "output_plugin_libraries" and reload the server configuration. +RESET ROLE; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); +ERROR: library "test_decoding" may not be used as an output plugin +HINT: If it is safe for all REPLICATION users to use this library as an output plugin, add it to "output_plugin_libraries" and reload the server configuration. +RESET output_plugin_libraries; -- replication user can control replication SET ROLE regress_lr_replication; SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); diff --git a/contrib/test_decoding/expected/slot.out b/contrib/test_decoding/expected/slot.out index 75b4b5cc625..217c45eae96 100644 --- a/contrib/test_decoding/expected/slot.out +++ b/contrib/test_decoding/expected/slot.out @@ -30,8 +30,11 @@ SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot_t2', 'tes init (1 row) +BEGIN; +SET LOCAL output_plugin_libraries = nonexistent; SELECT pg_create_logical_replication_slot('foo', 'nonexistent'); ERROR: could not access file "nonexistent": No such file or directory +ROLLBACK; -- here we want to start a new session and wait till old one is gone select pg_backend_pid() as oldpid \gset \c - diff --git a/contrib/test_decoding/sql/permissions.sql b/contrib/test_decoding/sql/permissions.sql index 312b5145937..fee36f5a096 100644 --- a/contrib/test_decoding/sql/permissions.sql +++ b/contrib/test_decoding/sql/permissions.sql @@ -15,6 +15,14 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc SELECT pg_drop_replication_slot('regression_slot'); RESET ROLE; +-- no users can load an untrusted plugin +SET output_plugin_libraries = pgoutput; +SET ROLE regress_lr_replication; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); +RESET ROLE; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); +RESET output_plugin_libraries; + -- replication user can control replication SET ROLE regress_lr_replication; SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); diff --git a/contrib/test_decoding/sql/slot.sql b/contrib/test_decoding/sql/slot.sql index 6d83fb26782..0a1398726ee 100644 --- a/contrib/test_decoding/sql/slot.sql +++ b/contrib/test_decoding/sql/slot.sql @@ -9,7 +9,10 @@ SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot_p', 'test SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot_t2', 'test_decoding', true); +BEGIN; +SET LOCAL output_plugin_libraries = nonexistent; SELECT pg_create_logical_replication_slot('foo', 'nonexistent'); +ROLLBACK; -- here we want to start a new session and wait till old one is gone select pg_backend_pid() as oldpid \gset diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index cd625271ea8..b98194a624f 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -4132,6 +4132,63 @@ restore_command = 'copy "C:\\server\\archivedir\\%f" "%p"' # Windows + + output_plugin_libraries (string) + + output_plugin_libraries configuration parameter + + + + + Lists the libraries installed in + that are also trusted for use as logical output plugins by replication + clients. Any logical decoding + or replication requests for + other libraries will be refused. All users are subject to this + restriction. The default is 'pgoutput, test_decoding', + which are the two logical output plugins included in the standard + PostgreSQL distribution. + + + The format is a comma-separated list of library names, where each name + is interpreted as for the LOAD + command (but logical decoding clients must specify a plugin name that + exactly matches an entry in the list, without + variations in case or path structure). Whitespace between entries is + ignored; surround a library name with double quotes if you need to + include whitespace or commas in the name. + + + It is the responsibility of the server administrator to ensure that + libraries added to this list do not unintentionally give additional + privileges to non-superusers when they are loaded into the server. + + + + When updating the server from a version that does not have the + output_plugin_libraries parameter, the following + query can help construct the list of plugins that are required by all + persistent logical replication slots: + +SELECT DISTINCT plugin FROM pg_replication_slots WHERE plugin IS NOT NULL; + + Review the list carefully for safety before adjusting + output_plugin_libraries. + + + The above query can only display plugins which were successfully + added to replication slots at some point in the past. Newly refused + requests will appear in the logs with a message similar to + +ERROR: library "..." may not be used as an output plugin +DETAIL: The configuration parameter "output_plugin_libraries" (currently 'pgoutput, test_decoding') does not name this library as a trusted output plugin. +HINT: If it is safe for all REPLICATION users to use this library as an output plugin, add it to "output_plugin_libraries" and reload the server configuration. + + + + + + wal_keep_size (integer) diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml index 57e2d3e7573..873c1d5985d 100644 --- a/doc/src/sgml/logical-replication.sgml +++ b/doc/src/sgml/logical-replication.sgml @@ -556,6 +556,15 @@ and it must have the LOGIN attribute. + + The name of the output plugin used by the replication connection must be + included in the server's . (For + subscriptions, the plugin name that is used is pgoutput.) + Superusers may modify the trusted list per-connection, by including + options=-coutput_plugin_libraries=... in the connection + string. + + In order to be able to copy the initial table data, the role used for the replication connection must have the SELECT privilege on diff --git a/src/backend/replication/logical/logical.c b/src/backend/replication/logical/logical.c index 39bcf844694..a7efca27512 100644 --- a/src/backend/replication/logical/logical.c +++ b/src/backend/replication/logical/logical.c @@ -41,7 +41,9 @@ #include "storage/proc.h" #include "storage/procarray.h" #include "utils/builtins.h" +#include "utils/guc.h" #include "utils/memutils.h" +#include "utils/varlena.h" /* data for errcontext callback */ typedef struct LogicalErrorCallbackState @@ -51,6 +53,9 @@ typedef struct LogicalErrorCallbackState XLogRecPtr report_location; } LogicalErrorCallbackState; +/* GUC variables */ +char *output_plugin_libraries_string; + /* wrappers around output plugin callbacks */ static void output_plugin_error_callback(void *arg); static void startup_cb_wrapper(LogicalDecodingContext *ctx, OutputPluginOptions *opt, @@ -175,7 +180,72 @@ StartupDecodingContext(List *output_plugin_options, * now. */ if (!fast_forward) - LoadOutputPlugin(&ctx->callbacks, NameStr(slot->data.plugin)); + { + /* + * Before loading this library, make sure it's been blessed for + * logical decoding. + */ + const char *plugin = NameStr(slot->data.plugin); + bool plugin_allowed = false; + + if (output_plugin_libraries_string && output_plugin_libraries_string[0]) + { + /* Check this plugin against output_plugin_libraries. */ + char *rawstring; + List *elemlist = NIL; + ListCell *lc; + + /* Need a modifiable copy */ + rawstring = pstrdup(output_plugin_libraries_string); + + if (!SplitGUCList(rawstring, ',', &elemlist)) + { + /* syntax error in list */ + ereport(LOG, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("invalid list syntax in parameter \"%s\"", + "output_plugin_libraries"))); + + list_free(elemlist); + elemlist = NIL; + } + + foreach(lc, elemlist) + { + char *allowed = lfirst(lc); + + if (strcmp(allowed, plugin) == 0) + { + plugin_allowed = true; + break; + } + } + + list_free(elemlist); + pfree(rawstring); + } + + if (!plugin_allowed) + { + /* + * Use the same error message as check_restricted_library_name(), + * but provide additional context for the DBA in the logs. (The + * HINT will be sent to the client, but that's not a secret.) + */ + ereport(ERROR, + errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("library \"%s\" may not be used as an output plugin", + plugin), + /*- translator: The second %s is the value of the output_plugin_libraries GUC, which may contain whitespace, commas, and double-quotes (") */ + errdetail_log("The configuration parameter \"%s\" (currently '%s') does not name this library as a trusted output plugin.", + "output_plugin_libraries", + output_plugin_libraries_string), + errhint("If it is safe for all REPLICATION users to use this library as an output plugin, add it to \"%s\" and reload the server configuration.", + "output_plugin_libraries")); + } + + LoadOutputPlugin(&ctx->callbacks, plugin); + } /* * Now that the slot's xmin has been set, we can announce ourselves as a @@ -667,7 +737,9 @@ OutputPluginUpdateProgress(struct LogicalDecodingContext *ctx) /* * Load the output plugin, lookup its output plugin init function, and check - * that it provides the required callbacks. + * that it provides the required callbacks. The caller must have checked that + * the current user has the necessary privileges to load the given plugin; + * standard LOAD restrictions are not applied here. */ static void LoadOutputPlugin(OutputPluginCallbacks *callbacks, const char *plugin) diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c index 5ba6a886e52..26e0a311e89 100644 --- a/src/backend/utils/misc/guc.c +++ b/src/backend/utils/misc/guc.c @@ -74,6 +74,7 @@ #include "postmaster/postmaster.h" #include "postmaster/syslogger.h" #include "postmaster/walwriter.h" +#include "replication/logical.h" #include "replication/logicallauncher.h" #include "replication/reorderbuffer.h" #include "replication/slot.h" @@ -4613,6 +4614,17 @@ static struct config_string ConfigureNamesString[] = check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL }, + { + {"output_plugin_libraries", PGC_SUSET, REPLICATION_SENDING, + gettext_noop("Lists libraries that may be named as logical decoding output plugins."), + gettext_noop("Users with REPLICATION privileges may only use plugins in this list when creating logical replication slots."), + GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY + }, + &output_plugin_libraries_string, + "pgoutput, test_decoding", + NULL, NULL, NULL + }, + /* End-of-list marker */ { {NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 618cdabcecc..a0a0c573927 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -727,6 +727,7 @@ #dynamic_library_path = '$libdir' #gin_fuzzy_search_limit = 0 +#output_plugin_libraries = 'pgoutput, test_decoding' # approved plugins for logical decoding #------------------------------------------------------------------------------ diff --git a/src/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c index 1e24e536f02..86a01e4391a 100644 --- a/src/bin/pg_dump/dumputils.c +++ b/src/bin/pg_dump/dumputils.c @@ -887,6 +887,7 @@ bool variable_is_guc_list_quote(const char *name) { if (pg_strcasecmp(name, "local_preload_libraries") == 0 || + pg_strcasecmp(name, "output_plugin_libraries") == 0 || pg_strcasecmp(name, "search_path") == 0 || pg_strcasecmp(name, "session_preload_libraries") == 0 || pg_strcasecmp(name, "shared_preload_libraries") == 0 || diff --git a/src/include/replication/logical.h b/src/include/replication/logical.h index daf458cf989..190bc8de0b1 100644 --- a/src/include/replication/logical.h +++ b/src/include/replication/logical.h @@ -140,4 +140,7 @@ extern bool filter_by_origin_cb_wrapper(LogicalDecodingContext *ctx, RepOriginId extern void ResetLogicalStreamingState(void); extern void UpdateDecodingStats(LogicalDecodingContext *ctx); +/* GUCs */ +extern PGDLLIMPORT char *output_plugin_libraries_string; + #endif diff --git a/src/test/subscription/t/100_bugs.pl b/src/test/subscription/t/100_bugs.pl index 81ff79fce46..d02cf2f2a2d 100644 --- a/src/test/subscription/t/100_bugs.pl +++ b/src/test/subscription/t/100_bugs.pl @@ -6,7 +6,7 @@ use strict; use warnings; use PostgresNode; use TestLib; -use Test::More tests => 12; +use Test::More tests => 14; # Bug #15114 @@ -363,6 +363,37 @@ is( $node_subscriber_d_cols->safe_psql( $node_publisher_d_cols->stop('fast'); $node_subscriber_d_cols->stop('fast'); +$node_publisher->rotate_logfile(); +$node_publisher->start(); + +my $publisher_host = $node_publisher->host; +my $publisher_port = $node_publisher->port; +my $connstr_db = + "host=$publisher_host port=$publisher_port replication=database dbname=postgres"; + +# REPLICATION users should not be able to bypass LOAD restrictions. +$node_publisher->safe_psql( + 'postgres', qq( + CREATE USER repluser REPLICATION; +)); + +my ($ret, $stdout, $stderr) = $node_publisher->psql( + 'postgres', + qq[ + SET ROLE repluser; + CREATE_REPLICATION_SLOT fail_slot LOGICAL regress;', + ], + timeout => $PostgreSQL::Test::Utils::timeout_default, + extra_params => [ '-d', $connstr_db ]); + +is($ret, 3, 'loading unblessed output plugin fails'); +like( + $stderr, + qr/ERROR: library "regress" may not be used as an output plugin/, + 'loading unblessed output plugin fails: stderr'); + +$node_publisher->stop('fast'); + # BUG #18988 # The bug happened due to a self-deadlock between the DROP SUBSCRIPTION # command and the walsender process for accessing pg_subscription. This @@ -380,7 +411,7 @@ $node_publisher->safe_psql( CREATE SUBSCRIPTION regress_sub1 CONNECTION '$publisher_connstr' PUBLICATION regress_pub WITH (connect=false); )); -my ($ret, $stdout, $stderr) = +($ret, $stdout, $stderr) = $node_publisher->psql('postgres', q{DROP SUBSCRIPTION regress_sub1}); isnt($ret, 0, "replication slot does not exist: exit code not 0");