diff --git a/nix/checks.nix b/nix/checks.nix index 875ca9f81f..80880e47b3 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -187,6 +187,7 @@ "output_plugin_libraries" # needs wal_level=logical + logical-decoding infra, not exercised in the CLI variant "btree_gist_nan" # needs btree_gist, not in the CLI prime file "hstore_copy_binary" # needs hstore + "bpchar_btree_dedup" # needs pageinspect, not in the CLI prime file "pgrst_watch_triggers" # needs dblink, not in the CLI prime file # Version-specific extension tests "z_17_ext_interface" diff --git a/nix/config.nix b/nix/config.nix index dc11345d7b..7c62b5c8d8 100644 --- a/nix/config.nix +++ b/nix/config.nix @@ -46,12 +46,12 @@ in supportedPostgresVersions = { postgres = { "15" = { - version = "15.19"; - hash = "sha256-4aZKh6RrgluIwILkUYFhpHqrU8RWlJZPi6HfKPeFn4k="; + version = "15.20"; + hash = "sha256-MOZcu7dxo5tVPtLffmViLmVkeu1V28Gb0P6FP/CghEI="; }; "17" = { - version = "17.11"; - hash = "sha256-3Sfys8Wec+0UqjMkkBJCv2mgMqY0eAXydOYmAyLUKXk="; + version = "17.12"; + hash = "sha256-nPE2pwPY7i+DRlxDvk6ejnkwyCsL+6E75+JTe+dprFU="; }; }; orioledb = { diff --git a/nix/tests/expected/alter_column_type_rowtype_deps.out b/nix/tests/expected/alter_column_type_rowtype_deps.out new file mode 100644 index 0000000000..3eed70c100 --- /dev/null +++ b/nix/tests/expected/alter_column_type_rowtype_deps.out @@ -0,0 +1,33 @@ +-- Fixes CVE-2026-6460. +-- +-- What changed: after a column type change, stored expressions could still read the table's row type in its old layout. +-- That could crash the backend or expose memory. +-- Postgres now refuses ALTER COLUMN TYPE when certain stored code uses the row type. +-- It also adds the composite_type_mismatch setting. +-- +-- Who notices: migrations such as `ALTER TABLE profiles ALTER COLUMN x TYPE ...`. +-- They now fail with "cannot alter table ... because function ... uses its row type" if the row type appears in any of these: +-- - a BEGIN ATOMIC function body +-- - a default for a function argument or a column +-- - an index expression on another table +-- To fix: drop the dependent object, run the ALTER, then recreate the object. +-- Functions that only take or return the row type are fine, for example `RETURNS SETOF profiles` for a PostgREST RPC. +-- +-- Upstream commits: 22762f03a and others (PG 15.20), bfcb6625e and others (PG 17.12). +BEGIN; +-- The new GUC exists. +SELECT count(*) AS has_guc FROM pg_settings WHERE name = 'composite_type_mismatch'; + has_guc +--------- + 1 +(1 row) + +CREATE TABLE act_tbl (a bytea); +CREATE FUNCTION act_func() RETURNS act_tbl + IMMUTABLE LANGUAGE sql BEGIN ATOMIC SELECT '(\\x4649584d45)'::act_tbl; END; +-- Must now be rejected. +SAVEPOINT s; +ALTER TABLE act_tbl ALTER COLUMN a TYPE text; +ERROR: cannot alter table "act_tbl" because function act_func() uses its row type +ROLLBACK TO SAVEPOINT s; +ROLLBACK; diff --git a/nix/tests/expected/bpchar_btree_dedup.out b/nix/tests/expected/bpchar_btree_dedup.out new file mode 100644 index 0000000000..90eb11b603 --- /dev/null +++ b/nix/tests/expected/bpchar_btree_dedup.out @@ -0,0 +1,36 @@ +-- What changed: btree deduplication isn't safe for bpchar, because bpchar equality ignores trailing spaces. +-- In rare cases index-only scans returned wrong results. +-- Postgres no longer deduplicates bpchar_ops indexes. +-- New indexes report allequalimage = false, which this test checks. +-- +-- Who notices: anyone with a btree index on a `bpchar` or `char(n)` column, including primary keys and unique constraints. +-- Upstream recommends a REINDEX after the upgrade. +-- Until then, amcheck's bt_index_check() reports these indexes as corrupt ("metapage incorrectly indicates that deduplication is safe"). +-- Expect support tickets about it. +-- This query lists the indexes to rebuild: +-- +-- SELECT DISTINCT i.indexrelid::regclass FROM pg_index i +-- JOIN pg_class c ON c.oid = i.indexrelid +-- JOIN pg_am am ON am.oid = c.relam AND am.amname = 'btree' +-- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) +-- WHERE a.atttypid = 'bpchar'::regtype; +-- +-- Upstream commits: de0d4f0b1 (PG 15.20), 74f26a193 (PG 17.12). +BEGIN; +CREATE TABLE bpchar_dedup (b bpchar, c char(10), t text) USING heap; +CREATE INDEX bpchar_dedup_b ON bpchar_dedup (b); +CREATE INDEX bpchar_dedup_c ON bpchar_dedup (c); +CREATE INDEX bpchar_dedup_t ON bpchar_dedup (t); +-- bpchar and char(n) report false after the fix. +-- text still reports true. +SELECT 'b' AS col, allequalimage FROM bt_metap('bpchar_dedup_b') +UNION ALL SELECT 'c', allequalimage FROM bt_metap('bpchar_dedup_c') +UNION ALL SELECT 't', allequalimage FROM bt_metap('bpchar_dedup_t'); + col | allequalimage +-----+--------------- + b | f + c | f + t | t +(3 rows) + +ROLLBACK; diff --git a/nix/tests/expected/cast_function_execute_privilege.out b/nix/tests/expected/cast_function_execute_privilege.out new file mode 100644 index 0000000000..2747c36736 --- /dev/null +++ b/nix/tests/expected/cast_function_execute_privilege.out @@ -0,0 +1,27 @@ +-- Fixes CVE-2026-116303. +-- +-- What changed: to_json() and to_jsonb() called a user-defined cast function without checking EXECUTE on it. +-- They now check, like normal cast evaluation. +-- +-- Who notices: projects that meet both conditions: +-- - they defined a custom cast to json or jsonb +-- - they revoked EXECUTE on its function from PUBLIC, for example with a blanket revoke from anon and authenticated +-- Those roles now get "permission denied for function ..." when they convert the type to JSON. +-- That includes PostgREST responses. +-- To fix: grant EXECUTE on the cast function. +-- +-- Upstream commits: 879c760d8 (PG 15.20), 9520dbea7 (PG 17.12). +BEGIN; +CREATE TYPE cast_priv_enum AS ENUM ('a'); +CREATE FUNCTION cast_priv_enum_to_json(cast_priv_enum) RETURNS json + LANGUAGE sql IMMUTABLE AS $$ SELECT to_json($1::text) $$; +CREATE CAST (cast_priv_enum AS json) WITH FUNCTION cast_priv_enum_to_json(cast_priv_enum); +REVOKE ALL ON FUNCTION cast_priv_enum_to_json(cast_priv_enum) FROM PUBLIC; +SET ROLE postgres; +-- Must be denied. +SAVEPOINT s; +SELECT to_json('a'::cast_priv_enum); +ERROR: permission denied for function cast_priv_enum_to_json +ROLLBACK TO SAVEPOINT s; +RESET ROLE; +ROLLBACK; diff --git a/nix/tests/expected/drop_owned_rls_policy.out b/nix/tests/expected/drop_owned_rls_policy.out new file mode 100644 index 0000000000..b35bbdd3fc --- /dev/null +++ b/nix/tests/expected/drop_owned_rls_policy.out @@ -0,0 +1,28 @@ +-- Fixes CVE-2026-116311. +-- +-- What changed: `DROP OWNED BY some_role` could remove the role from RLS policies on tables the caller doesn't own. +-- A role could use this to escape a restrictive policy. +-- Postgres now leaves those policies alone and issues a WARNING. +-- +-- Who notices: clean-up scripts that run `DROP OWNED BY some_role` as a role that doesn't own every affected table. +-- The policies stay, so a later `DROP ROLE some_role` can fail on the remaining dependency. +-- To fix: run the clean-up as the table owner (on Supabase, usually `postgres`), or remove the role from those policies first. +-- +-- Upstream commits: 1f2dd21c1 (PG 15.20), 21c732aad (PG 17.12). +BEGIN; +CREATE ROLE regress_dob_role NOLOGIN; +CREATE TABLE dob_tbl (c int); +ALTER TABLE dob_tbl ENABLE ROW LEVEL SECURITY; +CREATE POLICY dob_restrict ON dob_tbl AS RESTRICTIVE TO regress_dob_role USING (c > 0); +SET ROLE regress_dob_role; +DROP OWNED BY regress_dob_role; -- should warn +WARNING: role "regress_dob_role" could not be removed from policy "dob_restrict" for table "dob_tbl" +RESET ROLE; +-- Policy must still apply to regress_dob_role. +SELECT polname, polroles::regrole[] FROM pg_policy WHERE polrelid = 'dob_tbl'::regclass; + polname | polroles +--------------+-------------------- + dob_restrict | {regress_dob_role} +(1 row) + +ROLLBACK; diff --git a/nix/tests/expected/function_def_target_alias.out b/nix/tests/expected/function_def_target_alias.out new file mode 100644 index 0000000000..f178bf10a9 --- /dev/null +++ b/nix/tests/expected/function_def_target_alias.out @@ -0,0 +1,32 @@ +-- What changed: pg_get_functiondef(), pg_get_ruledef() and \d+ now always print AS before a target-table alias in UPDATE and DELETE. +-- So `UPDATE t x` becomes `UPDATE t AS x`. +-- Without AS, an alias named `set` produced a definition that failed to restore. +-- +-- Who notices: queries run the same. +-- Schema-diff tools compare these printed definitions, though. +-- So `supabase db diff`, `supabase db pull` and migra may report changes to unchanged BEGIN ATOMIC functions and rules right after the upgrade. +-- +-- Upstream commits: 697e1d3d5 (PG 15.20), 91246c726 (PG 17.12). +BEGIN; +CREATE TABLE fdta_tbl (id int, val int); +CREATE FUNCTION fdta_func() RETURNS void LANGUAGE sql +BEGIN ATOMIC + UPDATE fdta_tbl t SET val = t.val + 1 WHERE t.id = 1; + DELETE FROM fdta_tbl d WHERE d.id = 2; +END; +SELECT pg_get_functiondef('fdta_func'::regproc); + pg_get_functiondef +----------------------------------------------- + CREATE OR REPLACE FUNCTION public.fdta_func()+ + RETURNS void + + LANGUAGE sql + + BEGIN ATOMIC + + UPDATE fdta_tbl AS t SET val = (t.val + 1) + + WHERE (t.id = 1); + + DELETE FROM fdta_tbl AS d + + WHERE (d.id = 2); + + END + + +(1 row) + +ROLLBACK; diff --git a/nix/tests/expected/jsonpath_gin_lookup.out b/nix/tests/expected/jsonpath_gin_lookup.out new file mode 100644 index 0000000000..9f33f6eda6 --- /dev/null +++ b/nix/tests/expected/jsonpath_gin_lookup.out @@ -0,0 +1,64 @@ +-- What changed: jsonpath searches with `@@` and `@?` on a jsonb_ops GIN index missed matching rows in these cases: +-- - a top-level scalar, for example `strict $ == "a scalar"` +-- - a method on a literal, for example `"25".double()` +-- - an AND inside a NOT +-- The test checks that index scans find the same rows as a sequential scan. +-- +-- Who notices: these queries may return more rows after the upgrade (the correct ones). +-- Before, the result depended on whether the planner chose the index. +-- Existing indexes don't need a REINDEX. +-- +-- Upstream commits: 3b2a60481 + 04a8915fa (PG 15.20), 19656b77d + 65f1b71c3 (PG 17.12). +BEGIN; +CREATE TABLE jgin (j jsonb) USING heap; +INSERT INTO jgin VALUES + ('"a scalar"'), + ('{"age": 25}'), + ('{"age": 25.0}'), + ('{"bad": false, "coauthors": 98}'), + ('{"bad": true, "coauthors": 98}'), + ('{"bad": false, "coauthors": 1}'), + ('{}'); +CREATE INDEX jgin_idx ON jgin USING gin (j jsonb_ops); +-- Ground truth (seqscan). +SET LOCAL enable_bitmapscan = off; +SELECT count(*) FROM jgin WHERE j @@ 'strict $ == "a scalar"'; + count +------- + 1 +(1 row) + +SELECT count(*) FROM jgin WHERE j @@ '$.age == "25".double()'; + count +------- + 2 +(1 row) + +SELECT count(*) FROM jgin WHERE j @@ '!(!($.bad == false) && $.coauthors == 98)'; + count +------- + 6 +(1 row) + +-- Through the GIN index: must match. +SET LOCAL enable_bitmapscan = on; +SET LOCAL enable_seqscan = off; +SELECT count(*) FROM jgin WHERE j @@ 'strict $ == "a scalar"'; + count +------- + 1 +(1 row) + +SELECT count(*) FROM jgin WHERE j @@ '$.age == "25".double()'; + count +------- + 2 +(1 row) + +SELECT count(*) FROM jgin WHERE j @@ '!(!($.bad == false) && $.coauthors == 98)'; + count +------- + 6 +(1 row) + +ROLLBACK; diff --git a/nix/tests/expected/partition_prune_range.out b/nix/tests/expected/partition_prune_range.out new file mode 100644 index 0000000000..d040918513 --- /dev/null +++ b/nix/tests/expected/partition_prune_range.out @@ -0,0 +1,81 @@ +-- What changed: RANGE partition pruning could skip partitions that held matching rows, so queries silently returned too few rows. +-- Two cases: +-- - a multi-column partition key, with filters on only the leading columns +-- - a DEFAULT partition next to a partition bound that uses MINVALUE or MAXVALUE +-- The test checks that counts match those with enable_partition_pruning off. +-- +-- Who notices: projects with tables like these, for example partitioned by (tenant_id, created_at). +-- Affected queries may return more rows after the upgrade (the correct ones). +-- All rows sit in the right partitions, so no data needs fixing. +-- +-- Upstream commits: 1a501b6e2 + 9b8c0f238 (PG 15.20), 2d8494e24 + d21d4f1ca (PG 17.12). +BEGIN; +-- Multi-column RANGE, qual on the leading column only. +CREATE TABLE ppr_mc (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_mc1 PARTITION OF ppr_mc FOR VALUES FROM (1, 4) TO (1, 7); +CREATE TABLE ppr_mc2 PARTITION OF ppr_mc FOR VALUES FROM (1, 7) TO (3, 8); +CREATE TABLE ppr_mc3 PARTITION OF ppr_mc FOR VALUES FROM (4, 8) TO (6, 9); +CREATE TABLE ppr_mc_def PARTITION OF ppr_mc DEFAULT; +INSERT INTO ppr_mc VALUES (1, 5), (1, 8), (2, 0), (5, 8), (0, 0); +-- DEFAULT partition next to a MAXVALUE bound. +CREATE TABLE ppr_max (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_max1 PARTITION OF ppr_max FOR VALUES FROM (13, 0) TO (19, MAXVALUE); +CREATE TABLE ppr_max_def PARTITION OF ppr_max DEFAULT; +INSERT INTO ppr_max VALUES (15, 1), (32, 0), (25, 3); +-- DEFAULT partition next to a MINVALUE bound. +CREATE TABLE ppr_min (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_min1 PARTITION OF ppr_min FOR VALUES FROM (13, MINVALUE) TO (19, 5); +CREATE TABLE ppr_min_def PARTITION OF ppr_min DEFAULT; +INSERT INTO ppr_min VALUES (15, 1), (5, 0), (13, 3); +-- With pruning. +SELECT count(*) FROM ppr_mc WHERE a <= 1; -- (1,5), (1,8), (0,0) + count +------- + 3 +(1 row) + +SELECT count(*) FROM ppr_max WHERE a = 32 AND b >= -7; -- (32,0) + count +------- + 1 +(1 row) + +SELECT count(*) FROM ppr_max WHERE a >= 25 AND b >= 0; -- (32,0), (25,3) + count +------- + 2 +(1 row) + +SELECT count(*) FROM ppr_min WHERE a = 5 AND b <= 7; -- (5,0) + count +------- + 1 +(1 row) + +-- Without pruning: must match. +SET LOCAL enable_partition_pruning = off; +SELECT count(*) FROM ppr_mc WHERE a <= 1; + count +------- + 3 +(1 row) + +SELECT count(*) FROM ppr_max WHERE a = 32 AND b >= -7; + count +------- + 1 +(1 row) + +SELECT count(*) FROM ppr_max WHERE a >= 25 AND b >= 0; + count +------- + 2 +(1 row) + +SELECT count(*) FROM ppr_min WHERE a = 5 AND b <= 7; + count +------- + 1 +(1 row) + +ROLLBACK; diff --git a/nix/tests/expected/to_char_v_overflow.out b/nix/tests/expected/to_char_v_overflow.out new file mode 100644 index 0000000000..f9d9381f24 --- /dev/null +++ b/nix/tests/expected/to_char_v_overflow.out @@ -0,0 +1,37 @@ +-- What changed: to_char() with the V format multiplies by a power of ten. +-- For example, '999V99' turns 1.23 into 123. +-- With integer input, if the result overflowed the type, to_char() printed wrong digits without an error. +-- Now it fails with "integer out of range". +-- The test cases come from upstream int4.sql. +-- +-- Who notices: formatting code that uses V on large integers, for example to show amounts in cents. +-- It used to print wrong values, sometimes negative. +-- Now it fails. +-- To fix: cast the input to numeric first. +-- +-- Upstream commits: 772251744 (PG 15.20), 04c25af21 (PG 17.12). +SELECT to_char(2, '9V999999999'); -- 2 * 10^9 fits + to_char +------------- + 2000000000 +(1 row) + +SELECT to_char(214748364, '999999999V9'); -- 2147483640 fits + to_char +------------- + 2147483640 +(1 row) + +SELECT to_char(-2, '9V999999999'); -- fits + to_char +------------- + -2000000000 +(1 row) + +-- These overflow int4 and must now error. +SELECT to_char(3, '9V999999999'); +ERROR: integer out of range +SELECT to_char(2147483647, '9V9'); +ERROR: integer out of range +SELECT to_char(1, '9V9999999999'); +ERROR: integer out of range diff --git a/nix/tests/expected/upsert_rls_conflict_where.out b/nix/tests/expected/upsert_rls_conflict_where.out new file mode 100644 index 0000000000..5dddf465b2 --- /dev/null +++ b/nix/tests/expected/upsert_rls_conflict_where.out @@ -0,0 +1,30 @@ +-- Fixes CVE-2026-120154. +-- +-- What changed: on a table with RLS, Postgres evaluated an upsert's ON CONFLICT ... WHERE clause before the RLS USING policies. +-- A function in that clause could read rows the caller isn't allowed to see. +-- Postgres now checks the policies first, so nothing leaks. +-- +-- Who notices: normal upserts behave the same. +-- The fix closes a way to read other users' rows on RLS-protected tables. +-- In this test, the function prints the row it sees; that NOTICE must no longer appear. +-- +-- Upstream commits: 48be6f9e8 (PG 15.20), 9c677791d (PG 17.12). +BEGIN; +CREATE TABLE rls_upsert_doc (id int PRIMARY KEY, owner name, body text); +INSERT INTO rls_upsert_doc VALUES (1, 'someone_else', 'secret'); +ALTER TABLE rls_upsert_doc ENABLE ROW LEVEL SECURITY; +CREATE POLICY p_all ON rls_upsert_doc USING (owner = current_user); +GRANT SELECT, INSERT, UPDATE ON rls_upsert_doc TO postgres; +CREATE FUNCTION rls_upsert_leak(text) RETURNS boolean + LANGUAGE plpgsql COST 0.0000001 + AS $$ BEGIN RAISE NOTICE 'leaked => %', $1; RETURN true; END $$; +SET ROLE postgres; +-- Must fail without a "leaked => secret" NOTICE. +SAVEPOINT s; +INSERT INTO rls_upsert_doc VALUES (1, 'postgres', 'x') + ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body + WHERE rls_upsert_leak(rls_upsert_doc.body); +ERROR: new row violates row-level security policy (USING expression) for table "rls_upsert_doc" +ROLLBACK TO SAVEPOINT s; +RESET ROLE; +ROLLBACK; diff --git a/nix/tests/expected/upsert_view_visibility.out b/nix/tests/expected/upsert_view_visibility.out new file mode 100644 index 0000000000..c19fa66dd1 --- /dev/null +++ b/nix/tests/expected/upsert_view_visibility.out @@ -0,0 +1,32 @@ +-- Fixes CVE-2026-16237. +-- +-- What changed: an upsert (INSERT ... ON CONFLICT DO UPDATE) through a view could overwrite a row that the view's WHERE clause hides. +-- It now fails with "conflicting row is not visible through view". +-- +-- Who notices: apps that upsert through a view, for example with supabase-js `.upsert()` or PostgREST. +-- Say the view only shows a user's own rows. +-- If the conflicting row is hidden, the upsert used to change it quietly. +-- Now it fails. +-- This applies to plain, security_barrier and security_invoker views. +-- Upserts on visible rows and ON CONFLICT DO NOTHING (`ignoreDuplicates`) work as before. +-- +-- Upstream commits: 8bbd75721 (PG 15.20), a14aacebe (PG 17.12). +BEGIN; +CREATE TABLE uv_upsert_tab (id int PRIMARY KEY, secret int, vis boolean); +INSERT INTO uv_upsert_tab VALUES (1, 111, false), (2, 222, true); +CREATE VIEW uv_upsert_view AS SELECT * FROM uv_upsert_tab WHERE vis; +-- Hidden row: must be rejected. +SAVEPOINT s; +INSERT INTO uv_upsert_view VALUES (1, 0, true) + ON CONFLICT (id) DO UPDATE SET secret = EXCLUDED.secret RETURNING *; +ERROR: conflicting row is not visible through view "uv_upsert_view" +ROLLBACK TO SAVEPOINT s; +-- Visible row: still works. +INSERT INTO uv_upsert_view VALUES (2, 0, true) + ON CONFLICT (id) DO UPDATE SET secret = EXCLUDED.secret RETURNING *; + id | secret | vis +----+--------+----- + 2 | 0 | t +(1 row) + +ROLLBACK; diff --git a/nix/tests/sql/alter_column_type_rowtype_deps.sql b/nix/tests/sql/alter_column_type_rowtype_deps.sql new file mode 100644 index 0000000000..44c79346e2 --- /dev/null +++ b/nix/tests/sql/alter_column_type_rowtype_deps.sql @@ -0,0 +1,32 @@ +-- Fixes CVE-2026-6460. +-- +-- What changed: after a column type change, stored expressions could still read the table's row type in its old layout. +-- That could crash the backend or expose memory. +-- Postgres now refuses ALTER COLUMN TYPE when certain stored code uses the row type. +-- It also adds the composite_type_mismatch setting. +-- +-- Who notices: migrations such as `ALTER TABLE profiles ALTER COLUMN x TYPE ...`. +-- They now fail with "cannot alter table ... because function ... uses its row type" if the row type appears in any of these: +-- - a BEGIN ATOMIC function body +-- - a default for a function argument or a column +-- - an index expression on another table +-- To fix: drop the dependent object, run the ALTER, then recreate the object. +-- Functions that only take or return the row type are fine, for example `RETURNS SETOF profiles` for a PostgREST RPC. +-- +-- Upstream commits: 22762f03a and others (PG 15.20), bfcb6625e and others (PG 17.12). + +BEGIN; + +-- The new GUC exists. +SELECT count(*) AS has_guc FROM pg_settings WHERE name = 'composite_type_mismatch'; + +CREATE TABLE act_tbl (a bytea); +CREATE FUNCTION act_func() RETURNS act_tbl + IMMUTABLE LANGUAGE sql BEGIN ATOMIC SELECT '(\\x4649584d45)'::act_tbl; END; + +-- Must now be rejected. +SAVEPOINT s; +ALTER TABLE act_tbl ALTER COLUMN a TYPE text; +ROLLBACK TO SAVEPOINT s; + +ROLLBACK; diff --git a/nix/tests/sql/bpchar_btree_dedup.sql b/nix/tests/sql/bpchar_btree_dedup.sql new file mode 100644 index 0000000000..c55e260e22 --- /dev/null +++ b/nix/tests/sql/bpchar_btree_dedup.sql @@ -0,0 +1,33 @@ +-- What changed: btree deduplication isn't safe for bpchar, because bpchar equality ignores trailing spaces. +-- In rare cases index-only scans returned wrong results. +-- Postgres no longer deduplicates bpchar_ops indexes. +-- New indexes report allequalimage = false, which this test checks. +-- +-- Who notices: anyone with a btree index on a `bpchar` or `char(n)` column, including primary keys and unique constraints. +-- Upstream recommends a REINDEX after the upgrade. +-- Until then, amcheck's bt_index_check() reports these indexes as corrupt ("metapage incorrectly indicates that deduplication is safe"). +-- Expect support tickets about it. +-- This query lists the indexes to rebuild: +-- +-- SELECT DISTINCT i.indexrelid::regclass FROM pg_index i +-- JOIN pg_class c ON c.oid = i.indexrelid +-- JOIN pg_am am ON am.oid = c.relam AND am.amname = 'btree' +-- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) +-- WHERE a.atttypid = 'bpchar'::regtype; +-- +-- Upstream commits: de0d4f0b1 (PG 15.20), 74f26a193 (PG 17.12). + +BEGIN; + +CREATE TABLE bpchar_dedup (b bpchar, c char(10), t text) USING heap; +CREATE INDEX bpchar_dedup_b ON bpchar_dedup (b); +CREATE INDEX bpchar_dedup_c ON bpchar_dedup (c); +CREATE INDEX bpchar_dedup_t ON bpchar_dedup (t); + +-- bpchar and char(n) report false after the fix. +-- text still reports true. +SELECT 'b' AS col, allequalimage FROM bt_metap('bpchar_dedup_b') +UNION ALL SELECT 'c', allequalimage FROM bt_metap('bpchar_dedup_c') +UNION ALL SELECT 't', allequalimage FROM bt_metap('bpchar_dedup_t'); + +ROLLBACK; diff --git a/nix/tests/sql/cast_function_execute_privilege.sql b/nix/tests/sql/cast_function_execute_privilege.sql new file mode 100644 index 0000000000..572329bc4a --- /dev/null +++ b/nix/tests/sql/cast_function_execute_privilege.sql @@ -0,0 +1,32 @@ +-- Fixes CVE-2026-116303. +-- +-- What changed: to_json() and to_jsonb() called a user-defined cast function without checking EXECUTE on it. +-- They now check, like normal cast evaluation. +-- +-- Who notices: projects that meet both conditions: +-- - they defined a custom cast to json or jsonb +-- - they revoked EXECUTE on its function from PUBLIC, for example with a blanket revoke from anon and authenticated +-- Those roles now get "permission denied for function ..." when they convert the type to JSON. +-- That includes PostgREST responses. +-- To fix: grant EXECUTE on the cast function. +-- +-- Upstream commits: 879c760d8 (PG 15.20), 9520dbea7 (PG 17.12). + +BEGIN; + +CREATE TYPE cast_priv_enum AS ENUM ('a'); +CREATE FUNCTION cast_priv_enum_to_json(cast_priv_enum) RETURNS json + LANGUAGE sql IMMUTABLE AS $$ SELECT to_json($1::text) $$; +CREATE CAST (cast_priv_enum AS json) WITH FUNCTION cast_priv_enum_to_json(cast_priv_enum); +REVOKE ALL ON FUNCTION cast_priv_enum_to_json(cast_priv_enum) FROM PUBLIC; + +SET ROLE postgres; + +-- Must be denied. +SAVEPOINT s; +SELECT to_json('a'::cast_priv_enum); +ROLLBACK TO SAVEPOINT s; + +RESET ROLE; + +ROLLBACK; diff --git a/nix/tests/sql/drop_owned_rls_policy.sql b/nix/tests/sql/drop_owned_rls_policy.sql new file mode 100644 index 0000000000..048898a6a8 --- /dev/null +++ b/nix/tests/sql/drop_owned_rls_policy.sql @@ -0,0 +1,27 @@ +-- Fixes CVE-2026-116311. +-- +-- What changed: `DROP OWNED BY some_role` could remove the role from RLS policies on tables the caller doesn't own. +-- A role could use this to escape a restrictive policy. +-- Postgres now leaves those policies alone and issues a WARNING. +-- +-- Who notices: clean-up scripts that run `DROP OWNED BY some_role` as a role that doesn't own every affected table. +-- The policies stay, so a later `DROP ROLE some_role` can fail on the remaining dependency. +-- To fix: run the clean-up as the table owner (on Supabase, usually `postgres`), or remove the role from those policies first. +-- +-- Upstream commits: 1f2dd21c1 (PG 15.20), 21c732aad (PG 17.12). + +BEGIN; + +CREATE ROLE regress_dob_role NOLOGIN; +CREATE TABLE dob_tbl (c int); +ALTER TABLE dob_tbl ENABLE ROW LEVEL SECURITY; +CREATE POLICY dob_restrict ON dob_tbl AS RESTRICTIVE TO regress_dob_role USING (c > 0); + +SET ROLE regress_dob_role; +DROP OWNED BY regress_dob_role; -- should warn +RESET ROLE; + +-- Policy must still apply to regress_dob_role. +SELECT polname, polroles::regrole[] FROM pg_policy WHERE polrelid = 'dob_tbl'::regclass; + +ROLLBACK; diff --git a/nix/tests/sql/function_def_target_alias.sql b/nix/tests/sql/function_def_target_alias.sql new file mode 100644 index 0000000000..0303e275b0 --- /dev/null +++ b/nix/tests/sql/function_def_target_alias.sql @@ -0,0 +1,23 @@ +-- What changed: pg_get_functiondef(), pg_get_ruledef() and \d+ now always print AS before a target-table alias in UPDATE and DELETE. +-- So `UPDATE t x` becomes `UPDATE t AS x`. +-- Without AS, an alias named `set` produced a definition that failed to restore. +-- +-- Who notices: queries run the same. +-- Schema-diff tools compare these printed definitions, though. +-- So `supabase db diff`, `supabase db pull` and migra may report changes to unchanged BEGIN ATOMIC functions and rules right after the upgrade. +-- +-- Upstream commits: 697e1d3d5 (PG 15.20), 91246c726 (PG 17.12). + +BEGIN; + +CREATE TABLE fdta_tbl (id int, val int); + +CREATE FUNCTION fdta_func() RETURNS void LANGUAGE sql +BEGIN ATOMIC + UPDATE fdta_tbl t SET val = t.val + 1 WHERE t.id = 1; + DELETE FROM fdta_tbl d WHERE d.id = 2; +END; + +SELECT pg_get_functiondef('fdta_func'::regproc); + +ROLLBACK; diff --git a/nix/tests/sql/jsonpath_gin_lookup.sql b/nix/tests/sql/jsonpath_gin_lookup.sql new file mode 100644 index 0000000000..748134ebba --- /dev/null +++ b/nix/tests/sql/jsonpath_gin_lookup.sql @@ -0,0 +1,39 @@ +-- What changed: jsonpath searches with `@@` and `@?` on a jsonb_ops GIN index missed matching rows in these cases: +-- - a top-level scalar, for example `strict $ == "a scalar"` +-- - a method on a literal, for example `"25".double()` +-- - an AND inside a NOT +-- The test checks that index scans find the same rows as a sequential scan. +-- +-- Who notices: these queries may return more rows after the upgrade (the correct ones). +-- Before, the result depended on whether the planner chose the index. +-- Existing indexes don't need a REINDEX. +-- +-- Upstream commits: 3b2a60481 + 04a8915fa (PG 15.20), 19656b77d + 65f1b71c3 (PG 17.12). + +BEGIN; + +CREATE TABLE jgin (j jsonb) USING heap; +INSERT INTO jgin VALUES + ('"a scalar"'), + ('{"age": 25}'), + ('{"age": 25.0}'), + ('{"bad": false, "coauthors": 98}'), + ('{"bad": true, "coauthors": 98}'), + ('{"bad": false, "coauthors": 1}'), + ('{}'); +CREATE INDEX jgin_idx ON jgin USING gin (j jsonb_ops); + +-- Ground truth (seqscan). +SET LOCAL enable_bitmapscan = off; +SELECT count(*) FROM jgin WHERE j @@ 'strict $ == "a scalar"'; +SELECT count(*) FROM jgin WHERE j @@ '$.age == "25".double()'; +SELECT count(*) FROM jgin WHERE j @@ '!(!($.bad == false) && $.coauthors == 98)'; + +-- Through the GIN index: must match. +SET LOCAL enable_bitmapscan = on; +SET LOCAL enable_seqscan = off; +SELECT count(*) FROM jgin WHERE j @@ 'strict $ == "a scalar"'; +SELECT count(*) FROM jgin WHERE j @@ '$.age == "25".double()'; +SELECT count(*) FROM jgin WHERE j @@ '!(!($.bad == false) && $.coauthors == 98)'; + +ROLLBACK; diff --git a/nix/tests/sql/partition_prune_range.sql b/nix/tests/sql/partition_prune_range.sql new file mode 100644 index 0000000000..180bfcae7a --- /dev/null +++ b/nix/tests/sql/partition_prune_range.sql @@ -0,0 +1,48 @@ +-- What changed: RANGE partition pruning could skip partitions that held matching rows, so queries silently returned too few rows. +-- Two cases: +-- - a multi-column partition key, with filters on only the leading columns +-- - a DEFAULT partition next to a partition bound that uses MINVALUE or MAXVALUE +-- The test checks that counts match those with enable_partition_pruning off. +-- +-- Who notices: projects with tables like these, for example partitioned by (tenant_id, created_at). +-- Affected queries may return more rows after the upgrade (the correct ones). +-- All rows sit in the right partitions, so no data needs fixing. +-- +-- Upstream commits: 1a501b6e2 + 9b8c0f238 (PG 15.20), 2d8494e24 + d21d4f1ca (PG 17.12). + +BEGIN; + +-- Multi-column RANGE, qual on the leading column only. +CREATE TABLE ppr_mc (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_mc1 PARTITION OF ppr_mc FOR VALUES FROM (1, 4) TO (1, 7); +CREATE TABLE ppr_mc2 PARTITION OF ppr_mc FOR VALUES FROM (1, 7) TO (3, 8); +CREATE TABLE ppr_mc3 PARTITION OF ppr_mc FOR VALUES FROM (4, 8) TO (6, 9); +CREATE TABLE ppr_mc_def PARTITION OF ppr_mc DEFAULT; +INSERT INTO ppr_mc VALUES (1, 5), (1, 8), (2, 0), (5, 8), (0, 0); + +-- DEFAULT partition next to a MAXVALUE bound. +CREATE TABLE ppr_max (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_max1 PARTITION OF ppr_max FOR VALUES FROM (13, 0) TO (19, MAXVALUE); +CREATE TABLE ppr_max_def PARTITION OF ppr_max DEFAULT; +INSERT INTO ppr_max VALUES (15, 1), (32, 0), (25, 3); + +-- DEFAULT partition next to a MINVALUE bound. +CREATE TABLE ppr_min (a int, b int) PARTITION BY RANGE (a, b); +CREATE TABLE ppr_min1 PARTITION OF ppr_min FOR VALUES FROM (13, MINVALUE) TO (19, 5); +CREATE TABLE ppr_min_def PARTITION OF ppr_min DEFAULT; +INSERT INTO ppr_min VALUES (15, 1), (5, 0), (13, 3); + +-- With pruning. +SELECT count(*) FROM ppr_mc WHERE a <= 1; -- (1,5), (1,8), (0,0) +SELECT count(*) FROM ppr_max WHERE a = 32 AND b >= -7; -- (32,0) +SELECT count(*) FROM ppr_max WHERE a >= 25 AND b >= 0; -- (32,0), (25,3) +SELECT count(*) FROM ppr_min WHERE a = 5 AND b <= 7; -- (5,0) + +-- Without pruning: must match. +SET LOCAL enable_partition_pruning = off; +SELECT count(*) FROM ppr_mc WHERE a <= 1; +SELECT count(*) FROM ppr_max WHERE a = 32 AND b >= -7; +SELECT count(*) FROM ppr_max WHERE a >= 25 AND b >= 0; +SELECT count(*) FROM ppr_min WHERE a = 5 AND b <= 7; + +ROLLBACK; diff --git a/nix/tests/sql/to_char_v_overflow.sql b/nix/tests/sql/to_char_v_overflow.sql new file mode 100644 index 0000000000..844bfc65b8 --- /dev/null +++ b/nix/tests/sql/to_char_v_overflow.sql @@ -0,0 +1,21 @@ +-- What changed: to_char() with the V format multiplies by a power of ten. +-- For example, '999V99' turns 1.23 into 123. +-- With integer input, if the result overflowed the type, to_char() printed wrong digits without an error. +-- Now it fails with "integer out of range". +-- The test cases come from upstream int4.sql. +-- +-- Who notices: formatting code that uses V on large integers, for example to show amounts in cents. +-- It used to print wrong values, sometimes negative. +-- Now it fails. +-- To fix: cast the input to numeric first. +-- +-- Upstream commits: 772251744 (PG 15.20), 04c25af21 (PG 17.12). + +SELECT to_char(2, '9V999999999'); -- 2 * 10^9 fits +SELECT to_char(214748364, '999999999V9'); -- 2147483640 fits +SELECT to_char(-2, '9V999999999'); -- fits + +-- These overflow int4 and must now error. +SELECT to_char(3, '9V999999999'); +SELECT to_char(2147483647, '9V9'); +SELECT to_char(1, '9V9999999999'); diff --git a/nix/tests/sql/upsert_rls_conflict_where.sql b/nix/tests/sql/upsert_rls_conflict_where.sql new file mode 100644 index 0000000000..9026b6cdac --- /dev/null +++ b/nix/tests/sql/upsert_rls_conflict_where.sql @@ -0,0 +1,36 @@ +-- Fixes CVE-2026-120154. +-- +-- What changed: on a table with RLS, Postgres evaluated an upsert's ON CONFLICT ... WHERE clause before the RLS USING policies. +-- A function in that clause could read rows the caller isn't allowed to see. +-- Postgres now checks the policies first, so nothing leaks. +-- +-- Who notices: normal upserts behave the same. +-- The fix closes a way to read other users' rows on RLS-protected tables. +-- In this test, the function prints the row it sees; that NOTICE must no longer appear. +-- +-- Upstream commits: 48be6f9e8 (PG 15.20), 9c677791d (PG 17.12). + +BEGIN; + +CREATE TABLE rls_upsert_doc (id int PRIMARY KEY, owner name, body text); +INSERT INTO rls_upsert_doc VALUES (1, 'someone_else', 'secret'); +ALTER TABLE rls_upsert_doc ENABLE ROW LEVEL SECURITY; +CREATE POLICY p_all ON rls_upsert_doc USING (owner = current_user); +GRANT SELECT, INSERT, UPDATE ON rls_upsert_doc TO postgres; + +CREATE FUNCTION rls_upsert_leak(text) RETURNS boolean + LANGUAGE plpgsql COST 0.0000001 + AS $$ BEGIN RAISE NOTICE 'leaked => %', $1; RETURN true; END $$; + +SET ROLE postgres; + +-- Must fail without a "leaked => secret" NOTICE. +SAVEPOINT s; +INSERT INTO rls_upsert_doc VALUES (1, 'postgres', 'x') + ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body + WHERE rls_upsert_leak(rls_upsert_doc.body); +ROLLBACK TO SAVEPOINT s; + +RESET ROLE; + +ROLLBACK; diff --git a/nix/tests/sql/upsert_view_visibility.sql b/nix/tests/sql/upsert_view_visibility.sql new file mode 100644 index 0000000000..bdd0ee2707 --- /dev/null +++ b/nix/tests/sql/upsert_view_visibility.sql @@ -0,0 +1,31 @@ +-- Fixes CVE-2026-16237. +-- +-- What changed: an upsert (INSERT ... ON CONFLICT DO UPDATE) through a view could overwrite a row that the view's WHERE clause hides. +-- It now fails with "conflicting row is not visible through view". +-- +-- Who notices: apps that upsert through a view, for example with supabase-js `.upsert()` or PostgREST. +-- Say the view only shows a user's own rows. +-- If the conflicting row is hidden, the upsert used to change it quietly. +-- Now it fails. +-- This applies to plain, security_barrier and security_invoker views. +-- Upserts on visible rows and ON CONFLICT DO NOTHING (`ignoreDuplicates`) work as before. +-- +-- Upstream commits: 8bbd75721 (PG 15.20), a14aacebe (PG 17.12). + +BEGIN; + +CREATE TABLE uv_upsert_tab (id int PRIMARY KEY, secret int, vis boolean); +INSERT INTO uv_upsert_tab VALUES (1, 111, false), (2, 222, true); +CREATE VIEW uv_upsert_view AS SELECT * FROM uv_upsert_tab WHERE vis; + +-- Hidden row: must be rejected. +SAVEPOINT s; +INSERT INTO uv_upsert_view VALUES (1, 0, true) + ON CONFLICT (id) DO UPDATE SET secret = EXCLUDED.secret RETURNING *; +ROLLBACK TO SAVEPOINT s; + +-- Visible row: still works. +INSERT INTO uv_upsert_view VALUES (2, 0, true) + ON CONFLICT (id) DO UPDATE SET secret = EXCLUDED.secret RETURNING *; + +ROLLBACK;