From 3258f0376465c3d6e0540db7c6e34f1a75f426c1 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Thu, 5 Nov 2026 10:58:20 +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.
    
Verifying a password with crypt() now raises an error if the stored
hash is not valid in the database encoding, for the correct password
as well as a wrong one.  Such a hash could not be restored from a
dump.

Bug: #19600
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Michael Malis <malis@pgrust.com>
Reviewed-by: Ishjaap Singh <ishjaap.singh07@gmail.com>
Discussion: https://postgr.es/m/19600-e5fb479f50f5022d@postgresql.org
---
 contrib/pgcrypto/Makefile                  |  2 +-
 contrib/pgcrypto/expected/crypt-utf8.out   | 14 ++++++++++++++
 contrib/pgcrypto/expected/crypt-utf8_1.out |  4 ++++
 contrib/pgcrypto/meson.build               |  1 +
 contrib/pgcrypto/pgcrypto.c                |  4 ++++
 contrib/pgcrypto/sql/crypt-utf8.sql        | 11 +++++++++++
 6 files changed, 35 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..4f80830ba14
--- /dev/null
+++ b/contrib/pgcrypto/expected/crypt-utf8.out
@@ -0,0 +1,14 @@
+/* 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
+-- Salt bytes 5f4a392e2e6162e282ac (_J9..ab then euro). xDES copies 9 bytes and drops ac.
+SELECT crypt('password', convert_from(decode('5f4a392e2e6162e282ac', 'hex'), 'utf8'));
+ERROR:  invalid byte sequence for encoding "UTF8": 0xe2 0x82 0x48
+-- Salt bytes 24312431323334353637e282ac ($1$1234567 then euro). MD5 copies 8 salt bytes and keeps a trailing e2.
+SELECT crypt('password', convert_from(decode('24312431323334353637e282ac', 'hex'), 'utf8'));
+ERROR:  invalid byte sequence for encoding "UTF8": 0xe2 0x24 0x69
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 2756900077c..d60b958b39a 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..3f31f852d2e
--- /dev/null
+++ b/contrib/pgcrypto/sql/crypt-utf8.sql
@@ -0,0 +1,11 @@
+/* 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'));
+-- Salt bytes 5f4a392e2e6162e282ac (_J9..ab then euro). xDES copies 9 bytes and drops ac.
+SELECT crypt('password', convert_from(decode('5f4a392e2e6162e282ac', 'hex'), 'utf8'));
+-- Salt bytes 24312431323334353637e282ac ($1$1234567 then euro). MD5 copies 8 salt bytes and keeps a trailing e2.
+SELECT crypt('password', convert_from(decode('24312431323334353637e282ac', 'hex'), 'utf8'));
-- 
2.53.0

