From 728d0540949070eb965d8b843d2f62ba3e02c716 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Mon, 3 Aug 2026 19:15:15 +0500
Subject: [PATCH] pgcrypto: Reject crypt() results invalid in database encoding

crypt() copies a fixed number of caller-supplied salt bytes into its
text result.  When a multibyte character straddles that cut, the result
contains a truncated sequence that is not valid in the database
encoding, yet can still be stored.  Validate the result with
pg_verifymbstr(), as already done for pgp_*_decrypt_text.

A regress case based on the report is included.

Bug: #19600
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Michael Malis <malis@pgrust.com>
Discussion: https://postgr.es/m/19600-e5fb479f50f5022d@postgresql.org
---
 contrib/pgcrypto/Makefile                  | 2 +-
 contrib/pgcrypto/expected/crypt-utf8.out   | 8 ++++++++
 contrib/pgcrypto/expected/crypt-utf8_1.out | 4 ++++
 contrib/pgcrypto/meson.build               | 1 +
 contrib/pgcrypto/pgcrypto.c                | 4 ++++
 contrib/pgcrypto/sql/crypt-utf8.sql        | 7 +++++++
 6 files changed, 25 insertions(+), 1 deletion(-)
 create mode 100644 contrib/pgcrypto/expected/crypt-utf8.out
 create mode 100644 contrib/pgcrypto/expected/crypt-utf8_1.out
 create mode 100644 contrib/pgcrypto/sql/crypt-utf8.sql

diff --git a/contrib/pgcrypto/Makefile b/contrib/pgcrypto/Makefile
index 17d2b0c5ed1..ca9f99a1b16 100644
--- a/contrib/pgcrypto/Makefile
+++ b/contrib/pgcrypto/Makefile
@@ -42,7 +42,7 @@ PGFILEDESC = "pgcrypto - cryptographic functions"
 
 REGRESS = init md5 sha1 hmac-md5 hmac-sha1 blowfish rijndael \
 	sha2 des 3des cast5 \
-	crypt-des crypt-md5 crypt-blowfish crypt-xdes \
+	crypt-des crypt-md5 crypt-blowfish crypt-xdes crypt-utf8 \
 	pgp-armor pgp-decrypt pgp-encrypt pgp-encrypt-md5 $(CF_PGP_TESTS) \
 	pgp-pubkey-decrypt pgp-pubkey-encrypt pgp-pubkey-session \
 	pgp-info crypt-shacrypt
diff --git a/contrib/pgcrypto/expected/crypt-utf8.out b/contrib/pgcrypto/expected/crypt-utf8.out
new file mode 100644
index 00000000000..7ce1b570ef9
--- /dev/null
+++ b/contrib/pgcrypto/expected/crypt-utf8.out
@@ -0,0 +1,8 @@
+/* Needs UTF8; skip otherwise (truncated multibyte salt copy). */
+SELECT getdatabaseencoding() <> 'UTF8' AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+-- Salt bytes e282ac41 (euro then A). DES copies only e282, which is invalid UTF8.
+SELECT crypt('password', convert_from(decode('e282ac41', 'hex'), 'utf8'));
+ERROR:  invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x55
diff --git a/contrib/pgcrypto/expected/crypt-utf8_1.out b/contrib/pgcrypto/expected/crypt-utf8_1.out
new file mode 100644
index 00000000000..5375d85fef6
--- /dev/null
+++ b/contrib/pgcrypto/expected/crypt-utf8_1.out
@@ -0,0 +1,4 @@
+/* Needs UTF8; skip otherwise (truncated multibyte salt copy). */
+SELECT getdatabaseencoding() <> 'UTF8' AS skip_test \gset
+\if :skip_test
+\quit
diff --git a/contrib/pgcrypto/meson.build b/contrib/pgcrypto/meson.build
index 4f255c8cb05..678a403baa1 100644
--- a/contrib/pgcrypto/meson.build
+++ b/contrib/pgcrypto/meson.build
@@ -46,6 +46,7 @@ pgcrypto_regress = [
   'crypt-md5',
   'crypt-blowfish',
   'crypt-xdes',
+  'crypt-utf8',
   'pgp-armor',
   'pgp-decrypt',
   'pgp-encrypt',
diff --git a/contrib/pgcrypto/pgcrypto.c b/contrib/pgcrypto/pgcrypto.c
index 9ecbbd2e2f8..8f822d683d1 100644
--- a/contrib/pgcrypto/pgcrypto.c
+++ b/contrib/pgcrypto/pgcrypto.c
@@ -33,6 +33,7 @@
 
 #include <ctype.h>
 
+#include "mb/pg_wchar.h"
 #include "parser/scansup.h"
 #include "pgcrypto.h"
 #include "px-crypt.h"
@@ -239,6 +240,9 @@ pg_crypt(PG_FUNCTION_ARGS)
 
 	pfree(resbuf);
 
+	/* Ensure text result is valid in the database encoding. */
+	pg_verifymbstr(VARDATA_ANY(res), VARSIZE_ANY_EXHDR(res), false);
+
 	PG_FREE_IF_COPY(arg0, 0);
 	PG_FREE_IF_COPY(arg1, 1);
 
diff --git a/contrib/pgcrypto/sql/crypt-utf8.sql b/contrib/pgcrypto/sql/crypt-utf8.sql
new file mode 100644
index 00000000000..b6a45aabbe5
--- /dev/null
+++ b/contrib/pgcrypto/sql/crypt-utf8.sql
@@ -0,0 +1,7 @@
+/* Needs UTF8; skip otherwise (truncated multibyte salt copy). */
+SELECT getdatabaseencoding() <> 'UTF8' AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+-- Salt bytes e282ac41 (euro then A). DES copies only e282, which is invalid UTF8.
+SELECT crypt('password', convert_from(decode('e282ac41', 'hex'), 'utf8'));
-- 
2.53.0

