mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-10-31 08:34:01 -05:00
fix(security): escape index-stats/publication/subscription name sinks (CVE-2026-12044 follow-up)
The 9.16 fix for CVE-2026-12044 hardened qtLiteral and switched sixteen
COMMENT ON / pgstattuple / pgstatindex templates to it, but the fix
missed several sinks that were previously excused in
test_sql_string_literal_lint.py's ALLOWLIST on the (incorrect)
assumption that schema/table/publication/subscription names sourced
from pg_catalog via the browser tree could never contain apostrophes.
They can, since PostgreSQL permits arbitrary characters in quoted
identifiers, so a low-privileged user able to CREATE TABLE, CREATE
PUBLICATION, or CREATE SUBSCRIPTION can plant a name that breaks out of
the unescaped '{{ name }}' interpolation once any user views that
object's Statistics or Dependencies tab.
Switch the following templates to qtLiteral(conn):
- schemas/tables/templates/indexes/sql/{16_plus,default}/coll_stats.sql
(Index Statistics -- reported as "SQL injection in pgAdmin index
Statistics (incomplete fix for CVE-2026-12044)")
- publications/templates/publications/{pg,ppas}/default/sql/
{dependencies,get_position}.sql
- subscriptions/templates/subscriptions/sql/default/
{dependencies,get_position}.sql
publications/__init__.py and subscriptions/__init__.py now pass
conn=self.conn into the dependencies.sql render_template call so the
qtLiteral filter has a connection to quote against. Remove the
corresponding ALLOWLIST entries in test_sql_string_literal_lint.py now
that these sinks are properly escaped instead of merely assumed safe.
Patch supplied pre-written; manual testing by the reporter reported
both the SQL-injection and HTML-injection probes as positive, but the
exact pre/post-fix state tested was not fully disambiguated in this
session -- treat as reported-but-not-independently-verified pending
follow-up confirmation.
This commit is contained in:
1 parent
24fdcf0f58
commit
73b3218992
11 files changed
+12
-68
No files matched your search
@@ -1048,7 +1048,7 @@ class PublicationView(PGChildNodeView, SchemaDiffObjectCompare):
|
||||
status, pname = self.conn.execute_scalar(get_name_sql)
|
||||
table_sql = render_template(
|
||||
"/".join([self.template_path, 'dependencies.sql']),
|
||||
pname=pname
|
||||
pname=pname, conn=self.conn
|
||||
)
|
||||
status, res = self.conn.execute_dict(table_sql)
|
||||
if not status:
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ SELECT cl.oid AS oid,
|
||||
pg_catalog.quote_ident(pgb_table.schemaname)||'.'||pg_catalog.quote_ident(pgb_table.tablename) AS pubtable
|
||||
FROM pg_catalog.pg_publication_tables pgb_table
|
||||
LEFT JOIN pg_catalog.pg_class cl ON pgb_table.tablename = cl.relname
|
||||
WHERE pubname = '{{ pname }}' AND pgb_table.schemaname NOT LIKE 'pgagent';
|
||||
WHERE pubname = {{ pname|qtLiteral(conn) }} AND pgb_table.schemaname NOT LIKE 'pgagent';
|
||||
+1
-1
@@ -1 +1 @@
|
||||
SELECT oid, pubname AS name FROM pg_catalog.pg_publication WHERE pubname = '{{ pubname }}';
|
||||
SELECT oid, pubname AS name FROM pg_catalog.pg_publication WHERE pubname = {{ pubname|qtLiteral(conn) }};
|
||||
+1
-1
@@ -2,4 +2,4 @@ SELECT cl.oid AS oid,
|
||||
pg_catalog.quote_ident(pgb_table.schemaname)||'.'||pg_catalog.quote_ident(pgb_table.tablename) AS pubtable
|
||||
FROM pg_catalog.pg_publication_tables pgb_table
|
||||
LEFT JOIN pg_catalog.pg_class cl ON pgb_table.tablename = cl.relname
|
||||
WHERE pubname = '{{ pname }}' AND pgb_table.schemaname NOT LIKE 'pgagent';
|
||||
WHERE pubname = {{ pname|qtLiteral(conn) }} AND pgb_table.schemaname NOT LIKE 'pgagent';
|
||||
+1
-1
@@ -1 +1 @@
|
||||
SELECT oid, pubname AS name FROM pg_catalog.pg_publication WHERE pubname = '{{ pubname }}';
|
||||
SELECT oid, pubname AS name FROM pg_catalog.pg_publication WHERE pubname = {{ pubname|qtLiteral(conn) }};
|
||||
+2
-2
@@ -12,7 +12,7 @@ FROM
|
||||
AND dep.refclassid=(SELECT oid FROM pg_catalog.pg_class WHERE relname='pg_constraint'))
|
||||
LEFT OUTER JOIN pg_catalog.pg_constraint con ON (con.tableoid = dep.refclassid AND con.oid = dep.refobjid)
|
||||
WHERE
|
||||
schemaname = '{{schema}}'
|
||||
AND stat.relname = '{{table}}'
|
||||
schemaname = {{schema|qtLiteral(conn)}}
|
||||
AND stat.relname = {{table|qtLiteral(conn)}}
|
||||
AND (con.contype IS NULL OR con.contype = 'p')
|
||||
ORDER BY indexrelname;
|
||||
+2
-2
@@ -11,7 +11,7 @@ FROM
|
||||
AND dep.refclassid=(SELECT oid FROM pg_catalog.pg_class WHERE relname='pg_constraint'))
|
||||
LEFT OUTER JOIN pg_catalog.pg_constraint con ON (con.tableoid = dep.refclassid AND con.oid = dep.refobjid)
|
||||
WHERE
|
||||
schemaname = '{{schema}}'
|
||||
AND stat.relname = '{{table}}'
|
||||
schemaname = {{schema|qtLiteral(conn)}}
|
||||
AND stat.relname = {{table|qtLiteral(conn)}}
|
||||
AND (con.contype IS NULL OR con.contype = 'p')
|
||||
ORDER BY indexrelname;
|
||||
@@ -892,7 +892,7 @@ class SubscriptionView(PGChildNodeView, SchemaDiffObjectCompare):
|
||||
status, subname = self.conn.execute_scalar(get_name_sql)
|
||||
table_sql = render_template(
|
||||
"/".join([self.template_path, 'dependencies.sql']),
|
||||
subname=subname
|
||||
subname=subname, conn=self.conn
|
||||
)
|
||||
status, res = self.conn.execute_dict(table_sql)
|
||||
if not status:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
SELECT subpublications AS pub FROM pg_catalog.pg_subscription
|
||||
WHERE subname = '{{subname}}';
|
||||
WHERE subname = {{subname|qtLiteral(conn)}};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
SELECT oid, subname AS name FROM pg_catalog.pg_subscription WHERE subname = '{{ subname }}' and subdbid={{did}} :: oid;
|
||||
SELECT oid, subname AS name FROM pg_catalog.pg_subscription WHERE subname = {{ subname|qtLiteral(conn) }} and subdbid={{did}} :: oid;
|
||||
-56
@@ -154,32 +154,6 @@ ALLOWLIST = [
|
||||
"'{{constraint_type}}'"), 1,
|
||||
"Single-char pg_constraint.contype code, hardcoded by handler."),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index stat lookups for an already-existing table. ``schema`` and
|
||||
# ``table`` come from the browser tree node and ultimately from
|
||||
# pg_catalog. Cannot contain apostrophes for any object pgAdmin was
|
||||
# able to discover via its own browse queries (which use qtLiteral on
|
||||
# the way in), so this is not a SQL injection sink. A correctness
|
||||
# follow-up (legitimately named objects containing apostrophes) is
|
||||
# tracked separately.
|
||||
# ------------------------------------------------------------------
|
||||
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
|
||||
'templates/indexes/sql/default/coll_stats.sql',
|
||||
"'{{schema}}'"), 1,
|
||||
"Schema name from pg_catalog via browser tree, not request input."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
|
||||
'templates/indexes/sql/default/coll_stats.sql',
|
||||
"'{{table}}'"), 1,
|
||||
"Table name from pg_catalog via browser tree, not request input."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
|
||||
'templates/indexes/sql/16_plus/coll_stats.sql',
|
||||
"'{{schema}}'"), 1,
|
||||
"Schema name from pg_catalog via browser tree, not request input."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
|
||||
'templates/indexes/sql/16_plus/coll_stats.sql',
|
||||
"'{{table}}'"), 1,
|
||||
"Table name from pg_catalog via browser tree, not request input."),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregate initial-condition strings. ``initial_val`` and
|
||||
# ``moving_initial_val`` come from the aggregate-creation form. The
|
||||
@@ -300,36 +274,6 @@ ALLOWLIST = [
|
||||
"'{{ data.origin }}'"), 1,
|
||||
"Bounded enum (any/none); CREATE SUBSCRIPTION needs superuser."),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscription / publication name lookups. ``subname``, ``pubname``
|
||||
# and ``pname`` come from the browser tree (resolved from OID
|
||||
# references in pg_catalog), not from request body input.
|
||||
# ------------------------------------------------------------------
|
||||
(('pgadmin/browser/server_groups/servers/databases/subscriptions/'
|
||||
'templates/subscriptions/sql/default/get_position.sql',
|
||||
"'{{ subname }}'"), 1,
|
||||
"Subscription name from pg_catalog via browser tree."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/subscriptions/'
|
||||
'templates/subscriptions/sql/default/dependencies.sql',
|
||||
"'{{subname}}'"), 1,
|
||||
"Subscription name from pg_catalog via browser tree."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/publications/'
|
||||
'templates/publications/pg/default/sql/get_position.sql',
|
||||
"'{{ pubname }}'"), 1,
|
||||
"Publication name from pg_catalog via browser tree."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/publications/'
|
||||
'templates/publications/pg/default/sql/dependencies.sql',
|
||||
"'{{ pname }}'"), 1,
|
||||
"Publication name from pg_catalog via browser tree."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/publications/'
|
||||
'templates/publications/ppas/default/sql/get_position.sql',
|
||||
"'{{ pubname }}'"), 1,
|
||||
"Publication name from pg_catalog via browser tree."),
|
||||
(('pgadmin/browser/server_groups/servers/databases/publications/'
|
||||
'templates/publications/ppas/default/sql/dependencies.sql',
|
||||
"'{{ pname }}'"), 1,
|
||||
"Publication name from pg_catalog via browser tree."),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DBMS job scheduler ``CREATE`` templates emit lines that are entirely
|
||||
# inside SQL ``--`` line comments (display-only headers, never
|
||||
|
||||
Reference in new issue
Block a user