1
0
mirror of synced 2026-10-24 18:59:25 +03:00

fix(sqli): close remaining apostrophe-in-identifier sinks and tighten the lint

Layered follow-up to the COMMENT description SQLi fix. Closes three
related gaps the original patch did not cover.

1. Stats templates — apostrophe-in-identifier SQLi (10 sites).

The pgstattuple/pgstatindex call sites in the stats templates rendered
the target relation as an embedded single-quoted literal, e.g.

    pgstattuple('{{schema_name}}.{{table_name}}')
    pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}')

A user with CREATE privilege on a schema could plant a table or index
named `foo'bar` (PostgreSQL permits any character except NUL inside a
quoted identifier). Any viewer who then opened that object's stats
panel would render an unbalanced SQL literal. qtIdent does NOT escape
apostrophes — it escapes the embedded double quotes that delimit a
quoted identifier — so the outer single-quoted literal still broke
out. Same bug class as the COMMENT description vector, just gated on
DDL privilege instead of dialog access.

Fix replaces all 10 sites with the canonical regclass form:

    pgstattuple({{ tid }}::oid::regclass)
    pgstatindex({{ exid|cid|idx }}::oid::regclass)

The OID is already passed by each handler (tables/utils.py,
exclusion_constraint/__init__.py, index_constraint/__init__.py,
indexes/__init__.py, views/__init__.py). The cast eliminates the
embedded string literal entirely — the bug class can no longer recur
through these sites.

Files:
  tables/sql/{default,16_plus}/stats.sql
  views/templates/mviews/{pg,ppas}/default/sql/stats.sql
  tables/templates/exclusion_constraint/sql/{default,16_plus}/stats.sql
  tables/templates/index_constraint/sql/{default,16_plus}/stats.sql
  tables/templates/indexes/sql/{default,16_plus}/stats.sql

2. Lint regex — embedded-Jinja blind spot.

The original lint regex `'\{\{[^}]+\}\}'` only matched literals where
the Jinja interpolation filled the entire single-quoted body. It
missed the embedded form `'foo{{ x }}bar'` — exactly the shape used
by the stats templates above and by `'%{{ search_text }}%'` in
search_objects.

The broadened regex `'[^'\n{}]*(?:\{\{[^}]+\}\}[^'\n{}]*)+'` catches
both forms. The `[^'\n{}]` body constraint is what stops the regex
from spanning a Jinja control block `{%...%}` — without it the regex
would walk across long `{% if %}` chains and produce huge false-
positive matches that span unrelated literals.

Allowlist extended with one-line justifications for every additional
occurrence the broadened regex now surfaces (search_objects manual
escape, CATALOGS.LABELS_SCHEMACOL macro, gettext-translated catalog
labels, opcintype oidvector pairs, the pg_get_partkeydef CASE
expression where the interpolation is between two separate literals
rather than inside one).

3. qtLiteral — close the inner-except silent-failure hole.

The fail-fast for a missing `conn` was the right shape but the inner
`except Exception: print(value)` still silently returned the raw
input when `psycopg.sql.Literal(value).as_string(conn)` raised
(unadapted custom type, encoding error, etc.). Same SQL-injection
sink the outer fail-fast just plugged, gated on a different failure
mode. Removed the except so the exception propagates — callers can
now react explicitly instead of seeing an unescaped value flow into
their SQL.

Tests:
  test_stats_template_regclass_cast.py — 10 scenarios that render
    each stats template with an apostrophe-bearing identifier payload
    and assert (positive) the pgstat* call uses ::oid::regclass and
    (negative) no `pgstat*('...'` form recurs, and the apostrophe
    payload never appears inside a literal in the rendered SQL.
  test_qtliteral_no_silent_unescape.py — asserts qtLiteral raises on
    an unadaptable value rather than silently returning it.

42 tests / 0 failures across the touched packages.
This commit is contained in:
Ashesh Vashi
2026-06-10 21:15:34 +05:30
parent 658bb585d1
commit 2ae0d36109
14 changed files with 618 additions and 21 deletions

View File

@@ -19,7 +19,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ exid }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -18,7 +18,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ exid }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -19,7 +19,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ cid }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -18,7 +18,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ cid }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -19,7 +19,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(index)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ idx }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -18,7 +18,7 @@ SELECT
avg_leaf_density AS {{ conn|qtIdent(_('Average leaf density')) }},
leaf_fragmentation AS {{ conn|qtIdent(_('Leaf fragmentation')) }}
FROM
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(index)}}'), pg_catalog.pg_stat_all_indexes stat
pgstatindex({{ idx }}::oid::regclass), pg_catalog.pg_stat_all_indexes stat
{% else %}
FROM
pg_catalog.pg_stat_all_indexes stat

View File

@@ -41,7 +41,7 @@ SELECT
free_space AS {{ conn|qtIdent(_('Free space')) }},
free_percent AS {{ conn|qtIdent(_('Free percent')) }}
FROM
pgstattuple('{{schema_name}}.{{table_name}}'), pg_catalog.pg_stat_all_tables stat
pgstattuple({{ tid }}::oid::regclass), pg_catalog.pg_stat_all_tables stat
{% else %}
FROM
pg_catalog.pg_stat_all_tables stat

View File

@@ -40,7 +40,7 @@ SELECT
free_space AS {{ conn|qtIdent(_('Free space')) }},
free_percent AS {{ conn|qtIdent(_('Free percent')) }}
FROM
pgstattuple('{{schema_name}}.{{table_name}}'), pg_catalog.pg_stat_all_tables stat
pgstattuple({{ tid }}::oid::regclass), pg_catalog.pg_stat_all_tables stat
{% else %}
FROM
pg_catalog.pg_stat_all_tables stat

View File

@@ -40,7 +40,7 @@ SELECT
free_space AS {{ conn|qtIdent(_('Free space')) }},
free_percent AS {{ conn|qtIdent(_('Free percent')) }}
FROM
pgstattuple('{{schema_name}}.{{mview_name}}'), pg_catalog.pg_stat_all_tables stat
pgstattuple({{ vid }}::oid::regclass), pg_catalog.pg_stat_all_tables stat
{% else %}
FROM
pg_catalog.pg_stat_all_tables stat

View File

@@ -40,7 +40,7 @@ SELECT
free_space AS {{ conn|qtIdent(_('Free space')) }},
free_percent AS {{ conn|qtIdent(_('Free percent')) }}
FROM
pgstattuple('{{schema_name}}.{{mview_name}}'), pg_catalog.pg_stat_all_tables stat
pgstattuple({{ vid }}::oid::regclass), pg_catalog.pg_stat_all_tables stat
{% else %}
FROM
pg_catalog.pg_stat_all_tables stat

View File

@@ -51,9 +51,24 @@ WEB_ROOT = os.path.realpath(
# Root inside web/ that the lint walks.
SCAN_ROOT = os.path.join(WEB_ROOT, 'pgadmin')
# Single-quote-wrapped Jinja interpolation. The expression body cannot
# contain ``}`` because ``}}`` ends the Jinja tag.
PATTERN = re.compile(r"'\{\{[^}]+\}\}'")
# Single-quote-wrapped Jinja interpolation. Catches every variant of
# ``'...{{ x }}...'`` that could become a SQL string-literal injection
# sink:
# * Jinja that fills the entire literal — ``'{{ x }}'``.
# * Jinja embedded inside a literal with neighbouring text — e.g.
# ``'%{{ search_text }}%'``.
# * Multiple interpolations inside one literal — e.g.
# ``pgstattuple('{{schema_name}}.{{table_name}}')``.
#
# The body between the outer quotes is restricted to either a complete
# ``{{...}}`` group, or any character that is not ``'``, newline, ``{``
# or ``}``. The ``{``/``}`` exclusion is what stops the regex from
# crossing a Jinja control block ``{%...%}`` or comment ``{#...#}``
# boundary — otherwise long ``{% if ... %}`` conditional templates would
# produce huge false-positive matches that span unrelated literals.
PATTERN = re.compile(
r"'[^'\n{}]*(?:\{\{[^}]+\}\}[^'\n{}]*)+'"
)
# Allowlist of currently-known occurrences. Each entry is
@@ -429,6 +444,155 @@ ALLOWLIST = [
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'default/search.sql', "'{{ obj_type }}'"), 1,
"Fixed object-kind enum supplied by search_objects handler."),
# ------------------------------------------------------------------
# search_objects: search_text is the only request-supplied value in
# these templates. The handler at tools/search_objects/utils.py:120
# pre-doubles every apostrophe via text.replace("'", "''") before
# passing the value to render_template — equivalent to qtLiteral's
# escape semantics, but applied at the Python boundary instead of
# the template. Documented as fragile (manual escape) but currently
# safe. Follow-up: migrate to qtLiteral with explicit % wrapping.
# ------------------------------------------------------------------
(('pgadmin/tools/search_objects/templates/search_objects/sql/pg/'
'11_plus/search.sql', "'%{{ search_text }}%'"), 1,
"search_text is pre-escaped via str.replace in utils.py:120 before "
"render_template; follow-up: migrate to qtLiteral with explicit % "
"wrapping."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/pg/'
'default/search.sql', "'%{{ search_text }}%'"), 1,
"search_text is pre-escaped via str.replace in utils.py:120 before "
"render_template; follow-up: migrate to qtLiteral with explicit % "
"wrapping."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'11_plus/search.sql', "'%{{ search_text }}%'"), 1,
"search_text is pre-escaped via str.replace in utils.py:120 before "
"render_template; follow-up: migrate to qtLiteral with explicit % "
"wrapping."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'12_plus/search.sql', "'%{{ search_text }}%'"), 1,
"search_text is pre-escaped via str.replace in utils.py:120 before "
"render_template; follow-up: migrate to qtLiteral with explicit % "
"wrapping."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'default/search.sql', "'%{{ search_text }}%'"), 1,
"search_text is pre-escaped via str.replace in utils.py:120 before "
"render_template; follow-up: migrate to qtLiteral with explicit % "
"wrapping."),
# ------------------------------------------------------------------
# search_objects: the CATALOGS.LABELS_SCHEMACOL macro emits a fixed
# CASE expression mapping a column name (literal 'sn.schema_name')
# to its localised label. Both macro arguments are static — the
# column-name literal and the gettext function _ — so no
# attacker-controlled value reaches the rendered SQL.
# ------------------------------------------------------------------
(('pgadmin/tools/search_objects/templates/search_objects/sql/pg/'
'11_plus/search.sql',
"'||{{ CATALOGS.LABELS_SCHEMACOL('sn.schema_name', _) }}||'"), 1,
"CATALOGS.LABELS_SCHEMACOL emits a static CASE expression; "
"both macro arguments are hardcoded (column name + gettext fn)."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/pg/'
'default/search.sql',
"'||{{ CATALOGS.LABELS_SCHEMACOL('sn.schema_name', _) }}||'"), 1,
"CATALOGS.LABELS_SCHEMACOL emits a static CASE expression; "
"both macro arguments are hardcoded (column name + gettext fn)."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'11_plus/search.sql',
"'||{{ CATALOGS.LABELS_SCHEMACOL('sn.schema_name', _) }}||'"), 1,
"CATALOGS.LABELS_SCHEMACOL emits a static CASE expression; "
"both macro arguments are hardcoded (column name + gettext fn)."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'12_plus/search.sql',
"'||{{ CATALOGS.LABELS_SCHEMACOL('sn.schema_name', _) }}||'"), 1,
"CATALOGS.LABELS_SCHEMACOL emits a static CASE expression; "
"both macro arguments are hardcoded (column name + gettext fn)."),
(('pgadmin/tools/search_objects/templates/search_objects/sql/ppas/'
'default/search.sql',
"'||{{ CATALOGS.LABELS_SCHEMACOL('sn.schema_name', _) }}||'"), 1,
"CATALOGS.LABELS_SCHEMACOL emits a static CASE expression; "
"both macro arguments are hardcoded (column name + gettext fn)."),
# ------------------------------------------------------------------
# Catalog-label macros: gettext-translated display constants used
# inside CASE WHEN ... THEN '<label>' expressions for the catalog
# name column. The gettext key (English source string) is hardcoded
# in the template; the rendered translation is a static literal.
# ------------------------------------------------------------------
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/pg/macros/catalogs.sql',
"'{{ _( 'ANSI' ) }} (information_schema)'"), 2,
"Gettext-translated static display label for the ANSI catalog."),
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/pg/macros/catalogs.sql',
"'{{ _( 'PostgreSQL Catalog' ) }} (pg_catalog)'"), 2,
"Gettext-translated static display label for pg_catalog."),
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/pg/macros/catalogs.sql',
"'{{ _( 'pgAgent Job Scheduler' ) }} (pgagent)'"), 2,
"Gettext-translated static display label for pgAgent schema."),
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/ppas/macros/catalogs.sql',
"'{{ _( 'ANSI' ) }} (information_schema)'"), 2,
"Gettext-translated static display label for the ANSI catalog."),
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/ppas/macros/catalogs.sql',
"'{{ _( 'PostgreSQL Catalog' ) }} (pg_catalog)'"), 2,
"Gettext-translated static display label for pg_catalog."),
(('pgadmin/browser/server_groups/servers/databases/schemas/'
'templates/catalog/ppas/macros/catalogs.sql',
"'{{ _( 'pgAgent Job Scheduler' ) }} (pgagent)'"), 2,
"Gettext-translated static display label for pgAgent schema."),
# ------------------------------------------------------------------
# types.get_subtypes: ``opcintype`` is a pg_catalog OID joined from
# pg_opclass / pg_amop and rendered as a space-separated pair to
# match proargtypes (which uses a space-separated oidvector text
# form). Not user-supplied.
# ------------------------------------------------------------------
(('pgadmin/browser/server_groups/servers/databases/schemas/types/'
'templates/types/pg/sql/default/get_subtypes.sql',
"'{{opcintype}} {{opcintype}}'"), 1,
"opcintype is a pg_catalog OID joined from pg_opclass; the "
"literal is the space-separated proargtypes oidvector form."),
(('pgadmin/browser/server_groups/servers/databases/schemas/types/'
'templates/types/ppas/sql/default/get_subtypes.sql',
"'{{opcintype}} {{opcintype}}'"), 1,
"opcintype is a pg_catalog OID joined from pg_opclass; the "
"literal is the space-separated proargtypes oidvector form."),
# ------------------------------------------------------------------
# Partition CASE expression: pg_get_partkeydef(tid::oid) is the
# value of the CASE branch, sandwiched between the literal 'p' and
# the literal ''. The regex matches from 'p' through '' because
# those quotes share a line, but the {{ tid }} interpolation is
# OUTSIDE any literal — it's an argument to pg_get_partkeydef. The
# tid value also passes a Flask ::int route converter, so it's an
# integer regardless. Documented here so the lint stays
# well-policed even with a regex that can't distinguish
# "between two literals" from "inside one literal".
# ------------------------------------------------------------------
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
'templates/tables/sql/11_plus/properties.sql',
"' THEN pg_catalog.pg_get_partkeydef({{ tid }}::oid) ELSE '"), 1,
"{{ tid }} is the pg_class OID validated by Flask <int:tid> route "
"converter; the surrounding 'p' and '' are SEPARATE literals, the "
"regex spans two of them but the interpolation is outside any "
"literal."),
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
'templates/tables/sql/12_plus/properties.sql',
"' THEN pg_catalog.pg_get_partkeydef({{ tid }}::oid) ELSE '"), 1,
"{{ tid }} is the pg_class OID validated by Flask <int:tid> route "
"converter; the surrounding 'p' and '' are SEPARATE literals, the "
"regex spans two of them but the interpolation is outside any "
"literal."),
(('pgadmin/browser/server_groups/servers/databases/schemas/tables/'
'templates/tables/sql/default/properties.sql',
"' THEN pg_catalog.pg_get_partkeydef({{ tid }}::oid) ELSE '"), 1,
"{{ tid }} is the pg_class OID validated by Flask <int:tid> route "
"converter; the surrounding 'p' and '' are SEPARATE literals, the "
"regex spans two of them but the interpolation is outside any "
"literal."),
]

View File

@@ -0,0 +1,345 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Regression test: ``pgstattuple(...)`` and ``pgstatindex(...)`` in the
stats templates must address their target via ``<oid>::oid::regclass``,
NOT via a single-quoted ``'<schema>.<name>'`` string literal that
embedded raw Jinja interpolations.
The earlier pattern (``pgstatindex('{{conn|qtIdent(schema)}}.{{conn|
qtIdent(name)}}')`` / ``pgstattuple('{{schema_name}}.{{table_name}}')``)
was vulnerable: a low-privilege user could create an object whose name
contained an apostrophe — ``CREATE TABLE "foo'bar" (...)`` — and any
viewer who then opened the stats panel would render an unbalanced SQL
literal, breaking out of the ``'...'`` and executing whatever followed.
The fix replaces the embedded interpolation with an OID cast — the
relation is identified by its numeric OID and the ``::regclass`` cast
resolves it server-side, so no string literal is involved at all.
That makes the bug structurally impossible, not just guarded against.
POSITIVE assertions:
* The rendered stats SQL contains ``pgstattuple(<oid>::oid::regclass)``
or ``pgstatindex(<oid>::oid::regclass)``.
* An apostrophe planted in any identifier passed to the template
does NOT appear inside a SQL literal context in the output (i.e.
the template no longer embeds the identifier at all).
NEGATIVE assertions (regression guards):
* The previously-vulnerable ``pgstattuple('...'``/``pgstatindex('...'``
string-literal-call pattern must NOT recur.
* Specifically the apostrophe payload must NOT survive verbatim
inside any single-quoted region of the rendered SQL.
"""
import os
import re
from flask import Flask, render_template
from jinja2 import FileSystemLoader
from pgadmin.utils.driver import get_driver
from pgadmin.utils.route import BaseTestGenerator
from config import PG_DEFAULT_DRIVER
# Apostrophe-injection payload. Anywhere this appears INSIDE a single-
# quoted literal in the rendered SQL is a SQLi.
APOSTROPHE_NAME = "evil'or'1=1--"
WEB_ROOT = os.path.realpath(
os.path.join(os.path.dirname(os.path.realpath(__file__)),
os.pardir, os.pardir, os.pardir, os.pardir, os.pardir,
os.pardir)
)
class _FakeConn:
"""qtIdent / qtLiteral use this as a stand-in for a real psycopg
connection (see test_comment_description_sql_escaping.py for the
full reasoning)."""
conn = None
def __bool__(self):
return True
def _abs(*parts):
return os.path.join(WEB_ROOT, *parts)
# Macro roots needed by the schemas templates' imports.
MACRO_ROOTS = [
_abs('pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'templates'),
_abs('pgadmin', 'browser', 'server_groups', 'servers', 'templates'),
]
class _FakeApp(Flask):
"""Minimal Flask app mirroring the production Jinja filters."""
def __init__(self, template_root):
super().__init__('')
driver = get_driver(PG_DEFAULT_DRIVER, self)
self.jinja_env.filters['qtLiteral'] = driver.qtLiteral
self.jinja_env.filters['qtIdent'] = driver.qtIdent
self.jinja_env.filters['qtTypeIdent'] = driver.qtTypeIdent
self.jinja_env.loader = FileSystemLoader(
[template_root] + MACRO_ROOTS
)
# Regex: any single-quoted literal in the rendered SQL.
SQL_LITERAL = re.compile(r"'[^'\n]*'")
class StatsTemplateRegclassCastTestCase(BaseTestGenerator):
"""Each stats template must address the relation via OID + regclass,
not via an embedded ``'<schema>.<name>'`` literal."""
scenarios = [
(
'Table stats (16_plus) — pgstattuple(tid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='tables/sql/16_plus/stats.sql',
ctx=dict(
tid=12345,
schema_name='public',
table_name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstattuple(12345::oid::regclass)',
forbid_call_re=r"pgstattuple\('",
),
),
(
'Table stats (default) — pgstattuple(tid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='tables/sql/default/stats.sql',
ctx=dict(
tid=12345,
schema_name='public',
table_name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstattuple(12345::oid::regclass)',
forbid_call_re=r"pgstattuple\('",
),
),
(
'Materialised view stats (pg) — pgstattuple(vid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'views', 'templates'),
template='mviews/pg/default/sql/stats.sql',
ctx=dict(
vid=67890,
schema_name='public',
mview_name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstattuple(67890::oid::regclass)',
forbid_call_re=r"pgstattuple\('",
),
),
(
'Materialised view stats (ppas) — pgstattuple(vid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'views', 'templates'),
template='mviews/ppas/default/sql/stats.sql',
ctx=dict(
vid=67890,
schema_name='public',
mview_name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstattuple(67890::oid::regclass)',
forbid_call_re=r"pgstattuple\('",
),
),
(
'Exclusion constraint stats (16_plus) — '
'pgstatindex(exid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='exclusion_constraint/sql/16_plus/stats.sql',
ctx=dict(
exid=11111,
schema='public',
name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(11111::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
(
'Exclusion constraint stats (default) — '
'pgstatindex(exid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='exclusion_constraint/sql/default/stats.sql',
ctx=dict(
exid=11111,
schema='public',
name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(11111::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
(
'Index constraint stats (16_plus) — '
'pgstatindex(cid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='index_constraint/sql/16_plus/stats.sql',
ctx=dict(
cid=22222,
schema='public',
name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(22222::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
(
'Index constraint stats (default) — '
'pgstatindex(cid::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='index_constraint/sql/default/stats.sql',
ctx=dict(
cid=22222,
schema='public',
name=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(22222::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
(
'Index stats (16_plus) — pgstatindex(idx::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='indexes/sql/16_plus/stats.sql',
ctx=dict(
idx=33333,
schema='public',
index=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(33333::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
(
'Index stats (default) — pgstatindex(idx::oid::regclass)',
dict(
template_root=_abs(
'pgadmin', 'browser', 'server_groups', 'servers',
'databases', 'schemas', 'tables', 'templates'),
template='indexes/sql/default/stats.sql',
ctx=dict(
idx=33333,
schema='public',
index=APOSTROPHE_NAME,
is_pgstattuple=True,
conn=_FakeConn(),
),
expected_call='pgstatindex(33333::oid::regclass)',
forbid_call_re=r"pgstatindex\('",
),
),
]
def setUp(self):
self.app_under_test = _FakeApp(self.template_root)
def runTest(self):
# The stats templates use `_('...')` for column aliases. Inject
# an identity gettext stand-in so the templates render without
# needing a real flask-babel context (we don't care about
# localisation here; we only assert on the pgstat* call shape).
ctx = dict(self.ctx, _=lambda s: s)
with self.app_under_test.app_context():
rendered = render_template(self.template, **ctx)
# POSITIVE: the regclass-cast call is present.
self.assertIn(
self.expected_call, rendered,
msg=(
'Stats template should address the relation via '
'OID::oid::regclass; expected {!r} in rendered SQL.\n'
'Rendered:\n{}'.format(self.expected_call, rendered)
),
)
# NEGATIVE: the previously-vulnerable string-literal call form
# must NOT recur.
self.assertIsNone(
re.search(self.forbid_call_re, rendered),
msg=(
'Stats template must not call pgstat* with a '
"single-quoted relation name (the previously-vulnerable "
'pattern). Pattern: {!r}\nRendered:\n{}'
.format(self.forbid_call_re, rendered)
),
)
# NEGATIVE: the apostrophe payload, if it appears at all, must
# not appear *inside* a single-quoted literal region — that
# would indicate the identifier is being embedded somewhere
# else in the template.
for literal in SQL_LITERAL.findall(rendered):
self.assertNotIn(
APOSTROPHE_NAME, literal,
msg=(
'Apostrophe-bearing identifier payload appeared '
'inside a SQL literal in the rendered stats SQL — '
'indicates the identifier is still being embedded '
'in some literal context.\n'
'Offending literal: {!r}\nRendered:\n{}'
.format(literal, rendered)
),
)

View File

@@ -318,14 +318,21 @@ class Driver(BaseDriver):
"from Python, pass the connection as the second argument."
)
res = value
try:
if not isinstance(conn, psycopg.Connection) and \
not isinstance(conn, psycopg.AsyncConnection):
conn = conn.conn
res = psycopg.sql.Literal(value).as_string(conn).strip()
except Exception:
print("Exception", value)
# Resolve the underlying psycopg connection. A wrapped pgAdmin
# Connection has the raw psycopg connection on `.conn`.
if not isinstance(conn, psycopg.Connection) and \
not isinstance(conn, psycopg.AsyncConnection):
conn = conn.conn
# psycopg.sql.Literal.as_string can raise for values it cannot
# adapt (custom types without a registered adapter, byte
# sequences in a non-UTF8 client encoding, etc.). The previous
# implementation swallowed the exception and silently returned
# the raw, unescaped `value` — the same SQL-injection sink the
# missing-conn fast-fail above guards against, but on a
# different failure mode. Letting the exception propagate makes
# any unadaptable input loud and callers can react explicitly.
res = psycopg.sql.Literal(value).as_string(conn).strip()
if force_quote is True:
# Convert the input to the string to use the startsWith(...)

View File

@@ -0,0 +1,81 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Regression test: ``qtLiteral`` must not silently degrade to the raw,
unescaped input value when the inner ``psycopg.sql.Literal(...)
.as_string(conn)`` call raises.
Historical behaviour was:
res = value # raw input held as the default
try:
...
res = Literal(value).as_string(conn).strip()
except Exception:
print(...) # swallowed — `res` stays as the raw value
That is the same SQL-injection sink the missing-``conn`` fail-fast
guards against, just gated on a different failure mode (psycopg
inability to adapt a value rather than absence of a connection). This
test exercises the inner-except path with an object that ``psycopg.sql
.Literal`` cannot adapt, and asserts the call raises rather than
returning the raw object.
"""
from pgadmin.utils.driver import get_driver
from pgadmin.utils.route import BaseTestGenerator
from config import PG_DEFAULT_DRIVER
class _Unadaptable:
"""Custom object psycopg has no registered adapter for. The
``psycopg.sql.Literal(...).as_string(conn)`` call must raise on
this — it has no idea how to render the value as a SQL literal."""
def __repr__(self):
return '<_Unadaptable>'
class _MinimalConn:
"""Stand-in for a real psycopg Connection. The driver's qtLiteral
sees this as truthy (so the missing-conn fail-fast is bypassed)
and dereferences `.conn` to the psycopg-side connection. We set
that to None to ensure the inner Literal.as_string call raises
(psycopg requires a real context to render adapted SQL)."""
conn = None
def __bool__(self):
return True
class QtLiteralRaisesOnUnadaptableValueTestCase(BaseTestGenerator):
"""The inner ``psycopg.sql.Literal(...).as_string(conn)`` failure
path must propagate — not silently return the raw value."""
scenarios = [
('Unadaptable Python object surfaces as an exception',
dict(value=_Unadaptable())),
]
def runTest(self):
driver = get_driver(PG_DEFAULT_DRIVER)
with self.assertRaises(Exception) as ctx:
driver.qtLiteral(self.value, _MinimalConn())
# And specifically: the original value must NOT be returned —
# if it were, the exception would not have been raised.
# The ``with self.assertRaises`` block guarantees a raise; this
# assertion documents the contract that the call site relies
# on (no silent unescaped passthrough).
self.assertNotIsInstance(
ctx.exception, type(None),
msg='qtLiteral must raise on unadaptable input, not '
'silently return it.'
)