From d1dee1c6702e35a4dfbfb45291bdf020192de8d2 Mon Sep 17 00:00:00 2001 From: Nathan Bossart Date: Mon, 11 Aug 2025 09:00:00 -0500 Subject: [PATCH 1/3] Restrict psql meta-commands in plain-text dumps. A malicious server could inject psql meta-commands into plain-text dump output (i.e., scripts created with pg_dump --format=plain, pg_dumpall, or pg_restore --file) that are run at restore time on the machine running psql. To fix, introduce a new "restricted" mode in psql that blocks all meta-commands (except for \unrestrict to exit the mode), and teach pg_dump, pg_dumpall, and pg_restore to use this mode in plain-text dumps. While at it, encourage users to only restore dumps generated from trusted servers or to inspect it beforehand, since restoring causes the destination to execute arbitrary code of the source superusers' choice. However, the client running the dump and restore needn't trust the source or destination superusers. Reported-by: Martin Rakhmanov Reported-by: Matthieu Denais Reported-by: RyotaK Suggested-by: Tom Lane Reviewed-by: Noah Misch Reviewed-by: Michael Paquier Reviewed-by: Peter Eisentraut Security: CVE-2025-8714 Backpatch-through: 13 (cherry picked from commit 42404050685de4047a7b57b13f8ec4959666328a) Conflicts resolved: - doc/src/sgml/ref/pg_restore.sgml: - the option list, between --no-tablespaces and --section: - Upstream inserts a --restrict-key=restrict_key entry there. - YB 2a5f8530b4 cherry-picks master commit 1fd1bd8710, which inserts --with-data, --with-schema and --with-statistics entries starting at the same opening tag, so the two share an unclosed element. - Keep both: YB's three entries first, then upstream's --restrict-key entry, each in its own . - src/bin/pg_dump/pg_backup.h: - last member of RestoreOptions and of _dumpOptions: - Upstream appends "char *restrict_key;" as the final member of each struct. - YB 2a5f8530b4 appends a "flags derived from the user-settable flags" block (dumpSchema, dumpData, dumpStatistics) at the same position in both structs. - Keep both blocks, YB's first and restrict_key last. - src/bin/pg_dump/pg_dump.c: - the long-option switch in main(): - Upstream adds "case 25:" for --restrict-key immediately after case 11. - YB 2a5f8530b4 and b84ef4461a fill that gap with cases 18 through 24 for the statistics-related options. - Keep YB's cases 18-24 and append upstream's case 25 after them; option code 25 does not collide, as YB's highest is 24. - src/bin/pg_dump/pg_dumpall.c: - last entry of long_options[]: - Upstream adds {"restrict-key", required_argument, NULL, 9}. - YB 2a5f8530b4 adds {"statistics-only", no_argument, &statistics_only, 1} on the same final line. - Keep both; code 9 is free, as YB's numeric codes stop at 7. - src/bin/pg_dump/pg_restore.c: - last entries of long_options[]: - Upstream adds {"restrict-key", required_argument, NULL, 6}. - YB b84ef4461a and 2a5f8530b4 add no-statistics, with-data, with-schema, with-statistics and statistics-only in the same slot. - Keep both; code 6 is free, as YB's numeric codes stop at 3. - src/test/recovery/t/027_stream_regress.pl: - the pg_dumpall argument lists for the primary and standby dumps: - Upstream adds '--restrict-key=test' before '--no-sync' so the two dumps compare byte for byte. - YB 2a5f8530b4 adds '--no-statistics' onto the same line as '--no-sync' and '-p'. - Keep both arguments, reflowed onto separate lines. - src/bin/pg_dump/t/002_pg_dump.pl: - the test-definition validation loop: - Upstream adds two %tests entries, "restrict" and "unrestrict", that set "all_runs => 1" and define no "like" list, and teaches the run loop to honour "all_runs". The REL_15_STABLE back-patch stops there, because upstream REL_15_STABLE has no check that requires "like". That check exists only on master. 41a284411e "Clean up some pg_dump tests" added it as a test cleanup, and upstream never back-patched it; no commit on REL_15_STABLE has ever introduced it. - yb-pg15 is REL_15_STABLE plus selected master cherry-picks, and e9c1b29843 is one of them: YB's cherry-pick of 41a284411e. yb-pg15 therefore does carry the check, and it dies with 'missing "like" in test ""' for any entry that defines no "like" list. The two edits sit about 3,800 lines apart and never touch the same lines, so git merged them with no textual conflict. The breakage surfaced only when 002_pg_dump.pl ran and died. - Take the corresponding hunk from master commit 71ea0d6795, the master version of this same fix, which relaxes the check to accept "all_runs" in place of "like". Master's changes to this file and the back-patch's are otherwise identical. The hunk is missing from the back-patch only because REL_15_STABLE had no check to relax, and yb-pg15 has one. It is the only part of 71ea0d6795 that applies here. The rest is master-only: src/bin/pg_combinebackup (absent in 15); src/bin/psql/tab-complete.in.c (master renamed the file, and 15 has tab-complete.c, which the back-patch already covers); two attributes in psql-ref.sgml; an #include "dumputils.h" that pg_restore.c already has on this branch; and one extra '--restrict-key=test' argument to the pg_dumpall call in pg_upgrade's TAP test. --- doc/src/sgml/ref/pg_dump.sgml | 35 +++++++++ doc/src/sgml/ref/pg_dumpall.sgml | 30 ++++++++ doc/src/sgml/ref/pg_restore.sgml | 34 ++++++++ doc/src/sgml/ref/pgupgrade.sgml | 8 ++ doc/src/sgml/ref/psql-ref.sgml | 36 +++++++++ src/bin/pg_dump/dumputils.c | 38 +++++++++ src/bin/pg_dump/dumputils.h | 3 + src/bin/pg_dump/pg_backup.h | 4 + src/bin/pg_dump/pg_backup_archiver.c | 32 +++++++- src/bin/pg_dump/pg_dump.c | 21 +++++ src/bin/pg_dump/pg_dumpall.c | 36 +++++++++ src/bin/pg_dump/pg_restore.c | 22 ++++++ src/bin/pg_dump/t/002_pg_dump.pl | 26 +++++-- src/bin/pg_upgrade/t/002_pg_upgrade.pl | 2 + src/bin/psql/command.c | 94 ++++++++++++++++++++++- src/bin/psql/help.c | 4 + src/bin/psql/t/001_basic.pl | 7 ++ src/bin/psql/tab-complete.c | 4 +- src/test/recovery/t/027_stream_regress.pl | 10 ++- src/test/regress/expected/psql.out | 2 + src/test/regress/sql/psql.sql | 2 + 21 files changed, 435 insertions(+), 15 deletions(-) diff --git a/doc/src/sgml/ref/pg_dump.sgml b/doc/src/sgml/ref/pg_dump.sgml index b84cde2f8917d..b7e34b82e24ae 100644 --- a/doc/src/sgml/ref/pg_dump.sgml +++ b/doc/src/sgml/ref/pg_dump.sgml @@ -92,6 +92,18 @@ PostgreSQL documentation light of the limitations listed below. + + + Restoring a dump causes the destination to execute arbitrary code of the + source superusers' choice. Partial dumps and partial restores do not limit + that. If the source superusers are not trusted, the dumped SQL statements + must be inspected before restoring. Non-plain-text dumps can be inspected + by using pg_restore's + option. Note that the client running the dump and restore need not trust + the source or destination superusers. + + + @@ -1070,6 +1082,29 @@ PostgreSQL documentation + + + + + Use the provided string as the psql + \restrict key in the dump output. This can only be + specified for plain-text dumps, i.e., when is + set to plain or the option + is omitted. If no restrict key is specified, + pg_dump will generate a random one as + needed. Keys may contain only alphanumeric characters. + + + This option is primarily intended for testing purposes and other + scenarios that require repeatable output (e.g., comparing dump files). + It is not recommended for general use, as a malicious server with + advance knowledge of the key may be able to inject arbitrary code that + will be executed on the machine that runs + psql with the dump output. + + + + diff --git a/doc/src/sgml/ref/pg_dumpall.sgml b/doc/src/sgml/ref/pg_dumpall.sgml index 55ac2e7b1e061..6fe192dc149f1 100644 --- a/doc/src/sgml/ref/pg_dumpall.sgml +++ b/doc/src/sgml/ref/pg_dumpall.sgml @@ -66,6 +66,16 @@ PostgreSQL documentation linkend="libpq-pgpass"/> for more information. + + + Restoring a dump causes the destination to execute arbitrary code of the + source superusers' choice. Partial dumps and partial restores do not limit + that. If the source superusers are not trusted, the dumped SQL statements + must be inspected before restoring. Note that the client running the dump + and restore need not trust the source or destination superusers. + + + @@ -588,6 +598,26 @@ PostgreSQL documentation + + + + + Use the provided string as the psql + \restrict key in the dump output. If no restrict + key is specified, pg_dumpall will generate a + random one as needed. Keys may contain only alphanumeric characters. + + + This option is primarily intended for testing purposes and other + scenarios that require repeatable output (e.g., comparing dump files). + It is not recommended for general use, as a malicious server with + advance knowledge of the key may be able to inject arbitrary code that + will be executed on the machine that runs + psql with the dump output. + + + + diff --git a/doc/src/sgml/ref/pg_restore.sgml b/doc/src/sgml/ref/pg_restore.sgml index 260040b938392..77ba1368000c7 100644 --- a/doc/src/sgml/ref/pg_restore.sgml +++ b/doc/src/sgml/ref/pg_restore.sgml @@ -68,6 +68,18 @@ PostgreSQL documentation pg_restore will not be able to load the data using COPY statements. + + + + Restoring a dump causes the destination to execute arbitrary code of the + source superusers' choice. Partial dumps and partial restores do not limit + that. If the source superusers are not trusted, the dumped SQL statements + must be inspected before restoring. Non-plain-text dumps can be inspected + by using pg_restore's + option. Note that the client running the dump and restore need not trust + the source or destination superusers. + + @@ -742,6 +754,28 @@ PostgreSQL documentation + + + + + Use the provided string as the psql + \restrict key in the dump output. This can only be + specified for SQL script output, i.e., when the + option is used. If no restrict key is specified, + pg_restore will generate a random one as + needed. Keys may contain only alphanumeric characters. + + + This option is primarily intended for testing purposes and other + scenarios that require repeatable output (e.g., comparing dump files). + It is not recommended for general use, as a malicious server with + advance knowledge of the key may be able to inject arbitrary code that + will be executed on the machine that runs + psql with the dump output. + + + + diff --git a/doc/src/sgml/ref/pgupgrade.sgml b/doc/src/sgml/ref/pgupgrade.sgml index 1a483b7bce488..78c0f6724e278 100644 --- a/doc/src/sgml/ref/pgupgrade.sgml +++ b/doc/src/sgml/ref/pgupgrade.sgml @@ -70,6 +70,14 @@ PostgreSQL documentation pg_upgrade supports upgrades from 9.2.X and later to the current major release of PostgreSQL, including snapshot and beta releases. + + + + Upgrading a cluster causes the destination to execute arbitrary code of the + source superusers' choice. Ensure that the source superusers are trusted + before upgrading. + + diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml index 9bdc0289fdbd9..ca3b3db59c82e 100644 --- a/doc/src/sgml/ref/psql-ref.sgml +++ b/doc/src/sgml/ref/psql-ref.sgml @@ -3244,6 +3244,24 @@ lo_import 152801 + + \restrict restrict_key + + + Enter "restricted" mode with the provided key. In this mode, the only + allowed meta-command is \unrestrict, to exit + restricted mode. The key may contain only alphanumeric characters. + + + This command is primarily intended for use in plain-text dumps + generated by pg_dump, + pg_dumpall, and + pg_restore, but it may be useful elsewhere. + + + + + \s [ filename ] @@ -3418,6 +3436,24 @@ testdb=> \setenv LESS -imx4F + + \unrestrict restrict_key + + + Exit "restricted" mode (i.e., where all other meta-commands are + blocked), provided the specified key matches the one given to + \restrict when restricted mode was entered. + + + This command is primarily intended for use in plain-text dumps + generated by pg_dump, + pg_dumpall, and + pg_restore, but it may be useful elsewhere. + + + + + \unset name diff --git a/src/bin/pg_dump/dumputils.c b/src/bin/pg_dump/dumputils.c index 7657e426818b8..9dd406b9c117f 100644 --- a/src/bin/pg_dump/dumputils.c +++ b/src/bin/pg_dump/dumputils.c @@ -19,6 +19,7 @@ #include "dumputils.h" #include "fe_utils/string_utils.h" +static const char restrict_chars[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; static bool parseAclItem(const char *item, const char *type, const char *name, const char *subname, int remoteVersion, @@ -920,3 +921,40 @@ makeAlterConfigCommand(PGconn *conn, const char *configitem, pg_free(mine); } + +/* + * Generates a valid restrict key (i.e., an alphanumeric string) for use with + * psql's \restrict and \unrestrict meta-commands. For safety, the value is + * chosen at random. + */ +char * +generate_restrict_key(void) +{ + uint8 buf[64]; + char *ret = palloc(sizeof(buf)); + + if (!pg_strong_random(buf, sizeof(buf))) + return NULL; + + for (int i = 0; i < sizeof(buf) - 1; i++) + { + uint8 idx = buf[i] % strlen(restrict_chars); + + ret[i] = restrict_chars[idx]; + } + ret[sizeof(buf) - 1] = '\0'; + + return ret; +} + +/* + * Checks that a given restrict key (intended for use with psql's \restrict and + * \unrestrict meta-commands) contains only alphanumeric characters. + */ +bool +valid_restrict_key(const char *restrict_key) +{ + return restrict_key != NULL && + restrict_key[0] != '\0' && + strspn(restrict_key, restrict_chars) == strlen(restrict_key); +} diff --git a/src/bin/pg_dump/dumputils.h b/src/bin/pg_dump/dumputils.h index 1716b6d0d83da..09d254b4f44ae 100644 --- a/src/bin/pg_dump/dumputils.h +++ b/src/bin/pg_dump/dumputils.h @@ -64,4 +64,7 @@ extern void makeAlterConfigCommand(PGconn *conn, const char *configitem, const char *type2, const char *name2, PQExpBuffer buf); +extern char *generate_restrict_key(void); +extern bool valid_restrict_key(const char *restrict_key); + #endif /* DUMPUTILS_H */ diff --git a/src/bin/pg_dump/pg_backup.h b/src/bin/pg_dump/pg_backup.h index 8c10619c7d596..7a547072fdda3 100644 --- a/src/bin/pg_dump/pg_backup.h +++ b/src/bin/pg_dump/pg_backup.h @@ -156,6 +156,8 @@ typedef struct _restoreOptions bool dumpSchema; bool dumpData; bool dumpStatistics; + + char *restrict_key; } RestoreOptions; typedef struct _dumpOptions @@ -205,6 +207,8 @@ typedef struct _dumpOptions bool dumpSchema; bool dumpData; bool dumpStatistics; + + char *restrict_key; } DumpOptions; /* diff --git a/src/bin/pg_dump/pg_backup_archiver.c b/src/bin/pg_dump/pg_backup_archiver.c index 04324e4ad8a9c..b55d75d70f918 100644 --- a/src/bin/pg_dump/pg_backup_archiver.c +++ b/src/bin/pg_dump/pg_backup_archiver.c @@ -217,6 +217,7 @@ dumpOptionsFromRestoreOptions(RestoreOptions *ropt) dopt->include_everything = ropt->include_everything; dopt->enable_row_security = ropt->enable_row_security; dopt->sequence_data = ropt->sequence_data; + dopt->restrict_key = ropt->restrict_key ? pg_strdup(ropt->restrict_key) : NULL; return dopt; } @@ -473,6 +474,17 @@ RestoreArchive(Archive *AHX) ahprintf(AH, "--\n-- PostgreSQL database dump\n--\n\n"); + /* + * If generating plain-text output, enter restricted mode to block any + * unexpected psql meta-commands. A malicious source might try to inject + * a variety of things via bogus responses to queries. While we cannot + * prevent such sources from affecting the destination at restore time, we + * can block psql meta-commands so that the client machine that runs psql + * with the dump output remains unaffected. + */ + if (ropt->restrict_key) + ahprintf(AH, "\\restrict %s\n\n", ropt->restrict_key); + if (AH->archiveRemoteVersion) ahprintf(AH, "-- Dumped from database version %s\n", AH->archiveRemoteVersion); @@ -749,6 +761,14 @@ RestoreArchive(Archive *AHX) ahprintf(AH, "--\n-- PostgreSQL database dump complete\n--\n\n"); + /* + * If generating plain-text output, exit restricted mode at the very end + * of the script. This is not pro forma; in particular, pg_dumpall + * requires this when transitioning from one database to another. + */ + if (ropt->restrict_key) + ahprintf(AH, "\\unrestrict %s\n\n", ropt->restrict_key); + /* * Clean up & we're done. */ @@ -3335,11 +3355,21 @@ _reconnectToDB(ArchiveHandle *AH, const char *dbname) else { PQExpBufferData connectbuf; + RestoreOptions *ropt = AH->public.ropt; + + /* + * We must temporarily exit restricted mode for \connect, etc. + * Anything added between this line and the following \restrict must + * be careful to avoid any possible meta-command injection vectors. + */ + ahprintf(AH, "\\unrestrict %s\n", ropt->restrict_key); initPQExpBuffer(&connectbuf); appendPsqlMetaConnect(&connectbuf, dbname); - ahprintf(AH, "%s\n", connectbuf.data); + ahprintf(AH, "%s", connectbuf.data); termPQExpBuffer(&connectbuf); + + ahprintf(AH, "\\restrict %s\n\n", ropt->restrict_key); } /* diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c index dfeac78c2d03c..b075a3b2b0f15 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -434,6 +434,7 @@ main(int argc, char **argv) {"on-conflict-do-nothing", no_argument, &dopt.do_nothing, 1}, {"rows-per-insert", required_argument, NULL, 10}, {"include-foreign-data", required_argument, NULL, 11}, + {"restrict-key", required_argument, NULL, 25}, {NULL, 0, NULL, 0} }; @@ -672,6 +673,10 @@ main(int argc, char **argv) with_statistics = true; break; + case 25: + dopt.restrict_key = pg_strdup(optarg); + break; + default: /* getopt_long already emitted a complaint */ pg_log_error_hint("Try \"%s --help\" for more information.", progname); @@ -770,8 +775,22 @@ main(int argc, char **argv) /* archiveFormat specific setup */ if (archiveFormat == archNull) + { plainText = 1; + /* + * If you don't provide a restrict key, one will be appointed for you. + */ + if (!dopt.restrict_key) + dopt.restrict_key = generate_restrict_key(); + if (!dopt.restrict_key) + pg_fatal("could not generate restrict key"); + if (!valid_restrict_key(dopt.restrict_key)) + pg_fatal("invalid restrict key"); + } + else if (dopt.restrict_key) + pg_fatal("option --restrict-key can only be used with --format=plain"); + /* Custom and directory formats are compressed by default, others not */ if (compressLevel == -1) { @@ -1033,6 +1052,7 @@ main(int argc, char **argv) ropt->enable_row_security = dopt.enable_row_security; ropt->sequence_data = dopt.sequence_data; ropt->binary_upgrade = dopt.binary_upgrade; + ropt->restrict_key = dopt.restrict_key ? pg_strdup(dopt.restrict_key) : NULL; if (compressLevel == -1) ropt->compression = 0; @@ -1133,6 +1153,7 @@ help(const char *progname) printf(_(" --no-unlogged-table-data do not dump unlogged table data\n")); printf(_(" --on-conflict-do-nothing add ON CONFLICT DO NOTHING to INSERT commands\n")); printf(_(" --quote-all-identifiers quote all identifiers, even if not key words\n")); + printf(_(" --restrict-key=RESTRICT_KEY use provided string as psql \\restrict key\n")); printf(_(" --rows-per-insert=NROWS number of rows per INSERT; implies --inserts\n")); printf(_(" --section=SECTION dump named section (pre-data, data, or post-data)\n")); printf(_(" --serializable-deferrable wait until the dump can run without anomalies\n")); diff --git a/src/bin/pg_dump/pg_dumpall.c b/src/bin/pg_dump/pg_dumpall.c index f2df83e1bb11c..87d586ee7e4b6 100644 --- a/src/bin/pg_dump/pg_dumpall.c +++ b/src/bin/pg_dump/pg_dumpall.c @@ -103,6 +103,8 @@ static char *filename = NULL; static SimpleStringList database_exclude_patterns = {NULL, NULL}; static SimpleStringList database_exclude_names = {NULL, NULL}; +static char *restrict_key; + #define exit_nicely(code) exit(code) int @@ -166,6 +168,7 @@ main(int argc, char *argv[]) {"on-conflict-do-nothing", no_argument, &on_conflict_do_nothing, 1}, {"rows-per-insert", required_argument, NULL, 7}, {"statistics-only", no_argument, &statistics_only, 1}, + {"restrict-key", required_argument, NULL, 9}, {NULL, 0, NULL, 0} }; @@ -349,6 +352,12 @@ main(int argc, char *argv[]) appendShellString(pgdumpopts, optarg); break; + case 9: + restrict_key = pg_strdup(optarg); + appendPQExpBufferStr(pgdumpopts, " --restrict-key "); + appendShellString(pgdumpopts, optarg); + break; + default: /* getopt_long already emitted a complaint */ pg_log_error_hint("Try \"%s --help\" for more information.", progname); @@ -458,6 +467,16 @@ main(int argc, char *argv[]) if (statistics_only) appendPQExpBufferStr(pgdumpopts, " --statistics-only"); + /* + * If you don't provide a restrict key, one will be appointed for you. + */ + if (!restrict_key) + restrict_key = generate_restrict_key(); + if (!restrict_key) + pg_fatal("could not generate restrict key"); + if (!valid_restrict_key(restrict_key)) + pg_fatal("invalid restrict key"); + /* * If there was a database specified on the command line, use that, * otherwise try to connect to database "postgres", and failing that @@ -545,6 +564,16 @@ main(int argc, char *argv[]) if (verbose) dumpTimestamp("Started on"); + /* + * Enter restricted mode to block any unexpected psql meta-commands. A + * malicious source might try to inject a variety of things via bogus + * responses to queries. While we cannot prevent such sources from + * affecting the destination at restore time, we can block psql + * meta-commands so that the client machine that runs psql with the dump + * output remains unaffected. + */ + fprintf(OPF, "\\restrict %s\n\n", restrict_key); + /* * We used to emit \connect postgres here, but that served no purpose * other than to break things for installations without a postgres @@ -605,6 +634,12 @@ main(int argc, char *argv[]) dumpTablespaces(conn); } + /* + * Exit restricted mode just before dumping the databases. pg_dump will + * handle entering restricted mode again as appropriate. + */ + fprintf(OPF, "\\unrestrict %s\n\n", restrict_key); + if (!globals_only && !roles_only && !tablespaces_only) dumpDatabases(conn); @@ -675,6 +710,7 @@ help(void) printf(_(" --no-unlogged-table-data do not dump unlogged table data\n")); printf(_(" --on-conflict-do-nothing add ON CONFLICT DO NOTHING to INSERT commands\n")); printf(_(" --quote-all-identifiers quote all identifiers, even if not key words\n")); + printf(_(" --restrict-key=RESTRICT_KEY use provided string as psql \\restrict key\n")); printf(_(" --rows-per-insert=NROWS number of rows per INSERT; implies --inserts\n")); printf(_(" --statistics-only dump only the statistics, not schema or data\n")); printf(_(" --use-set-session-authorization\n" diff --git a/src/bin/pg_dump/pg_restore.c b/src/bin/pg_dump/pg_restore.c index bbbf07119de3d..f2fb2f55954d0 100644 --- a/src/bin/pg_dump/pg_restore.c +++ b/src/bin/pg_dump/pg_restore.c @@ -139,6 +139,7 @@ main(int argc, char **argv) {"with-schema", no_argument, &with_schema, 1}, {"with-statistics", no_argument, &with_statistics, 1}, {"statistics-only", no_argument, &statistics_only, 1}, + {"restrict-key", required_argument, NULL, 6}, {NULL, 0, NULL, 0} }; @@ -302,6 +303,10 @@ main(int argc, char **argv) set_dump_section(optarg, &(opts->dumpSections)); break; + case 6: + opts->restrict_key = pg_strdup(optarg); + break; + default: /* getopt_long already emitted a complaint */ pg_log_error_hint("Try \"%s --help\" for more information.", progname); @@ -337,8 +342,24 @@ main(int argc, char **argv) pg_log_error_hint("Try \"%s --help\" for more information.", progname); exit_nicely(1); } + + if (opts->restrict_key) + pg_fatal("options -d/--dbname and --restrict-key cannot be used together"); + opts->useDB = 1; } + else + { + /* + * If you don't provide a restrict key, one will be appointed for you. + */ + if (!opts->restrict_key) + opts->restrict_key = generate_restrict_key(); + if (!opts->restrict_key) + pg_fatal("could not generate restrict key"); + if (!valid_restrict_key(opts->restrict_key)) + pg_fatal("invalid restrict key"); + } /* reject conflicting "-only" options */ if (data_only && schema_only) @@ -526,6 +547,7 @@ usage(const char *progname) printf(_(" --no-subscriptions do not restore subscriptions\n")); printf(_(" --no-table-access-method do not restore table access methods\n")); printf(_(" --no-tablespaces do not restore tablespace assignments\n")); + printf(_(" --restrict-key=RESTRICT_KEY use provided string as psql \\restrict key\n")); printf(_(" --section=SECTION restore named section (pre-data, data, or post-data)\n")); printf(_(" --statistics-only restore only the statistics, not schema or data\n")); printf(_(" --strict-names require table and/or schema include patterns to\n" diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl index d621c3cc78f56..8c89ccd141523 100644 --- a/src/bin/pg_dump/t/002_pg_dump.pl +++ b/src/bin/pg_dump/t/002_pg_dump.pl @@ -540,6 +540,16 @@ # This is where the actual tests are defined. my %tests = ( + 'restrict' => { + all_runs => 1, + regexp => qr/^\\restrict [a-zA-Z0-9]+$/m, + }, + + 'unrestrict' => { + all_runs => 1, + regexp => qr/^\\unrestrict [a-zA-Z0-9]+$/m, + }, + 'ALTER DEFAULT PRIVILEGES FOR ROLE regress_dump_test_role GRANT' => { create_order => 14, create_sql => 'ALTER DEFAULT PRIVILEGES @@ -3298,7 +3308,6 @@ }, 'ALTER TABLE measurement PRIMARY KEY' => { - all_runs => 1, catch_all => 'CREATE ... commands', create_order => 93, create_sql => @@ -3335,7 +3344,6 @@ }, 'ALTER INDEX ... ATTACH PARTITION (primary key)' => { - all_runs => 1, catch_all => 'CREATE ... commands', regexp => qr/^ \QALTER INDEX dump_test.measurement_pkey ATTACH PARTITION dump_test_second_schema.measurement_y2006m2_pkey\E @@ -4345,9 +4353,10 @@ # Check for proper test definitions # - # There should be a "like" list, even if it is empty. (This - # makes the test more self-documenting.) - if (!defined($tests{$test}->{like})) + # Either "all_runs" should be set or there should be a "like" list, + # even if it is empty. (This makes the test more self-documenting.) + if (!defined($tests{$test}->{all_runs}) + && !defined($tests{$test}->{like})) { die "missing \"like\" in test \"$test\""; } @@ -4383,9 +4392,10 @@ next; } - # Run the test listed as a like, unless it is specifically noted - # as an unlike (generally due to an explicit exclusion or similar). - if ($tests{$test}->{like}->{$test_key} + # Run the test if all_runs is set or if listed as a like, unless it is + # specifically noted as an unlike (generally due to an explicit + # exclusion or similar). + if (($tests{$test}->{like}->{$test_key} || $tests{$test}->{all_runs}) && !defined($tests{$test}->{unlike}->{$test_key})) { if (!ok($output_file =~ $tests{$test}->{regexp}, diff --git a/src/bin/pg_upgrade/t/002_pg_upgrade.pl b/src/bin/pg_upgrade/t/002_pg_upgrade.pl index 347c47b3650f8..770138e9278d7 100644 --- a/src/bin/pg_upgrade/t/002_pg_upgrade.pl +++ b/src/bin/pg_upgrade/t/002_pg_upgrade.pl @@ -208,6 +208,7 @@ sub filter_dump # that we need to use pg_dumpall from the new node here. my @dump_command = ( 'pg_dumpall', '--no-sync', '-d', $oldnode->connstr('postgres'), + '--restrict-key=test', '-f', $dump1_file); # --extra-float-digits is needed when upgrading from a version older than 11. push(@dump_command, '--extra-float-digits', '0') @@ -370,6 +371,7 @@ sub filter_dump # Second dump from the upgraded instance. @dump_command = ( 'pg_dumpall', '--no-sync', '-d', $newnode->connstr('postgres'), + '--restrict-key=test', '-f', $dump2_file); # --extra-float-digits is needed when upgrading from a version older than 11. push(@dump_command, '--extra-float-digits', '0') diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c index 57cef4fa670c3..e64978626ca9a 100644 --- a/src/bin/psql/command.c +++ b/src/bin/psql/command.c @@ -122,6 +122,8 @@ static backslashResult exec_command_pset(PsqlScanState scan_state, bool active_b static backslashResult exec_command_quit(PsqlScanState scan_state, bool active_branch); static backslashResult exec_command_reset(PsqlScanState scan_state, bool active_branch, PQExpBuffer query_buf); +static backslashResult exec_command_restrict(PsqlScanState scan_state, bool active_branch, + const char *cmd); static backslashResult exec_command_s(PsqlScanState scan_state, bool active_branch); static backslashResult exec_command_set(PsqlScanState scan_state, bool active_branch); static backslashResult exec_command_setenv(PsqlScanState scan_state, bool active_branch, @@ -131,6 +133,8 @@ static backslashResult exec_command_sf_sv(PsqlScanState scan_state, bool active_ static backslashResult exec_command_t(PsqlScanState scan_state, bool active_branch); static backslashResult exec_command_T(PsqlScanState scan_state, bool active_branch); static backslashResult exec_command_timing(PsqlScanState scan_state, bool active_branch); +static backslashResult exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, + const char *cmd); static backslashResult exec_command_unset(PsqlScanState scan_state, bool active_branch, const char *cmd); static backslashResult exec_command_write(PsqlScanState scan_state, bool active_branch, @@ -179,6 +183,8 @@ static char *pset_value_string(const char *param, printQueryOpt *popt); static void checkWin32Codepage(void); #endif +static bool restricted; +static char *restrict_key; /*---------- @@ -224,8 +230,19 @@ HandleSlashCmds(PsqlScanState scan_state, /* Parse off the command name */ cmd = psql_scan_slash_command(scan_state); - /* And try to execute it */ - status = exec_command(cmd, scan_state, cstack, query_buf, previous_buf); + /* + * And try to execute it. + * + * If we are in "restricted" mode, the only allowable backslash command is + * \unrestrict (to exit restricted mode). + */ + if (restricted && strcmp(cmd, "unrestrict") != 0) + { + pg_log_error("backslash commands are restricted; only \\unrestrict is allowed"); + status = PSQL_CMD_ERROR; + } + else + status = exec_command(cmd, scan_state, cstack, query_buf, previous_buf); if (status == PSQL_CMD_UNKNOWN) { @@ -384,6 +401,8 @@ exec_command(const char *cmd, status = exec_command_quit(scan_state, active_branch); else if (strcmp(cmd, "r") == 0 || strcmp(cmd, "reset") == 0) status = exec_command_reset(scan_state, active_branch, query_buf); + else if (strcmp(cmd, "restrict") == 0) + status = exec_command_restrict(scan_state, active_branch, cmd); else if (strcmp(cmd, "s") == 0) status = exec_command_s(scan_state, active_branch); else if (strcmp(cmd, "set") == 0) @@ -400,6 +419,8 @@ exec_command(const char *cmd, status = exec_command_T(scan_state, active_branch); else if (strcmp(cmd, "timing") == 0) status = exec_command_timing(scan_state, active_branch); + else if (strcmp(cmd, "unrestrict") == 0) + status = exec_command_unrestrict(scan_state, active_branch, cmd); else if (strcmp(cmd, "unset") == 0) status = exec_command_unset(scan_state, active_branch, cmd); else if (strcmp(cmd, "w") == 0 || strcmp(cmd, "write") == 0) @@ -2315,6 +2336,35 @@ exec_command_reset(PsqlScanState scan_state, bool active_branch, return PSQL_CMD_SKIP_LINE; } +/* + * \restrict -- enter "restricted mode" with the provided key + */ +static backslashResult +exec_command_restrict(PsqlScanState scan_state, bool active_branch, + const char *cmd) +{ + if (active_branch) + { + char *opt; + + Assert(!restricted); + + opt = psql_scan_slash_option(scan_state, OT_NORMAL, NULL, true); + if (opt == NULL || opt[0] == '\0') + { + pg_log_error("\\%s: missing required argument", cmd); + return PSQL_CMD_ERROR; + } + + restrict_key = pstrdup(opt); + restricted = true; + } + else + ignore_slash_options(scan_state); + + return PSQL_CMD_SKIP_LINE; +} + /* * \s -- save history in a file or show it on the screen */ @@ -2603,6 +2653,46 @@ exec_command_timing(PsqlScanState scan_state, bool active_branch) return success ? PSQL_CMD_SKIP_LINE : PSQL_CMD_ERROR; } +/* + * \unrestrict -- exit "restricted mode" if provided key matches + */ +static backslashResult +exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, + const char *cmd) +{ + if (active_branch) + { + char *opt; + + opt = psql_scan_slash_option(scan_state, OT_NORMAL, NULL, true); + if (opt == NULL || opt[0] == '\0') + { + pg_log_error("\\%s: missing required argument", cmd); + return PSQL_CMD_ERROR; + } + + if (!restricted) + { + pg_log_error("\\%s: not currently in restricted mode", cmd); + return PSQL_CMD_ERROR; + } + else if (strcmp(opt, restrict_key) == 0) + { + pfree(restrict_key); + restricted = false; + } + else + { + pg_log_error("\\%s: wrong key", cmd); + return PSQL_CMD_ERROR; + } + } + else + ignore_slash_options(scan_state); + + return PSQL_CMD_SKIP_LINE; +} + /* * \unset -- unset variable */ diff --git a/src/bin/psql/help.c b/src/bin/psql/help.c index f8ce1a07060d7..0edefe783d101 100644 --- a/src/bin/psql/help.c +++ b/src/bin/psql/help.c @@ -199,6 +199,10 @@ slashUsage(unsigned short int pager) HELP0(" \\gset [PREFIX] execute query and store result in psql variables\n"); HELP0(" \\gx [(OPTIONS)] [FILE] as \\g, but forces expanded output mode\n"); HELP0(" \\q quit psql\n"); + HELP0(" \\restrict RESTRICT_KEY\n" + " enter restricted mode with provided key\n"); + HELP0(" \\unrestrict RESTRICT_KEY\n" + " exit restricted mode if key matches\n"); HELP0(" \\watch [SEC] execute query every SEC seconds\n"); HELP0("\n"); diff --git a/src/bin/psql/t/001_basic.pl b/src/bin/psql/t/001_basic.pl index f4478457175d7..ef5ec24355a14 100644 --- a/src/bin/psql/t/001_basic.pl +++ b/src/bin/psql/t/001_basic.pl @@ -325,4 +325,11 @@ sub psql_fails_like 'client-side error commits transaction, no ON_ERROR_STOP and multiple -c switches' ); +psql_fails_like( + $node, + qq{\\restrict test +\\! should_fail}, + qr/backslash commands are restricted; only \\unrestrict is allowed/, + 'meta-command in restrict mode fails'); + done_testing(); diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c index f6e7011c21df7..f8d085502361b 100644 --- a/src/bin/psql/tab-complete.c +++ b/src/bin/psql/tab-complete.c @@ -1699,10 +1699,10 @@ psql_completion(const char *text, int start, int end) "\\out", "\\password", "\\print", "\\prompt", "\\pset", "\\qecho", "\\quit", - "\\reset", + "\\reset", "\\restrict", "\\s", "\\set", "\\setenv", "\\sf", "\\sv", "\\t", "\\T", "\\timing", - "\\unset", + "\\unrestrict", "\\unset", "\\x", "\\warn", "\\watch", "\\write", "\\z", diff --git a/src/test/recovery/t/027_stream_regress.pl b/src/test/recovery/t/027_stream_regress.pl index 49587d4c242d7..8f3b4fc9ec7ad 100644 --- a/src/test/recovery/t/027_stream_regress.pl +++ b/src/test/recovery/t/027_stream_regress.pl @@ -93,14 +93,18 @@ command_ok( [ 'pg_dumpall', '-f', $outputdir . '/primary.dump', - '--no-sync', '--no-statistics', '-p', $node_primary->port, + '--no-sync', '--no-statistics', + '--restrict-key=test', + '-p', $node_primary->port, '--no-unlogged-table-data' # if unlogged, standby has schema only ], 'dump primary server'); command_ok( [ 'pg_dumpall', '-f', $outputdir . '/standby.dump', - '--no-sync', '--no-statistics', '-p', $node_standby_1->port + '--no-sync', '--no-statistics', + '--restrict-key=test', + '-p', $node_standby_1->port ], 'dump standby server'); command_ok( @@ -119,6 +123,7 @@ ('--schema', 'pg_catalog'), ('-f', $outputdir . '/catalogs_primary.dump'), '--no-sync', + '--restrict-key=test', ('-p', $node_primary->port), '--no-unlogged-table-data', 'regression' @@ -130,6 +135,7 @@ ('--schema', 'pg_catalog'), ('-f', $outputdir . '/catalogs_standby.dump'), '--no-sync', + '--restrict-key=test', ('-p', $node_standby_1->port), 'regression' ], diff --git a/src/test/regress/expected/psql.out b/src/test/regress/expected/psql.out index 29c6b2dae3bb9..75488480542e0 100644 --- a/src/test/regress/expected/psql.out +++ b/src/test/regress/expected/psql.out @@ -4505,6 +4505,7 @@ invalid command \lo \pset arg1 arg2 \q \reset + \restrict test \s arg1 \set arg1 arg2 arg3 arg4 arg5 arg6 arg7 \setenv arg1 arg2 @@ -4513,6 +4514,7 @@ invalid command \lo \t arg1 \T arg1 \timing arg1 + \unrestrict not_valid \unset arg1 \w arg1 \watch arg1 diff --git a/src/test/regress/sql/psql.sql b/src/test/regress/sql/psql.sql index 94a54d6fd8d10..1eef2a62de9e4 100644 --- a/src/test/regress/sql/psql.sql +++ b/src/test/regress/sql/psql.sql @@ -1006,6 +1006,7 @@ select \if false \\ (bogus \else \\ 42 \endif \\ forty_two; \pset arg1 arg2 \q \reset + \restrict test \s arg1 \set arg1 arg2 arg3 arg4 arg5 arg6 arg7 \setenv arg1 arg2 @@ -1014,6 +1015,7 @@ select \if false \\ (bogus \else \\ 42 \endif \\ forty_two; \t arg1 \T arg1 \timing arg1 + \unrestrict not_valid \unset arg1 \w arg1 \watch arg1 From 64523ae33ef6b28a294eb8f3486b676d04dc56f7 Mon Sep 17 00:00:00 2001 From: Nathan Bossart Date: Mon, 10 Aug 2026 06:38:31 -0700 Subject: [PATCH 2/3] psql: Don't do backquote expansion in \unrestrict. This oversight in commit 71ea0d6795 allows a malicious server to inject shell commands into plain-text dump output that are run at restore time on the machine running psql. To fix, interpret all text after \unrestrict until the end of the line as its argument. Reported-by: Lucas Velgus Reported-by: Filip Janus Reported-by: Daniel Bakker Author: Nathan Bossart Reviewed-by: Robert Haas Reviewed-by: Noah Misch Security: CVE-2026-18408 Backpatch-through: 14 (cherry picked from commit df245c37458315cf8a6ff08f4f8c3b1103857ed3) --- doc/src/sgml/ref/psql-ref.sgml | 5 +++++ src/bin/psql/command.c | 19 +++++++++++++++++-- src/bin/psql/t/001_basic.pl | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml index ca3b3db59c82e..d25a431671ed5 100644 --- a/doc/src/sgml/ref/psql-ref.sgml +++ b/doc/src/sgml/ref/psql-ref.sgml @@ -3450,6 +3450,11 @@ testdb=> \setenv LESS -imx4F pg_dumpall, and pg_restore, but it may be useful elsewhere. + + Unlike most other meta-commands, the entire remainder of the line is + always taken to be the argument of \unrestrict, and + neither variable interpolation nor backquote expansion are performed. + diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c index e64978626ca9a..eeeb19e61912f 100644 --- a/src/bin/psql/command.c +++ b/src/bin/psql/command.c @@ -2349,6 +2349,12 @@ exec_command_restrict(PsqlScanState scan_state, bool active_branch, Assert(!restricted); + /* + * Unlike \unrestrict, this argument may safely undergo backquote and + * variable expansion: HandleSlashCmds() rejects \restrict in + * restricted mode before its argument is scanned, so we only get here + * when the input could execute such things anyway. + */ opt = psql_scan_slash_option(scan_state, OT_NORMAL, NULL, true); if (opt == NULL || opt[0] == '\0') { @@ -2663,14 +2669,23 @@ exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, if (active_branch) { char *opt; + size_t len; - opt = psql_scan_slash_option(scan_state, OT_NORMAL, NULL, true); + opt = psql_scan_slash_option(scan_state, OT_WHOLE_LINE, NULL, true); if (opt == NULL || opt[0] == '\0') { pg_log_error("\\%s: missing required argument", cmd); return PSQL_CMD_ERROR; } + /* strip any trailing spaces and semicolons */ + len = strlen(opt); + while (len > 0 && + (opt[len - 1] == ';' || + (isascii((unsigned char) opt[len - 1]) && + isspace((unsigned char) opt[len - 1])))) + opt[--len] = '\0'; + if (!restricted) { pg_log_error("\\%s: not currently in restricted mode", cmd); @@ -2688,7 +2703,7 @@ exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, } } else - ignore_slash_options(scan_state); + ignore_slash_whole_line(scan_state); return PSQL_CMD_SKIP_LINE; } diff --git a/src/bin/psql/t/001_basic.pl b/src/bin/psql/t/001_basic.pl index ef5ec24355a14..660c9ab4fac14 100644 --- a/src/bin/psql/t/001_basic.pl +++ b/src/bin/psql/t/001_basic.pl @@ -332,4 +332,11 @@ sub psql_fails_like qr/backslash commands are restricted; only \\unrestrict is allowed/, 'meta-command in restrict mode fails'); +psql_fails_like( + $node, + qq{\\restrict test +\\unrestrict `echo test`}, + qr/wrong key/, + '\unrestrict does not do backquote expansion'); + done_testing(); From 8efac9b2ee908f3532d378911474df807ecdd0e4 Mon Sep 17 00:00:00 2001 From: Fujii Masao Date: Wed, 19 Aug 2026 12:38:16 +0900 Subject: [PATCH 3/3] psql: Fix psql slash option leaks psql_scan_slash_option() returns a malloc'd string, but \getresults, \gset in pipeline mode, \restrict, and \unrestrict did not free it after consuming or copying the value. Free these option strings after use. Backpatch to all supported versions. In v17 and older, only \restrict and \unrestrict are affected, so those branches need only that part of the fix. Author: Fujii Masao Reviewed-by: Chao Li Discussion: https://postgr.es/m/CAHGQGwEh3R3=1tx_a5=fTDJ+ycuwxWMEn6bG_Yt4B5P+hE7AVw@mail.gmail.com Backpatch-through: 14 (cherry picked from commit bc18487fe1cad25b36dbe06d33ae3cd046cddfeb) --- src/bin/psql/command.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c index eeeb19e61912f..5f5ca3d71060d 100644 --- a/src/bin/psql/command.c +++ b/src/bin/psql/command.c @@ -2359,10 +2359,12 @@ exec_command_restrict(PsqlScanState scan_state, bool active_branch, if (opt == NULL || opt[0] == '\0') { pg_log_error("\\%s: missing required argument", cmd); + free(opt); return PSQL_CMD_ERROR; } restrict_key = pstrdup(opt); + free(opt); restricted = true; } else @@ -2675,6 +2677,7 @@ exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, if (opt == NULL || opt[0] == '\0') { pg_log_error("\\%s: missing required argument", cmd); + free(opt); return PSQL_CMD_ERROR; } @@ -2689,16 +2692,19 @@ exec_command_unrestrict(PsqlScanState scan_state, bool active_branch, if (!restricted) { pg_log_error("\\%s: not currently in restricted mode", cmd); + free(opt); return PSQL_CMD_ERROR; } else if (strcmp(opt, restrict_key) == 0) { pfree(restrict_key); restricted = false; + free(opt); } else { pg_log_error("\\%s: wrong key", cmd); + free(opt); return PSQL_CMD_ERROR; } }