agora inbox for pgsql-bugs@postgresql.org
help / color / mirror / Atom feedBUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp
2+ messages / 2 participants
[nested] [flat]
* BUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp
@ 2026-08-03 06:58 PG Bug reporting form <noreply@postgresql.org>
2026-09-23 19:40 ` Re: BUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 2+ messages in thread
From: PG Bug reporting form @ 2026-08-03 06:58 UTC (permalink / raw)
To: pgsql-bugs@lists.postgresql.org; +Cc: 1217816127@qq.com
The following bug has been logged on the website:
Bug reference: 19602
Logged by: Yuelin Wang
Email address: 1217816127@qq.com
PostgreSQL version: 19beta2
Operating system: Linux (Ubuntu 24.04, x86_64)
Description:
## Vuln46: citext split_part silently returns NULL for a zero field position
instead of raising core split_part's error
### Summary
citext.split_part(citext, citext, int) is implemented in SQL as an array
subscript expression (regexp_split_to_array(...))[$3] rather than by calling
pg_catalog.split_part. Postgres array subscripting silently returns NULL for
an out of range index such as 0, so citext's split_part diverges from core
split_part, which explicitly raises "field position must not be zero" for a
zero field argument.
CWE: CWE-1284. Severity: Low.
### PoC
```sql
CREATE EXTENSION citext;
SELECT split_part('abc~@~def~@~ghi'::citext, '~@~', 0) IS NULL AS is_null_0;
SELECT split_part('abc~@~def~@~ghi'::citext, '~@~', 0);
SELECT pg_catalog.split_part('abc~@~def~@~ghi', '~@~', 0);
```
### Result
Real captured output from the independent verification run:
```
CREATE EXTENSION
is_null_0
-----------
t
(1 row)
split_part
------------
(1 row)
ERROR: field position must not be zero
```
### Impact
An application that relies on split_part raising an error for a zero field
position to catch a programming or input validation bug will instead
silently receive NULL when operating on citext values, potentially masking
the underlying logic error rather than failing loudly.
^ permalink raw reply [nested|flat] 2+ messages in thread
* Re: BUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp
2026-08-03 06:58 BUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp PG Bug reporting form <noreply@postgresql.org>
@ 2026-09-23 19:40 ` Manu <manuelreyesbravo@gmail.com>
0 siblings, 0 replies; 2+ messages in thread
From: Manu @ 2026-09-23 19:40 UTC (permalink / raw)
To: pgsql-bugs@lists.postgresql.org; +Cc: 1217816127@qq.com
Hi,
This reproduces on master, and zero is only one of the positions that
differ. citext's split_part() splits with a case-insensitive regular
expression and subscripts the resulting array, so it was left behind
when ec0294fb2c8 (v14) taught the core split_part() negative positions.
On master:
- split_part('a,b,c'::citext, ',', -1): NULL, core returns 'c'
- split_part('a,b,c'::citext, ',', 4): NULL, core returns ''
- split_part('a,b,c'::citext, ',', 0): NULL, core raises an error
- split_part('abc'::citext, '', 1): 'a', core returns 'abc'
The last one is the empty delimiter: the regular expression splits
between every character, while the core function treats the whole
string as the only field.
The attached patch keeps the case-insensitive split and applies the
core rules to the result: a negative position counts from the end, a
position past the last field gives an empty string. A zero position
and an empty delimiter, which have no case to ignore, are passed to
the core function, so the error message is the same. Since the
function is part of the extension script, this needs citext 1.9.
Tested on master:
- the citext regression tests pass, with new cases for all of the above;
- ALTER EXTENSION citext UPDATE TO '1.9' changes the -1 case from NULL
to 'c' in an existing database;
- on 20000 random lower-case strings, delimiters of up to three
characters (some of them regex metacharacters) and positions from -6
to 6, the result matches the core split_part() in every case.
Regards,
Manu
Attachments:
[text/x-patch] 0001-Make-citext-s-split_part-handle-field-positions-like.patch (7.3K, ../../179019240902.108284.2431649561108446384@gmail.com/2-0001-Make-citext-s-split_part-handle-field-positions-like.patch)
download | inline diff:
From 3af9c0ddaa8c82879528e468fac11e0b8eaf8b4c Mon Sep 17 00:00:00 2001
From: Manu <manuelreyesbravo@gmail.com>
Date: Wed, 23 Sep 2026 16:30:11 -0300
Subject: [PATCH] Make citext's split_part() handle field positions like the
core one
ec0294fb2c8 taught split_part() to count negative positions from the
end and to reject a position of zero. citext's version, which splits
with a case-insensitive regular expression and subscripts the result,
was not updated, so it returned NULL for negative positions, for a
position of zero and for a position past the last field, where the core
function returns the field, an error and an empty string. With an empty
delimiter it also split the string into single characters, where the
core function treats the whole string as the only field.
Handle the positions the same way, and leave a zero position and an
empty delimiter, which have no case to ignore, to the core function.
This needs a new extension version, 1.9.
Reported-by: Yuelin Wang <1217816127@qq.com>
Discussion: https://postgr.es/m/19602-5ec4b4e30fa6f5f2@postgresql.org
---
contrib/citext/Makefile | 1 +
contrib/citext/citext--1.8--1.9.sql | 20 ++++++++++++++
contrib/citext/citext.control | 2 +-
contrib/citext/expected/citext.out | 40 ++++++++++++++++++++++++++++
contrib/citext/expected/citext_1.out | 40 ++++++++++++++++++++++++++++
contrib/citext/meson.build | 1 +
contrib/citext/sql/citext.sql | 8 ++++++
7 files changed, 111 insertions(+), 1 deletion(-)
create mode 100644 contrib/citext/citext--1.8--1.9.sql
diff --git a/contrib/citext/Makefile b/contrib/citext/Makefile
index fc990607bf2..f4db865ced5 100644
--- a/contrib/citext/Makefile
+++ b/contrib/citext/Makefile
@@ -4,6 +4,7 @@ MODULES = citext
EXTENSION = citext
DATA = citext--1.4.sql \
+ citext--1.8--1.9.sql \
citext--1.7--1.8.sql \
citext--1.6--1.7.sql \
citext--1.5--1.6.sql \
diff --git a/contrib/citext/citext--1.8--1.9.sql b/contrib/citext/citext--1.8--1.9.sql
new file mode 100644
index 00000000000..6573f55b5f9
--- /dev/null
+++ b/contrib/citext/citext--1.8--1.9.sql
@@ -0,0 +1,20 @@
+/* contrib/citext/citext--1.8--1.9.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION citext UPDATE TO '1.9'" to load this file. \quit
+
+-- Follow the core split_part(): a negative position counts from the end, a
+-- position past the last field returns an empty string, and a position of
+-- zero is an error. A zero position and an empty delimiter, where there is no
+-- case to ignore, are left to the core function.
+CREATE OR REPLACE FUNCTION split_part( citext, citext, int ) RETURNS TEXT
+LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE
+RETURN CASE
+ WHEN $3 = 0 OR pg_catalog.length($2::pg_catalog.text) = 0 THEN
+ pg_catalog.split_part($1::pg_catalog.text, $2::pg_catalog.text, $3)
+ ELSE
+ (SELECT COALESCE(fields[CASE WHEN $3 > 0 THEN $3
+ ELSE pg_catalog.array_length(fields, 1) + $3 + 1 END],
+ '')
+ FROM (SELECT pg_catalog.regexp_split_to_array( $1::pg_catalog.text, pg_catalog.regexp_replace($2::pg_catalog.text, '([^a-zA-Z_0-9])', E'\\\\\\1', 'g'), 'i') AS fields) AS s)
+END;
diff --git a/contrib/citext/citext.control b/contrib/citext/citext.control
index 2b0f3fa8407..a76f6bb2292 100644
--- a/contrib/citext/citext.control
+++ b/contrib/citext/citext.control
@@ -1,6 +1,6 @@
# citext extension
comment = 'data type for case-insensitive character strings'
-default_version = '1.8'
+default_version = '1.9'
module_pathname = '$libdir/citext'
relocatable = true
trusted = true
diff --git a/contrib/citext/expected/citext.out b/contrib/citext/expected/citext.out
index 8c0bf54f0f3..1e8e7c2a5b1 100644
--- a/contrib/citext/expected/citext.out
+++ b/contrib/citext/expected/citext.out
@@ -2120,6 +2120,46 @@ SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
t
(1 row)
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
+ERROR: field position must not be zero
+CONTEXT: SQL function "split_part" statement 1
SELECT strpos('high'::citext, 'gh' ) = 3 AS t;
t
---
diff --git a/contrib/citext/expected/citext_1.out b/contrib/citext/expected/citext_1.out
index c5e5f180f2b..e6c64d60a67 100644
--- a/contrib/citext/expected/citext_1.out
+++ b/contrib/citext/expected/citext_1.out
@@ -2120,6 +2120,46 @@ SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
t
(1 row)
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+ t
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
+ERROR: field position must not be zero
+CONTEXT: SQL function "split_part" statement 1
SELECT strpos('high'::citext, 'gh' ) = 3 AS t;
t
---
diff --git a/contrib/citext/meson.build b/contrib/citext/meson.build
index 1cc49fc999f..d8aba86ca44 100644
--- a/contrib/citext/meson.build
+++ b/contrib/citext/meson.build
@@ -27,6 +27,7 @@ install_data(
'citext--1.5--1.6.sql',
'citext--1.6--1.7.sql',
'citext--1.7--1.8.sql',
+ 'citext--1.8--1.9.sql',
kwargs: contrib_data_args,
)
diff --git a/contrib/citext/sql/citext.sql b/contrib/citext/sql/citext.sql
index aa1cf9abd5c..ccbcae8d6bd 100644
--- a/contrib/citext/sql/citext.sql
+++ b/contrib/citext/sql/citext.sql
@@ -653,6 +653,14 @@ SELECT split_part('abc~@~def~@~ghi'::citext, '~@~', 2) = 'def' AS t;
SELECT split_part('abcTdefTghi'::citext, 't', 2) = 'def' AS t;
SELECT split_part('abcTdefTghi'::citext, 't'::citext, 2) = 'def' AS t;
SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
SELECT strpos('high'::citext, 'gh' ) = 3 AS t;
SELECT strpos('high', 'gh'::citext) = 3 AS t;
--
2.55.0
^ permalink raw reply [nested|flat] 2+ messages in thread
end of thread, other threads:[~2026-09-23 19:40 UTC | newest]
Thread overview: 2+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-08-03 06:58 BUG #19602: Vuln46: citext split_part silently returns NULL for a zero field position instead of raising core sp PG Bug reporting form <noreply@postgresql.org>
2026-09-23 19:40 ` Manu <manuelreyesbravo@gmail.com>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox