Skip to content

Commit 8f96f31

Browse files
committed
test(postgres): add pg_regress CVE/behavior-change regression suite
Pin this release's CVE and behavior-change fixes as permanent pg_regress tests, and fix the existing operator-gate test so it actually exercises the gate. - operator_breaking_change: use real intarray estimators (_int_matchsel, _int_overlap_joinsel); the previous LANGUAGE-sql fakes failed on 'internal' arguments before ever reaching the superuser gate. - pgcrypto (CVE-2026-2005), pg_trgm (CVE-2026-2006), intarray_ltree_query (CVE-2026-6473), ltree_reindex, hstore_copy_binary, merge_repeatable_read, multirange_create_priv (CVE-2026-6472), create_statistics_priv (CVE-2025-12817). - Skip all on orioledb-17, which is built on a PG 17.6 base that predates these fixes. Verified passing on 15.18 and 17.10; excluded on orioledb-17. Refs: PSQL-1110, PSQL-1234
1 parent 6c6c99f commit 8f96f31

19 files changed

Lines changed: 695 additions & 0 deletions

nix/checks.nix

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@
183183
# Tests to skip for OrioleDB (not compatible with OrioleDB storage)
184184
orioledbSkipTests = [
185185
"index_advisor" # index_advisor doesn't support OrioleDB tables
186+
# The CVE / behavior-change regression tests below pin fixes that
187+
# first landed in 15.16-15.18 / 17.7-17.10. orioledb-17 is built on
188+
# a PG 17.6 base (config.nix: orioledb version "17_16"), which
189+
# predates these fixes, so the post-fix behavior they assert is not
190+
# present here. They run on psql_15 (15.18) and psql_17 (17.10).
191+
# Refs: PSQL-1110, PSQL-1234.
192+
"operator_breaking_change" # CVE-2026-2004 gate (17.8)
193+
"pgcrypto" # CVE-2026-2005 (17.8)
194+
"pg_trgm" # CVE-2026-2006 multibyte (17.8)
195+
"intarray_ltree_query" # CVE-2026-6473 (17.10)
196+
"ltree_reindex" # ltree multibyte fix (17.8/17.10)
197+
"hstore_copy_binary" # hstore recv crash fix (17.x > 17.6)
198+
"merge_repeatable_read" # MERGE 40001 serialization fix
199+
"multirange_create_priv" # CVE-2026-6472 (17.10)
200+
"create_statistics_priv" # CVE-2025-12817 (17.7)
186201
];
187202

188203
# Helper function to filter SQL files based on version
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- CVE-2025-12817: CREATE STATISTICS did not check CREATE privilege on the
2+
-- schema where the statistics object is created, letting a table owner create
3+
-- statistics objects in any schema (naming-conflict / privilege concern).
4+
--
5+
-- Upstream commits: 2393d374 + d202ec1f (PG 15.15), e2fb3dfa (PG 17.7). The fix
6+
-- adds a pg_namespace_aclcheck(namespaceId, GetUserId(), ACL_CREATE).
7+
--
8+
-- Verified as a non-superuser table owner. Refs: PSQL-1110, PSQL-1234.
9+
BEGIN;
10+
CREATE SCHEMA owned_ns;
11+
CREATE SCHEMA forbidden_ns;
12+
-- postgres can create in owned_ns only; it has no rights on forbidden_ns.
13+
GRANT CREATE, USAGE ON SCHEMA owned_ns TO postgres;
14+
SET ROLE postgres;
15+
-- A table postgres owns, in a schema postgres controls.
16+
CREATE TABLE owned_ns.stat_tbl (a int, b int);
17+
INSERT INTO owned_ns.stat_tbl SELECT g % 10, g % 5 FROM generate_series(1, 100) g;
18+
-- Positive control: stats object in owned_ns (postgres has CREATE) is allowed.
19+
CREATE STATISTICS owned_ns.okstat (dependencies) ON a, b FROM owned_ns.stat_tbl;
20+
-- The fix: a stats object targeting a schema where postgres lacks CREATE is denied.
21+
SAVEPOINT no_priv;
22+
CREATE STATISTICS forbidden_ns.badstat (dependencies) ON a, b FROM owned_ns.stat_tbl;
23+
ERROR: permission denied for schema forbidden_ns
24+
ROLLBACK TO SAVEPOINT no_priv;
25+
RESET ROLE;
26+
ROLLBACK;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-- Non-CVE behavior change: the hstore receive function had a NULL-pointer
2+
-- dereference (backend crash) on COPY BINARY of an hstore whose binary form
3+
-- contains a DUPLICATE key where the second occurrence's value is NULL.
4+
--
5+
-- Upstream commits: 63c05e03 (PG 15.x), 0dfbe42d (PG 17.x).
6+
--
7+
-- A normal INSERT cannot reproduce this: hstore de-duplicates on text input, so
8+
-- a stored value never carries a duplicate key into the binary path. We instead
9+
-- hand-craft a COPY-BINARY stream whose single hstore field contains the pair
10+
-- sequence [ 'a' => '1', 'a' => NULL ] and feed it through hstore_recv via
11+
-- COPY ... FROM. Pre-fix this crashed the backend; on the fixed builds the
12+
-- duplicate is de-duplicated and the row loads cleanly.
13+
--
14+
-- pg_regress runs as the superuser supabase_admin, so lo_export / server-side
15+
-- COPY FROM a file are permitted. Refs: PSQL-1110, PSQL-1234.
16+
BEGIN;
17+
CREATE TABLE hstore_dst (h hstore);
18+
-- Materialise the crafted COPY-BINARY stream to a file (created and exported in
19+
-- separate statements so the large object is visible to lo_export).
20+
SELECT lo_from_bytea(81000,
21+
'\x5047434f50590aff0d0a00'::bytea || -- COPY binary signature
22+
'\x00000000'::bytea || '\x00000000'::bytea || -- flags + header-extension length
23+
'\x0001'::bytea || '\x00000017'::bytea || -- one row, one field of length 23
24+
'\x00000002'::bytea || -- hstore: 2 pairs
25+
'\x00000001'::bytea||'\x61'::bytea||'\x00000001'::bytea||'\x31'::bytea || -- 'a' => '1'
26+
'\x00000001'::bytea||'\x61'::bytea||'\xffffffff'::bytea || -- 'a' => NULL
27+
'\xffff'::bytea) AS loid; -- COPY trailer
28+
loid
29+
-------
30+
81000
31+
(1 row)
32+
33+
SELECT lo_export(81000, '/tmp/pg_regress_hstore_dup.bin') AS exported;
34+
exported
35+
----------
36+
1
37+
(1 row)
38+
39+
-- Must not crash the backend; the duplicate key is de-duplicated on receive.
40+
COPY hstore_dst FROM '/tmp/pg_regress_hstore_dup.bin' WITH (FORMAT binary);
41+
SELECT h AS received, akeys(h) AS keys FROM hstore_dst;
42+
received | keys
43+
----------+------
44+
"a"=>"1" | {a}
45+
(1 row)
46+
47+
ROLLBACK;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- CVE-2026-6473: memory-allocation overflow umbrella covering, among others,
2+
-- contrib intarray query_int and contrib ltree ltxtquery / lquery parsing.
3+
--
4+
-- Upstream key commits: 84a9f264 (intarray/ltree), 9c2fa5b6 (ltree lquery) on
5+
-- PG 15.18; c4d04cc4 and siblings on PG 17.10. Full list:
6+
-- git log REL_17_6..REL_17_10 --grep='CVE-2026-6473'
7+
--
8+
-- Functional regression: well-formed queries parse and match correctly; a
9+
-- malformed query raises a clean parse error instead of crashing.
10+
--
11+
-- Refs: PSQL-1110, PSQL-1234.
12+
BEGIN;
13+
-- 1) intarray query_int matching.
14+
SELECT '{1,2,3}'::int[] @@ '2&4'::query_int AS q_and; -- expect false
15+
q_and
16+
-------
17+
f
18+
(1 row)
19+
20+
SELECT '{1,2,3}'::int[] @@ '2|4'::query_int AS q_or; -- expect true
21+
q_or
22+
------
23+
t
24+
(1 row)
25+
26+
-- 2) ltree lquery and ltxtquery matching.
27+
SELECT 'Top.Science.Astronomy'::ltree ~ 'Top.*.Astronomy'::lquery AS lquery_match; -- true
28+
lquery_match
29+
--------------
30+
t
31+
(1 row)
32+
33+
SELECT 'Top.Science.Astronomy'::ltree @ 'Astronomy & Top'::ltxtquery AS ltxtquery_match; -- true
34+
ltxtquery_match
35+
-----------------
36+
t
37+
(1 row)
38+
39+
-- 3) A malformed query_int must raise a clean parse error, not crash.
40+
SAVEPOINT bad_query;
41+
SELECT '{1}'::int[] @@ '2&&'::query_int;
42+
ERROR: syntax error
43+
LINE 1: SELECT '{1}'::int[] @@ '2&&'::query_int;
44+
^
45+
ROLLBACK TO SAVEPOINT bad_query;
46+
ROLLBACK;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
-- Non-CVE behavior change (highest customer blast radius this release: ltree is
2+
-- enabled on 2,245 projects). Two upstream commits fix multibyte handling in
3+
-- ltree's case-insensitive label matching, so GiST indexes built under the old
4+
-- logic must be REINDEXed after upgrade on multibyte / ICU databases:
5+
--
6+
-- 335b2f30 (PG 15.16) + 2b993167 (PG 15.18) "Fix multibyte issues in ltree"
7+
-- b8cfe9dc (PG 17.8) + d1bd9a7d (PG 17.10)
8+
--
9+
-- This pins WITHIN-version GiST index correctness + REINDEX idempotence. The
10+
-- cross-version pre-upgrade-build / post-upgrade-REINDEX leg is covered by A3
11+
-- (pg_upgrade migration tests, PSQL-1235).
12+
--
13+
-- Refs: PSQL-1110, PSQL-1234.
14+
BEGIN;
15+
CREATE TABLE ltree_mb (id int, path ltree);
16+
INSERT INTO ltree_mb VALUES
17+
(1, 'Top.Naïve.Café'),
18+
(2, 'Top.Science.Astronomy'),
19+
(3, 'Top.Résumé');
20+
CREATE INDEX ltree_mb_gist ON ltree_mb USING gist (path);
21+
SET enable_seqscan = off;
22+
-- Index search before REINDEX.
23+
SELECT id, path FROM ltree_mb WHERE path ~ 'Top.*'::lquery ORDER BY id;
24+
id | path
25+
----+-----------------------
26+
1 | Top.Naïve.Café
27+
2 | Top.Science.Astronomy
28+
3 | Top.Résumé
29+
(3 rows)
30+
31+
-- REINDEX (plain, so it runs inside the transaction) must not corrupt the index.
32+
REINDEX INDEX ltree_mb_gist;
33+
-- Same search after REINDEX must return the identical set.
34+
SELECT id, path FROM ltree_mb WHERE path ~ 'Top.*'::lquery ORDER BY id;
35+
id | path
36+
----+-----------------------
37+
1 | Top.Naïve.Café
38+
2 | Top.Science.Astronomy
39+
3 | Top.Résumé
40+
(3 rows)
41+
42+
-- The '@' label modifier makes the match case-insensitive, which is what
43+
-- invokes the multibyte ltree_strncasecmp path the upstream commits fixed.
44+
SELECT id FROM ltree_mb WHERE path ~ 'top@.*'::lquery ORDER BY id;
45+
id
46+
----
47+
1
48+
2
49+
3
50+
(3 rows)
51+
52+
RESET enable_seqscan;
53+
ROLLBACK;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Non-CVE behavior change: MERGE now correctly raises a serialization failure
2+
-- (SQLSTATE 40001) under REPEATABLE READ / SERIALIZABLE when it hits a
3+
-- concurrently-updated tuple (previously this could be silently mishandled).
4+
--
5+
-- This pins the single-session HAPPY PATH only: MERGE under REPEATABLE READ
6+
-- still produces correct results. The actual concurrent-conflict (40001) case
7+
-- needs two concurrent sessions via the isolation tester, tracked in (PSQL-1277)
8+
-- since pg_isolation_regress is not wired into nix/checks.nix yet.
9+
--
10+
-- Refs: PSQL-1110, PSQL-1234, PSQL-1277.
11+
BEGIN ISOLATION LEVEL REPEATABLE READ;
12+
CREATE TABLE merge_target (id int PRIMARY KEY, v int);
13+
CREATE TABLE merge_source (id int, v int);
14+
INSERT INTO merge_target VALUES (1, 10), (2, 20);
15+
INSERT INTO merge_source VALUES (1, 100), (3, 300);
16+
MERGE INTO merge_target t
17+
USING merge_source s ON t.id = s.id
18+
WHEN MATCHED THEN UPDATE SET v = s.v
19+
WHEN NOT MATCHED THEN INSERT (id, v) VALUES (s.id, s.v);
20+
-- Expect: id 1 updated to 100, id 2 untouched (20), id 3 inserted (300).
21+
SELECT id, v FROM merge_target ORDER BY id;
22+
id | v
23+
----+-----
24+
1 | 100
25+
2 | 20
26+
3 | 300
27+
(3 rows)
28+
29+
ROLLBACK;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- CVE-2026-6472: CREATE TYPE ... AS RANGE auto-creates a companion multirange
2+
-- type. When the multirange type name was given EXPLICITLY, the schema CREATE
3+
-- privilege for that name was not validated (the auto-generated-name path was
4+
-- already checked), letting a role create a multirange type in any schema.
5+
--
6+
-- Upstream commits: 08c397b0 (PG 15.18), c27ba08c (PG 17.10). The fix adds a
7+
-- pg_namespace_aclcheck(multirangeNamespace, GetUserId(), ACL_CREATE).
8+
--
9+
-- Verified as a non-superuser. Refs: PSQL-1110, PSQL-1234.
10+
BEGIN;
11+
CREATE SCHEMA allowed_ns;
12+
CREATE SCHEMA forbidden_ns;
13+
-- postgres gets CREATE on allowed_ns only; it has no rights on forbidden_ns.
14+
GRANT CREATE, USAGE ON SCHEMA allowed_ns TO postgres;
15+
SET ROLE postgres;
16+
-- Positive control: explicit multirange name in a schema postgres CAN create in.
17+
CREATE TYPE allowed_ns.okrange AS RANGE (
18+
subtype = int4,
19+
multirange_type_name = allowed_ns.okmultirange
20+
);
21+
-- The fix: an explicit multirange name targeting a schema where postgres lacks
22+
-- CREATE must now be denied.
23+
SAVEPOINT no_priv;
24+
CREATE TYPE allowed_ns.badrange AS RANGE (
25+
subtype = int4,
26+
multirange_type_name = forbidden_ns.badmultirange
27+
);
28+
ERROR: permission denied for schema forbidden_ns
29+
ROLLBACK TO SAVEPOINT no_priv;
30+
RESET ROLE;
31+
ROLLBACK;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
-- Pin CVE-2026-2004 behaviour: attaching a non-built-in selectivity estimator
2+
-- to an operator requires superuser. Verified against both RESTRICT and JOIN.
3+
--
4+
-- Upstream commits: b764b26f (PG 15.16), bbf5bcf5 (PG 17.8). The check fires in
5+
-- both ValidateRestrictionEstimator() and ValidateJoinEstimator() in
6+
-- src/backend/commands/operatorcmds.c.
7+
--
8+
-- We use real non-built-in estimators shipped by intarray (_int_matchsel for
9+
-- RESTRICT, _int_overlap_joinsel for JOIN) -- these are exactly the customer-
10+
-- reachable estimators the CVE-2026-2004 fleet-scan query targets.
11+
--
12+
-- Refs: PSQL-1110, PSQL-1234.
13+
BEGIN;
14+
-- A schema the non-superuser controls, so CREATE OPERATOR reaches the estimator
15+
-- validation rather than failing an earlier schema-permission check.
16+
CREATE SCHEMA op_ns;
17+
GRANT CREATE, USAGE ON SCHEMA op_ns TO postgres;
18+
-- Trivial boolean procedure for the operator (no internal args -> valid in SQL).
19+
CREATE FUNCTION op_ns.fake_op_proc(_int4, _int4)
20+
RETURNS bool LANGUAGE sql IMMUTABLE AS $$ SELECT true $$;
21+
-- Switch to a non-superuser role.
22+
SET ROLE postgres;
23+
-- 1) RESTRICT = non-built-in estimator should be rejected.
24+
SAVEPOINT before_restrict;
25+
CREATE OPERATOR op_ns.@@@ (
26+
LEFTARG = _int4, RIGHTARG = _int4,
27+
PROCEDURE = op_ns.fake_op_proc,
28+
RESTRICT = _int_matchsel
29+
);
30+
ERROR: must be superuser to specify a non-built-in restriction estimator function
31+
ROLLBACK TO SAVEPOINT before_restrict;
32+
-- 2) JOIN = non-built-in estimator should be rejected.
33+
SAVEPOINT before_join;
34+
CREATE OPERATOR op_ns.@@@ (
35+
LEFTARG = _int4, RIGHTARG = _int4,
36+
PROCEDURE = op_ns.fake_op_proc,
37+
JOIN = _int_overlap_joinsel
38+
);
39+
ERROR: must be superuser to specify a non-built-in join estimator function
40+
ROLLBACK TO SAVEPOINT before_join;
41+
-- 3) Sanity check: built-in selectivity estimators still work for non-superusers.
42+
CREATE OPERATOR op_ns.@@@ (
43+
LEFTARG = _int4, RIGHTARG = _int4,
44+
PROCEDURE = op_ns.fake_op_proc,
45+
RESTRICT = eqsel,
46+
JOIN = eqjoinsel
47+
);
48+
DROP OPERATOR op_ns.@@@ (_int4, _int4);
49+
RESET ROLE;
50+
ROLLBACK;

nix/tests/expected/pg_trgm.out

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- CVE-2026-2006: multibyte length validation via bounds-checked pg_mblen()
2+
-- variants. Affects every multibyte text path, including pg_trgm.
3+
--
4+
-- Upstream commits: fd82ddb6, 50863be0, b2c81ac8, 8f8b1ffa (PG 15.16);
5+
-- 319e8a64, 7a522039, 838248b1, dc072a09 (PG 17.8).
6+
--
7+
-- Functional regression: trigram generation, similarity, and GIN index search
8+
-- all return correct results on multibyte (UTF-8) input on the fixed builds.
9+
--
10+
-- Refs: PSQL-1110, PSQL-1234.
11+
BEGIN;
12+
-- 1) show_trgm() on a multibyte (UTF-8) string returns well-formed trigrams.
13+
SELECT show_trgm('café');
14+
show_trgm
15+
-------------------------------------
16+
{0xef5960," c"," ca",0x544980,caf}
17+
(1 row)
18+
19+
-- 2) similarity() of two multibyte strings is positive and symmetric.
20+
SELECT similarity('café', 'café') AS self_sim,
21+
similarity('café', 'cafe') = similarity('cafe', 'café') AS symmetric;
22+
self_sim | symmetric
23+
----------+-----------
24+
1 | t
25+
(1 row)
26+
27+
-- 3) A GIN trigram index on multibyte data returns the correct match set.
28+
CREATE TABLE trgm_mb (id int, t text);
29+
INSERT INTO trgm_mb VALUES (1, 'café'), (2, 'naïve'), (3, 'résumé');
30+
CREATE INDEX trgm_mb_idx ON trgm_mb USING gin (t gin_trgm_ops);
31+
SET enable_seqscan = off;
32+
SELECT id, t FROM trgm_mb WHERE t % 'café' ORDER BY id;
33+
id | t
34+
----+------
35+
1 | café
36+
(1 row)
37+
38+
RESET enable_seqscan;
39+
ROLLBACK;

nix/tests/expected/pgcrypto.out

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- CVE-2026-2005: heap buffer overflow in pgcrypto's pgp_*_decrypt_bytea() on an
2+
-- oversized PGP session-key length. The fix hardens the PGP packet-length parser
3+
-- shared by the symmetric and public-key bytea decrypt paths.
4+
--
5+
-- Upstream commits: 9a9982ec (PG 15.16), 7a7d9693 (PG 17.8).
6+
-- pgcrypto is default-enabled on Supabase, so this path is customer-reachable.
7+
--
8+
-- This is a functional + crash-safety regression: a valid round-trip still works,
9+
-- and a malformed PGP packet raises a clean SQL error instead of crashing the
10+
-- backend. (The public-key variant needs externally-generated GPG keys, so we
11+
-- exercise the shared packet parser via the symmetric bytea path.)
12+
--
13+
-- Refs: PSQL-1110, PSQL-1234.
14+
BEGIN;
15+
-- 1) Happy path: symmetric PGP bytea round-trip returns the original plaintext.
16+
SELECT pgp_sym_decrypt_bytea(
17+
pgp_sym_encrypt_bytea('\xdeadbeef'::bytea, 'test-key'),
18+
'test-key') = '\xdeadbeef'::bytea AS roundtrip_ok;
19+
roundtrip_ok
20+
--------------
21+
t
22+
(1 row)
23+
24+
-- 2) A malformed PGP packet must raise a clean error, not crash the backend
25+
-- (exercises the hardened packet-length parser).
26+
SAVEPOINT malformed;
27+
SELECT pgp_sym_decrypt_bytea('\xdeadbeefcafebabe'::bytea, 'test-key');
28+
ERROR: Wrong key or corrupt data
29+
ROLLBACK TO SAVEPOINT malformed;
30+
-- 3) Backend is still alive and pgcrypto still works after the error.
31+
SELECT pgp_sym_decrypt(pgp_sym_encrypt('still-here', 'k'), 'k') AS post_error_ok;
32+
post_error_ok
33+
---------------
34+
still-here
35+
(1 row)
36+
37+
ROLLBACK;

0 commit comments

Comments
 (0)