From b8ac9a387069d74ea106560b8c52b3e9d5561153 Mon Sep 17 00:00:00 2001
From: Robert I Nilsson <robert.i.nilsson@callclue.com>
Date: Tue, 10 Nov 2026 17:36:05 +0100
Subject: [PATCH v1] Fix SASLprep validation after NFKC normalization

The prohibited code point and bidirectional checks in pg_saslprep()
are performed on the original input rather than the NFKC-normalized
output. Since normalization can change both the code points and
their number, this can lead to valid strings being rejected and
invalid strings being accepted.

Perform these checks on the normalized output, using its length
instead of the original input length. Also reject an empty result
after normalization.

Add regression tests covering both incorrect rejection of a valid
string and incorrect acceptance of a string violating the
bidirectional rules.
---
 src/common/saslprep.c                         | 24 ++++++++++++-------
 .../test_saslprep/expected/test_saslprep.out  | 21 ++++++++++++++++
 .../test_saslprep/sql/test_saslprep.sql       | 16 +++++++++++++
 3 files changed, 53 insertions(+), 8 deletions(-)

diff --git a/src/common/saslprep.c b/src/common/saslprep.c
index a2bb60ac0cb..475d98bfe4b 100644
--- a/src/common/saslprep.c
+++ b/src/common/saslprep.c
@@ -1049,6 +1049,7 @@ pg_saslprep(const char *input, char **output)
 	char32_t   *input_chars = NULL;
 	char32_t   *output_chars = NULL;
 	int			input_size;
+	int			output_size;
 	char	   *result;
 	int			result_size;
 	int			count;
@@ -1121,13 +1122,20 @@ pg_saslprep(const char *input, char **output)
 	if (!output_chars)
 		goto oom;
 
+	output_size = 0;
+	for (wp = output_chars; *wp; wp++)
+		output_size++;
+
+	if (output_size == 0)
+		goto prohibited;		/* don't allow empty password, after NFKC */
+
 	/*
 	 * 3) Prohibit -- Check for any characters that are not allowed in the
 	 * output.  If any are found, return an error.
 	 */
-	for (i = 0; i < input_size; i++)
+	for (i = 0; i < output_size; i++)
 	{
-		char32_t	code = input_chars[i];
+		char32_t	code = output_chars[i];
 
 		if (IS_CODE_IN_TABLE(code, prohibited_output_ranges))
 			goto prohibited;
@@ -1157,9 +1165,9 @@ pg_saslprep(const char *input, char **output)
 	 * MUST be the last character of the string."
 	 */
 	contains_RandALCat = false;
-	for (i = 0; i < input_size; i++)
+	for (i = 0; i < output_size; i++)
 	{
-		char32_t	code = input_chars[i];
+		char32_t	code = output_chars[i];
 
 		if (IS_CODE_IN_TABLE(code, RandALCat_codepoint_ranges))
 		{
@@ -1170,12 +1178,12 @@ pg_saslprep(const char *input, char **output)
 
 	if (contains_RandALCat)
 	{
-		char32_t	first = input_chars[0];
-		char32_t	last = input_chars[input_size - 1];
+		char32_t	first = output_chars[0];
+		char32_t	last = output_chars[output_size - 1];
 
-		for (i = 0; i < input_size; i++)
+		for (i = 0; i < output_size; i++)
 		{
-			char32_t	code = input_chars[i];
+			char32_t	code = output_chars[i];
 
 			if (IS_CODE_IN_TABLE(code, LCat_codepoint_ranges))
 				goto prohibited;
diff --git a/src/test/modules/test_saslprep/expected/test_saslprep.out b/src/test/modules/test_saslprep/expected/test_saslprep.out
index 92f93365343..a3b75d45d33 100644
--- a/src/test/modules/test_saslprep/expected/test_saslprep.out
+++ b/src/test/modules/test_saslprep/expected/test_saslprep.out
@@ -149,4 +149,25 @@ SELECT
  <DEL>    | \x7f | (,PROHIBITED)
 (128 rows)
 
+-- Test prohibited code points and bidi rules after normalization
+WITH cases(name, src, expected_status, expected_res) AS (
+    VALUES
+    ('step3', decode('61cd80', 'hex'),
+     'SUCCESS', decode('c3a0', 'hex')),
+    ('step4', decode('efac9d', 'hex'),
+     'PROHIBITED', NULL::bytea)
+)
+SELECT name,
+       status,
+       encode(res, 'hex') AS result,
+       status = expected_status
+           AND res IS NOT DISTINCT FROM expected_res AS passed
+FROM cases
+CROSS JOIN LATERAL test_saslprep(src);
+ name  |   status   | result | passed 
+-------+------------+--------+--------
+ step3 | SUCCESS    | c3a0   | t
+ step4 | PROHIBITED |        | t
+(2 rows)
+
 DROP EXTENSION test_saslprep;
diff --git a/src/test/modules/test_saslprep/sql/test_saslprep.sql b/src/test/modules/test_saslprep/sql/test_saslprep.sql
index 00bad48eca7..c2ddd7dda42 100644
--- a/src/test/modules/test_saslprep/sql/test_saslprep.sql
+++ b/src/test/modules/test_saslprep/sql/test_saslprep.sql
@@ -16,4 +16,20 @@ SELECT
     test_saslprep(set_byte('\x00'::bytea, 0, a)) AS saslprep
   FROM generate_series(0,127) AS a;
 
+-- Test prohibited code points and bidi rules after normalization
+WITH cases(name, src, expected_status, expected_res) AS (
+    VALUES
+    ('step3', decode('61cd80', 'hex'),
+     'SUCCESS', decode('c3a0', 'hex')),
+    ('step4', decode('efac9d', 'hex'),
+     'PROHIBITED', NULL::bytea)
+)
+SELECT name,
+       status,
+       encode(res, 'hex') AS result,
+       status = expected_status
+           AND res IS NOT DISTINCT FROM expected_res AS passed
+FROM cases
+CROSS JOIN LATERAL test_saslprep(src);
+
 DROP EXTENSION test_saslprep;
-- 
2.53.0

