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:
Kundan Sable authored and Ashesh Vashi committed 2026-07-25 00:55:46 +05:30
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:
@@ -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 @@
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,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 @@
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) }};
@@ -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;
@@ -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,2 +1,2 @@
SELECT subpublications AS pub FROM pg_catalog.pg_subscription
WHERE subname = '{{subname}}';
WHERE subname = {{subname|qtLiteral(conn)}};
@@ -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;
@@ -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