Fix privilege checks in pg_stats_ext and pg_stats_ext_exprs.

The catalog view pg_stats_ext fails to consider privileges for
expression statistics.  The catalog view pg_stats_ext_exprs fails
to consider privileges and row-level security policies.  To fix,
restrict the data in these views to table owners or roles that
inherit privileges of the table owner.  It may be possible to apply
less restrictive privilege checks in some cases, but that is left
as a future exercise.  Furthermore, for pg_stats_ext_exprs, do not
return data for tables with row-level security enabled, as is
already done for pg_stats_ext.

On the back-branches, a fix-CVE-2024-4317.sql script is provided
that will install into the "share" directory.  This file can be
used to apply the fix to existing clusters.

Bumps catversion on 'master' branch only.

Reported-by: Lukas Fittl
Reviewed-by: Noah Misch, Tomas Vondra, Tom Lane
Security: CVE-2024-4317
Backpatch-through: 14
This commit is contained in:
Nathan Bossart 2024-05-06 09:00:00 -05:00
parent d1d286d83c
commit 521a7156ab
7 changed files with 81 additions and 17 deletions

View file

@ -7788,8 +7788,7 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
is a publicly readable view is a publicly readable view
on <structname>pg_statistic_ext_data</structname> (after joining on <structname>pg_statistic_ext_data</structname> (after joining
with <link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link>) that only exposes with <link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link>) that only exposes
information about those tables and columns that are readable by the information about tables the current user owns.
current user.
</para> </para>
<table> <table>

View file

@ -3944,7 +3944,7 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link>
catalogs. This view allows access only to rows of catalogs. This view allows access only to rows of
<link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link> <link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link>
that correspond to tables the user has permission to read, and therefore that correspond to tables the user owns, and therefore
it is safe to allow public read access to this view. it is safe to allow public read access to this view.
</para> </para>
@ -4155,7 +4155,7 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link>
catalogs. This view allows access only to rows of catalogs. This view allows access only to rows of
<link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link> <link linkend="catalog-pg-statistic-ext"><structname>pg_statistic_ext</structname></link> and <link linkend="catalog-pg-statistic-ext-data"><structname>pg_statistic_ext_data</structname></link>
that correspond to tables the user has permission to read, and therefore that correspond to tables the user owns, and therefore
it is safe to allow public read access to this view. it is safe to allow public read access to this view.
</para> </para>

View file

@ -305,12 +305,7 @@ CREATE VIEW pg_stats_ext WITH (security_barrier) AS
array_agg(base_frequency) AS most_common_base_freqs array_agg(base_frequency) AS most_common_base_freqs
FROM pg_mcv_list_items(sd.stxdmcv) FROM pg_mcv_list_items(sd.stxdmcv)
) m ON sd.stxdmcv IS NOT NULL ) m ON sd.stxdmcv IS NOT NULL
WHERE NOT EXISTS WHERE pg_has_role(c.relowner, 'USAGE')
( SELECT 1
FROM unnest(stxkeys) k
JOIN pg_attribute a
ON (a.attrelid = s.stxrelid AND a.attnum = k)
WHERE NOT has_column_privilege(c.oid, a.attnum, 'select') )
AND (c.relrowsecurity = false OR NOT row_security_active(c.oid)); AND (c.relrowsecurity = false OR NOT row_security_active(c.oid));
CREATE VIEW pg_stats_ext_exprs WITH (security_barrier) AS CREATE VIEW pg_stats_ext_exprs WITH (security_barrier) AS
@ -380,7 +375,9 @@ CREATE VIEW pg_stats_ext_exprs WITH (security_barrier) AS
JOIN LATERAL ( JOIN LATERAL (
SELECT unnest(pg_get_statisticsobjdef_expressions(s.oid)) AS expr, SELECT unnest(pg_get_statisticsobjdef_expressions(s.oid)) AS expr,
unnest(sd.stxdexpr)::pg_statistic AS a unnest(sd.stxdexpr)::pg_statistic AS a
) stat ON (stat.expr IS NOT NULL); ) stat ON (stat.expr IS NOT NULL)
WHERE pg_has_role(c.relowner, 'USAGE')
AND (c.relrowsecurity = false OR NOT row_security_active(c.oid));
-- unprivileged users may read pg_statistic_ext but not pg_statistic_ext_data -- unprivileged users may read pg_statistic_ext but not pg_statistic_ext_data
REVOKE ALL ON pg_statistic_ext_data FROM public; REVOKE ALL ON pg_statistic_ext_data FROM public;

View file

@ -57,6 +57,6 @@
*/ */
/* yyyymmddN */ /* yyyymmddN */
#define CATALOG_VERSION_NO 202405051 #define CATALOG_VERSION_NO 202405061
#endif #endif

View file

@ -2531,10 +2531,7 @@ pg_stats_ext| SELECT cn.nspname AS schemaname,
array_agg(pg_mcv_list_items.frequency) AS most_common_freqs, array_agg(pg_mcv_list_items.frequency) AS most_common_freqs,
array_agg(pg_mcv_list_items.base_frequency) AS most_common_base_freqs array_agg(pg_mcv_list_items.base_frequency) AS most_common_base_freqs
FROM pg_mcv_list_items(sd.stxdmcv) pg_mcv_list_items(index, "values", nulls, frequency, base_frequency)) m ON ((sd.stxdmcv IS NOT NULL))) FROM pg_mcv_list_items(sd.stxdmcv) pg_mcv_list_items(index, "values", nulls, frequency, base_frequency)) m ON ((sd.stxdmcv IS NOT NULL)))
WHERE ((NOT (EXISTS ( SELECT 1 WHERE (pg_has_role(c.relowner, 'USAGE'::text) AND ((c.relrowsecurity = false) OR (NOT row_security_active(c.oid))));
FROM (unnest(s.stxkeys) k(k)
JOIN pg_attribute a ON (((a.attrelid = s.stxrelid) AND (a.attnum = k.k))))
WHERE (NOT has_column_privilege(c.oid, a.attnum, 'select'::text))))) AND ((c.relrowsecurity = false) OR (NOT row_security_active(c.oid))));
pg_stats_ext_exprs| SELECT cn.nspname AS schemaname, pg_stats_ext_exprs| SELECT cn.nspname AS schemaname,
c.relname AS tablename, c.relname AS tablename,
sn.nspname AS statistics_schemaname, sn.nspname AS statistics_schemaname,
@ -2607,7 +2604,8 @@ pg_stats_ext_exprs| SELECT cn.nspname AS schemaname,
LEFT JOIN pg_namespace cn ON ((cn.oid = c.relnamespace))) LEFT JOIN pg_namespace cn ON ((cn.oid = c.relnamespace)))
LEFT JOIN pg_namespace sn ON ((sn.oid = s.stxnamespace))) LEFT JOIN pg_namespace sn ON ((sn.oid = s.stxnamespace)))
JOIN LATERAL ( SELECT unnest(pg_get_statisticsobjdef_expressions(s.oid)) AS expr, JOIN LATERAL ( SELECT unnest(pg_get_statisticsobjdef_expressions(s.oid)) AS expr,
unnest(sd.stxdexpr) AS a) stat ON ((stat.expr IS NOT NULL))); unnest(sd.stxdexpr) AS a) stat ON ((stat.expr IS NOT NULL)))
WHERE (pg_has_role(c.relowner, 'USAGE'::text) AND ((c.relrowsecurity = false) OR (NOT row_security_active(c.oid))));
pg_tables| SELECT n.nspname AS schemaname, pg_tables| SELECT n.nspname AS schemaname,
c.relname AS tablename, c.relname AS tablename,
pg_get_userbyid(c.relowner) AS tableowner, pg_get_userbyid(c.relowner) AS tableowner,

View file

@ -3281,10 +3281,53 @@ SELECT * FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not le
(0 rows) (0 rows)
DELETE FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak DELETE FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak
-- privilege checks for pg_stats_ext and pg_stats_ext_exprs
RESET SESSION AUTHORIZATION;
CREATE TABLE stats_ext_tbl (id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, col TEXT);
INSERT INTO stats_ext_tbl (col) VALUES ('secret'), ('secret'), ('very secret');
CREATE STATISTICS s_col ON id, col FROM stats_ext_tbl;
CREATE STATISTICS s_expr ON mod(id, 2), lower(col) FROM stats_ext_tbl;
ANALYZE stats_ext_tbl;
-- unprivileged role should not have access
SET SESSION AUTHORIZATION regress_stats_user1;
SELECT statistics_name, most_common_vals FROM pg_stats_ext x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
statistics_name | most_common_vals
-----------------+------------------
(0 rows)
SELECT statistics_name, most_common_vals FROM pg_stats_ext_exprs x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
statistics_name | most_common_vals
-----------------+------------------
(0 rows)
-- give unprivileged role ownership of table
RESET SESSION AUTHORIZATION;
ALTER TABLE stats_ext_tbl OWNER TO regress_stats_user1;
-- unprivileged role should now have access
SET SESSION AUTHORIZATION regress_stats_user1;
SELECT statistics_name, most_common_vals FROM pg_stats_ext x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
statistics_name | most_common_vals
-----------------+-------------------------------------------
s_col | {{1,secret},{2,secret},{3,"very secret"}}
s_expr | {{0,secret},{1,secret},{1,"very secret"}}
(2 rows)
SELECT statistics_name, most_common_vals FROM pg_stats_ext_exprs x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
statistics_name | most_common_vals
-----------------+------------------
s_expr | {secret}
s_expr | {1}
(2 rows)
-- Tidy up -- Tidy up
DROP OPERATOR <<< (int, int); DROP OPERATOR <<< (int, int);
DROP FUNCTION op_leak(int, int); DROP FUNCTION op_leak(int, int);
RESET SESSION AUTHORIZATION; RESET SESSION AUTHORIZATION;
DROP TABLE stats_ext_tbl;
DROP SCHEMA tststats CASCADE; DROP SCHEMA tststats CASCADE;
NOTICE: drop cascades to 2 other objects NOTICE: drop cascades to 2 other objects
DETAIL: drop cascades to table tststats.priv_test_tbl DETAIL: drop cascades to table tststats.priv_test_tbl

View file

@ -1657,9 +1657,36 @@ SET SESSION AUTHORIZATION regress_stats_user1;
SELECT * FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak SELECT * FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak
DELETE FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak DELETE FROM tststats.priv_test_tbl WHERE a <<< 0 AND b <<< 0; -- Should not leak
-- privilege checks for pg_stats_ext and pg_stats_ext_exprs
RESET SESSION AUTHORIZATION;
CREATE TABLE stats_ext_tbl (id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, col TEXT);
INSERT INTO stats_ext_tbl (col) VALUES ('secret'), ('secret'), ('very secret');
CREATE STATISTICS s_col ON id, col FROM stats_ext_tbl;
CREATE STATISTICS s_expr ON mod(id, 2), lower(col) FROM stats_ext_tbl;
ANALYZE stats_ext_tbl;
-- unprivileged role should not have access
SET SESSION AUTHORIZATION regress_stats_user1;
SELECT statistics_name, most_common_vals FROM pg_stats_ext x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
SELECT statistics_name, most_common_vals FROM pg_stats_ext_exprs x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
-- give unprivileged role ownership of table
RESET SESSION AUTHORIZATION;
ALTER TABLE stats_ext_tbl OWNER TO regress_stats_user1;
-- unprivileged role should now have access
SET SESSION AUTHORIZATION regress_stats_user1;
SELECT statistics_name, most_common_vals FROM pg_stats_ext x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
SELECT statistics_name, most_common_vals FROM pg_stats_ext_exprs x
WHERE tablename = 'stats_ext_tbl' ORDER BY ROW(x.*);
-- Tidy up -- Tidy up
DROP OPERATOR <<< (int, int); DROP OPERATOR <<< (int, int);
DROP FUNCTION op_leak(int, int); DROP FUNCTION op_leak(int, int);
RESET SESSION AUTHORIZATION; RESET SESSION AUTHORIZATION;
DROP TABLE stats_ext_tbl;
DROP SCHEMA tststats CASCADE; DROP SCHEMA tststats CASCADE;
DROP USER regress_stats_user1; DROP USER regress_stats_user1;