From 73b3218992cc37af6e10b7e54eaeed6ec293c6b2 Mon Sep 17 00:00:00 2001 From: Kundan Sable Date: Thu, 23 Jul 2026 17:39:00 +0530 Subject: [PATCH] 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. --- .../databases/publications/__init__.py | 2 +- .../pg/default/sql/dependencies.sql | 2 +- .../pg/default/sql/get_position.sql | 2 +- .../ppas/default/sql/dependencies.sql | 2 +- .../ppas/default/sql/get_position.sql | 2 +- .../indexes/sql/16_plus/coll_stats.sql | 4 +- .../indexes/sql/default/coll_stats.sql | 4 +- .../databases/subscriptions/__init__.py | 2 +- .../sql/default/dependencies.sql | 2 +- .../sql/default/get_position.sql | 2 +- .../tests/test_sql_string_literal_lint.py | 56 ------------------- 11 files changed, 12 insertions(+), 68 deletions(-) diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/publications/__init__.py index b69463e5c..91aed1fa8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/__init__.py @@ -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: diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/dependencies.sql b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/dependencies.sql index d08ab9b47..a0bf1d90f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/dependencies.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/dependencies.sql @@ -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'; diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/get_position.sql b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/get_position.sql index 5d2d25009..ce6a0846c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/get_position.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/pg/default/sql/get_position.sql @@ -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) }}; diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/dependencies.sql b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/dependencies.sql index d08ab9b47..a0bf1d90f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/dependencies.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/dependencies.sql @@ -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'; diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/get_position.sql b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/get_position.sql index 5d2d25009..ce6a0846c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/get_position.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/templates/publications/ppas/default/sql/get_position.sql @@ -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) }}; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/16_plus/coll_stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/16_plus/coll_stats.sql index 2514a97af..082a9ea1b 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/16_plus/coll_stats.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/16_plus/coll_stats.sql @@ -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; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/default/coll_stats.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/default/coll_stats.sql index bb430937f..561162548 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/default/coll_stats.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/indexes/sql/default/coll_stats.sql @@ -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; diff --git a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/__init__.py index a3e994aea..5f39b3c55 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/__init__.py @@ -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: diff --git a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/dependencies.sql b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/dependencies.sql index 5ea81d9c2..ce56c0934 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/dependencies.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/dependencies.sql @@ -1,2 +1,2 @@ SELECT subpublications AS pub FROM pg_catalog.pg_subscription -WHERE subname = '{{subname}}'; +WHERE subname = {{subname|qtLiteral(conn)}}; diff --git a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/get_position.sql b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/get_position.sql index 5ddf72a3f..8381e88be 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/get_position.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/templates/subscriptions/sql/default/get_position.sql @@ -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; diff --git a/web/pgadmin/browser/server_groups/servers/databases/tests/test_sql_string_literal_lint.py b/web/pgadmin/browser/server_groups/servers/databases/tests/test_sql_string_literal_lint.py index 57a5c2272..c284727f6 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/tests/test_sql_string_literal_lint.py +++ b/web/pgadmin/browser/server_groups/servers/databases/tests/test_sql_string_literal_lint.py @@ -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