doc: Correct the macOS Replace shortcut in the Query Tool toolbar docs. #9817
The Edit toolbar table listed Replace as "Option + Cmd + F (MAC)" /
"Ctrl + Shift + F (Others)", but the default Replace shortcut is
Cmd/Ctrl + R, per register_editor_preferences.py.
Tighten the release-notes prose for the two security entries whose
original one-liner didn't carry the impact framing settled during
Dave Page's 2026-06-11 CVSS review.
#10068 (CVE-2026-12048): re-lead with the critical-severity chain.
Default pgAdmin Content-Security-Policy allows inline script and an
iframe srcdoc inherits the embedding origin, so attacker JavaScript
ran same-origin to the victim's authenticated pgAdmin session and
could read every saved server connection credential and issue
arbitrary SQL against every server the victim was connected to.
The previous wording described the sinks but not the impact.
#10078 (CVE-2026-12044): re-lead with the stored
pgstattuple/pgstatindex sink (low-privilege user names a table or
index foo'bar, superuser viewer triggers SQL under the superuser
role). That sub-defect is what earns the score; the description-
field self-injection is bundled because the fix is the same. The
previous wording led with the self-injection only.
Both rewrites match the descriptions used in the CVE JSON records
submitted to the PostgreSQL CNA.
Extend the read-only query validator's regression suite with 17 more
scenarios lifted from a v2 of the original #10022 patch that was
never committed (recovered from an uncommitted draft).
Five new accept scenarios cover parser corner cases:
- leading whitespace
- trailing semicolon followed by trailing whitespace
- leading line comment
- semicolon inside a string literal (must not split the statement)
- (SELECT 1) UNION (SELECT 2), confirming the leading-keyword
traversal walks past parentheses
Twelve new reject scenarios pin commands that directly attack the
BEGIN TRANSACTION READ ONLY wrapper rather than merely attempting a
generic write:
- bare COMMIT / END / ROLLBACK / ABORT / BEGIN as single statements
(previously tested only as the leading verb of a multi-statement
payload)
- START TRANSACTION (synonym for BEGIN)
- SAVEPOINT (savepoint manipulation)
- SET LOCAL transaction_read_only = off
- SET SESSION default_transaction_read_only = off
- DISCARD ALL (resets session state -- search_path, prepared
statements, etc.)
- multi-statement payloads where the leading statement is an
allowed verb (SELECT or WITH) followed by ROLLBACK and a write
-- closer to a real attack shape than the trailing-COMMIT PoC
If a future refactor of _ALLOWED_LEADING_KEYWORDS or the statement-
counting logic lets any of these slip through, the new scenarios
fail loudly. Module is now 77 passed / 0 failed / 0 skipped.
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.
Fixes a SQL injection vulnerability where authenticated users could
break out of SQL string literals in COMMENT ON ... IS '<description>'
clauses by submitting an apostrophe-laden description through pgAdmin
dialogs. The original report covered domains; the patch expands the
fix to every site of the same pattern.
Three layers of defense:
1. Site fixes (16 places) — Replace '{{ x.description }}' with
{{ x.description|qtLiteral(conn) }} across templates for domains,
domain constraints, foreign tables, languages, event triggers,
and the views OID-lookup query. Plumbs conn=self.conn through
every render_template call that needed it. Also fixes a `{ % elif`
Jinja typo in foreign-table schema diff that was preventing the
elif branch from being reachable.
2. Driver hardening — qtLiteral (in utils/driver/psycopg3/__init__.py)
used to silently return the raw unescaped value when conn was
falsy. Now raises ValueError with a message pointing at the two
fixes (render_template(..., conn=) or pass conn as the second
argument). Surfaces this whole bug class loudly going forward,
and immediately uncovered 8 latent plumbing bugs in
schemas/__init__.py, schemas/functions/__init__.py,
schemas/tables/utils.py, foreign_servers/__init__.py, and 7 sites
in roles/__init__.py — all now fixed.
3. Regression tests (3 new files):
- test_comment_description_sql_escaping.py — renders each
previously-vulnerable template with an apostrophe-injection
payload and asserts the escaped fragment is present (15
scenarios).
- test_sql_string_literal_lint.py — walks every *.sql template,
flags every '{{ ... }}' single-quote-wrapped Jinja
interpolation, and compares against a curated allowlist (75
entries, each with a justification — OIDs, fixed enums,
server-derived identifiers, SQL-comment headers, etc.). New
occurrences fail the test until either qtLiteral is used or
an allowlist entry is added.
- test_qtliteral_requires_conn.py — unit test asserting the new
fail-fast behavior.
Reported by Jasser Chebbi (j3seer).
The 'close' (DELETE /sqleditor/close/<trans_id>) and
'update_sqleditor_connection' (POST /sqleditor/initialize/sqleditor/
update_connection/...) endpoints were the only state-mutating SQL Editor
routes missing @pga_login_required. Both reach pickle.loads on
session['gridData'][trans_id]['command_obj'] via
close_sqleditor_session() and check_transaction_status() respectively.
Combined with a forged session file (precondition: SECRET_KEY leak +
write access to sessions/), this gave an unauthenticated attacker a
pickle deserialization sink and arbitrary code execution in the pgAdmin
process. Adding the login decorator forces is_authenticated/MFA checks
before the unsafe deserialization path is reached, matching the
convention used by every other endpoint in the module.
Includes a server-mode regression test that harvests a CSRF token from
GET /login (mirroring the attacker's path) and asserts both endpoints
reject the unauthenticated request before reaching the route body.
Self-skips in DESKTOP mode because pgAdmin's before_request hook
re-authenticates DESKTOP_USER on every request there, so no auth
decorator can be exercised in an unauthenticated state. Wired into the
existing server-mode CI workflow alongside the data-isolation tests.
Reported by Fernando Bortotti <fernando.bortotti@bsd.com.br>.
Promote the post-connection-SQL sanitiser to a generic helper and apply
it to every cloud-module endpoint that propagates AWS / Azure / Google
SDK exception text into a JSON response field. Closes the RDS
HTML-injection vector reported against /rds/verify_credentials/ and
sweeps the same pattern across the verify-credentials, deploy, regions,
and update-server paths so no remaining cloud endpoint embeds raw
SDK / OS exception text into the response.
Sanitiser
- Move web/pgadmin/utils/driver/psycopg3/text_sanitize.py to
web/pgadmin/utils/text_sanitize.py.
- Rename sanitize_driver_message -> sanitize_external_text; the
function now describes its real role (HTML-escape text from any
external/untrusted source — driver, cloud SDK, OS process).
- Move tests to web/pgadmin/utils/tests/test_text_sanitize.py.
- Update the single existing importer in psycopg3/connection.py.
Backend wrap sites
- misc/cloud/rds/__init__.py — verify_credentials info= (the
reported case: AWS STS IncompleteSignature echoes access_key
verbatim into the exception string) and get_regions errormsg=
(boto3 Session().get_available_regions exception).
- misc/cloud/azure/__init__.py — verify_credentials and
check_cluster_name_availability errormsg= (3 sites).
- misc/cloud/google/__init__.py — verify_credentials
(PermissionError + generic Exception path-resolution branches and
the get_auth_url error), verification_ack, projects, regions,
instance_types, database_versions — 8 sites covering every path
that propagates SDK / file-resolution exception text.
- misc/cloud/__init__.py — central /deploy endpoint (errormsg=resp
where resp is str(e) bubbled up from deploy_on_rds /
deploy_on_azure / deploy_on_google) and update_cloud_server
(errormsg=server where server is the str(e) from db.session.commit
failure).
Frontend
- components/FormComponents.jsx — declare plainText in
FormFooterMessage.propTypes (it already passes through via
spread).
- misc/cloud/static/js/CloudWizard.jsx — add plainText to the three
FormFooterMessage sites. INFO messages there are gettext literals,
so plain-text rendering is uniformly safe; the ERROR path now
follows the SafeMessage contract for backend-derived strings.
Tests
- rds/tests/test_rds_verify_credentials_xss.py — wiring test that
patches RDS.validate_credentials to return an AWS-shaped error
embedding an <iframe> payload, asserts the response info field is
entity-encoded and contains no raw markup. Includes a happy-path
guard against the sanitiser altering 'verified'.
- azure/tests/test_azure_verify_credentials_xss.py — same pattern,
Azure.__init__ touches current_user so the Azure class is patched
to a stand-in that yields the HTML-bearing validation error.
- google/tests/test_google_verify_credentials_xss.py — covers the
PermissionError and generic-Exception branches of the
client_secret_file resolution path (most directly user-influenced).
- test_post_connection_sql_xss.py — docstring + comment updates for
the renamed function.
Frontend rendering is already DOMPurify-guarded at the NotifierMessage
sink, so the reported PoC payload would not execute even without
these backend changes; the fixes here are the defence-in-depth layer
the report's section-5 "Suggested directions" calls out, ensuring API
consumers other than the browser (audit logs, third-party clients)
never see raw markup.
876 / 876 JS tests + ESLint clean. Cloud / utils / server-groups
Python test packages pass (24 + 60 + 102, with pre-existing skips
unchanged).
Mitigates a stored XSS vector where a malicious PostgreSQL server's
ErrorResponse — or any backend-derived string — could inject HTML into
pgAdmin's DOM via the notifier, Explain visualiser, or form-input error
paths. Combines three complementary layers of defence.
Frontend — DOMPurify at every HTMLReactParse sink:
- NotifierMessage (toasts) and FormFooterMessage
- FormInput help / error messages, FormNote
- ModalProvider AlertContent + confirmDelete
- ToolErrorView (tool open failure)
- Explain/Analysis NodeText (plan extra-info renderer)
- SQL editor confirm dialogs (transaction, promotion, close-running)
- Dialogs/ConfirmSaveContent
- PreferencesHelper modal alert
- SelectThemes helper text
No HTMLReactParse call site renders text unsanitised after this.
Frontend — plain-text contract for backend-derived strings:
- New components/SafeMessage.jsx exports SafeMessage (plain text,
preserves newlines via pre-wrap) and SafeHtmlMessage (sanitised
HTML via DOMPurify + html-react-parser).
- NotifierMessage and ModalProvider.AlertContent accept a `plainText`
prop. When true the body renders via SafeMessage.
- Notifier gains errorText / alertText / warningText / infoText /
successText methods that plumb {plainText: true} through. These
are the correct choice whenever the message may originate from a
PostgreSQL server, driver, OS process, remote API, or any other
untrusted channel.
- pgRespErrorNotify and pgNotifier use alertText / errorText
internally. The redundant _.escape and \\n -> <br/> substitution
in pgNotifier are gone — the SafeMessage container's pre-wrap
preserves newlines without further transformation.
- FormInput.errorMessage and ToolErrorView render as plain text
unconditionally; their inputs are always validation / driver
errors.
- ~50 callers across browser, tools, dashboard, debugger, misc, llm,
preferences, schema diff, and the SQL editor are migrated to the
*Text variants where they pass a backend-derived field
(err.response.data.errormsg, parseApiError(error), res.errormsg,
res.info, res.data.result, err.message, interpolated PG object
names).
- Browser auto-update notification rewritten to plain text — the URL
is shown inline instead of being interpolated into an <a href>
attribute. The gettext template-literal anti-pattern on that path
is fixed too (URL is a separate %s argument).
Cleanup:
- helpers/Notifier.jsx — remove the dead AlertContent function and
its seven imports (Box, CloseIcon, DefaultButton, PrimaryButton,
HTMLReactParser, CheckRoundedIcon). The function was defined but
never instantiated; the real top-level alert path goes through
ModalProvider.AlertContent. Eliminates a false-positive sink that
recent audits cite — the cited line numbers reference a function
that never runs.
Explain — escape Recheck Cond / Exact Heap Blocks:
- nodeExplainTableData concatenates plan-node fields into HTML
strings parsed by html-react-parser. Every sibling field (Filter,
Index Cond, Hash Cond, Join Filter, Rows Removed by Filter, Hash
Buckets/Batches/Peak Memory) wraps the value in _.escape();
Recheck Cond and Exact Heap Blocks were the two outliers
concatenating raw. An attacker who introduces a table with a
crafted column name and triggers a Bitmap Heap Scan over it could
inject HTML into Explain output that a victim later views in the
visualiser. With the rest of the XSS fix this would otherwise be
neutralised by DOMPurify at the sink — escaping at construction
matches the sibling fields and provides defence in depth.
Backend — HTML-escape PG-returned text in post-connection-SQL:
- New web/pgadmin/utils/driver/psycopg3/text_sanitize.py exports
sanitize_driver_message. Strips C0 control characters
(0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F) — preserving TAB, LF,
CR so multi-line error messages stay readable — then HTML-escapes
via html.escape(..., quote=True).
- execute_post_connection_sql logs the raw PG-returned text so the
server log stays human-readable, and applies the escape only to
the value crossing into the JSON response body, where downstream
consumers may render it as HTML:
<iframe srcdoc="..."> -> <iframe srcdoc="...">
Real Postgres errors (which never contain literal markup) round-
trip unchanged for human readability. Protects third-party JSON
consumers (audit logs, API clients, support tickets) that do not
have the frontend's DOMPurify.
Tests
- components/SafeMessage.spec.js — 25 payloads (iframe srcdoc, SVG
onload, MathML href javascript:, embed/object data:, marquee
onstart, template/noscript, CSS expression, etc.).
- Notifier.spec.js — 9 cases (errorText / alertText plumbing,
pgRespErrorNotify 410 vs non-410, pgNotifier raw errormsg pass-
through, "Unknown error" fallback).
- ToolErrorView.spec.js — 3 cases.
- ModalProvider.spec.js — 4 cases.
- FormComponents.spec.js extended with NotifierMessage plainText
mode + FormInput errorMessage plain-text rendering.
- Explain regression test renders Analysis with HTML-laden extraInfo
and asserts no <iframe> / <script> reaches the DOM.
- Python: test_sanitize_driver_message.py (21 sanitiser + 5
execute_post_connection_sql wiring scenarios with a real Flask +
flask-babel app context and a stand-in connection).
- test_post_connection_sql_xss.py — end-to-end integration: register
a server, set post_connection_sql to a query referencing a relation
whose name contains an iframe payload, POST to the connect endpoint,
and assert the response JSON errormsg is entity-encoded and never
raw markup.
- fake_pgadmin.js notifier mocks and enable_disable_triggers_spec.js
spies updated for the new API.
875 / 875 JS tests pass; ESLint clean.
When a table inherits a column from a parent, the generated table
SQL/EDIT script omitted the column's default. Emit a post-create
ALTER TABLE ... ALTER COLUMN ... SET DEFAULT for inherited columns
that carry a default, and show the default in the inherited-column
comment, across all version buckets.
Generated columns (colconstype 'g') are excluded from both the new
ALTER statement and the comment default, consistent with the inline
column-definition logic, since SET DEFAULT is invalid for a generated
column.
When a database connection was silently dropped while pgAdmin sat idle
(common on Linux Desktop behind a firewall/NAT), the Object Explorer and
Query Tool would hang on an infinite spinner instead of offering to
reconnect, because connected() only checks local driver state and misses
stale/half-open TCP sockets.
- ping() now performs a real network check (SELECT 1) guarded by the
connection's transaction_status, so an in-progress query or open
transaction is never disrupted; on failure it tears down the dead
connection and returns False.
- The Object Explorer children() endpoint uses ping() to detect a dead
connection up front and returns 503 CONNECTION_LOST; the tree shows a
reconnect dialog (deduplicated per server) and marks the node
disconnected.
- TCP keepalive defaults are applied to all connections so the OS
surfaces dead sockets in seconds rather than the full retransmission
timeout.
- The Query Tool re-checks connection status when its tab becomes
visible again, and its layout/visibility listeners are now cleaned up
on unmount.
Refs #9700, #8279. Adds a 9.16 release note.
The passfile kwarg passed to Connection.connect() was only ever used as
a gate for the passexec fallback; it was never forwarded to
create_connection_string(), which builds the DSN's passfile from the
ServerManager's connection parameters. This made the gate inconsistent
with the passfile actually used for the connection.
Use the ServerManager passfile for the credential gate so the check
matches what is used to connect. The manager passfile now takes
precedence over both passexec and any passfile kwarg; warnings are
emitted when either is ignored in its favor.
The entries for #9875 (EXPLAIN/blank-line query extraction) and #9988
(OAuth2 metadata URL guidance) were added to release_notes_9_9.rst,
which is an already-released version. Move them to the in-progress
9.16 notes where they belong.
The two init containers in the Helm deployment template had hardcoded
securityContext blocks, unlike the main container which already renders
its context from .Values.containerSecurityContext via the
renderSecurityContext helper. Switch the init containers to the same
pattern so operators can customise (or disable) their security context.
Default behaviour is unchanged: containerSecurityContext defaults to
enabled with values identical to the previous hardcoded block, and the
helper continues to gate appArmorProfile on
global.compatibility.appArmor.enabled. Verified with helm template that
the rendered init-container securityContext is unchanged for the default
values, honours the appArmor toggle, and is omitted entirely when
containerSecurityContext.enabled=false.
Adds a 9.16 release note.
getQueryAt now uses the syntax tree as the primary check for whether a
blank-line boundary cut through a SQL statement. It is split into two
helpers: _findQueryBoundaries (the original scan, parameterized by
stopAtBlankLine) and _needsExpansion, which detects when a Statement
node straddles the extracted range and re-scans ignoring blank lines.
A STATEMENT_STARTERS keyword list guards the case where the parser
merges semicolon-less queries into one Statement, and WRAPPER_STARTERS
(EXPLAIN, ANALYZE, WITH) force expansion when the Statement extends past
the range. Also fixes a Lezer boundary bug (tree.iterate is inclusive at
boundaries) with a node.to > startPos check. Adds 21 tests covering
EXPLAIN, negative no-merge cases, comments, and boundary edge cases.
Some OpenAI-compatible providers emit empty or null name/arguments/id
fields in streaming continuation deltas to keep the response schema
stable. pgAdmin's accumulator overwrote the real tool name (captured in
the first delta) with the later null, producing a tool call named "null"
that could not be dispatched.
Skip falsy name/arguments/id when accumulating (matching the OpenAI
Python SDK, which ignores nulls the same way) so the values captured in
the first delta survive. Also guard against a null `function` object in
a delta, which previously raised TypeError. Without the id guard a null
id in a continuation delta clobbered the real id, which the final build
then replaced with a random uuid rather than the provider's id.
Adds a unit test covering the null-continuation, multi-chunk-arguments,
and null-function cases, and a 9.16 release note.
When OAUTH2_SCOPE contains 'openid' but OAUTH2_SERVER_METADATA_URL is
not set, Authlib fails deep inside id_token verification with a cryptic
`Missing "jwks_uri" in metadata` error that names neither the config
knob nor the fix.
Add a pre-flight check at the entry of _authorize_access_token: if the
scope includes 'openid' and no (non-whitespace) metadata URL is set,
raise a RuntimeError with actionable guidance before any network
round-trip. server_metadata_url is the only way pgAdmin feeds JWKS to
Authlib, so this carries no regression risk for correctly-configured
providers. Clarify the OAUTH2_SERVER_METADATA_URL comment in config.py
and add regression coverage.
The container entrypoint substituted ${PGADMIN_CONFIG_CONFIG_DATABASE_URI}
into a double-quoted Python string for `python3 -c`. Combined with the
config_distro.py convention (where the env var's value must itself be a
Python literal, i.e. users set it to 'postgresql+psycopg://...'), the
entrypoint re-wrapped the already-quoted value, producing a string with
literal quotes inside that SQLAlchemy could not parse -- and the Python
crash made the first-launch check capture an empty string, silently
skipping PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD setup.
Read the env var inside Python via os.environ so the shell no longer
participates in Python-literal quoting (also removing a shell-injection
surface), and use ast.literal_eval to unwrap the legacy quoted form while
letting raw values pass through. external_config_db_exists now stays
"False" on any Python failure so first-launch setup still runs.
Adds a 9.16 release note.
An old configuration database (e.g. a pre-9.0 pgadmin4.db lingering in
%appdata%) can carry a stale foreign key such as server.user_id ->
user_old, a table removed long ago. SQLAlchemy's MetaData.reflect()
defaults to resolve_fks=True, which auto-follows the reflected table's
foreign keys and reflects their targets too; the orphan target trips
NoSuchTableError and aborts the startup migration, surfacing in the GUI
as the misleading "Server could not be contacted".
None of the migrations use the FK-target tables -- each operates only on
the explicitly requested table(s). Pass resolve_fks=False at all 14
meta.reflect() call sites across the 12 migration files so reflection no
longer cascades into broken FK targets. Behaviour is unchanged for
healthy databases.
Adds a 9.16 release note.
This internal design proposal was inadvertently included with the
session and file-manager hardening changes and should not have been
added to the repository.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes the PostgreSQL 11+ TOAST_TUPLE_TARGET storage parameter in the
Materialized View properties dialog, mirroring the existing FILLFACTOR
support: an integer field (128-8160) in the Definition group, extracted
from reloptions in properties.sql, emitted in the CREATE ... WITH clause,
and SET/RESET on edit. The recreate-on-definition-change path preserves
the value.
The dialog field and SQL are applied to the version buckets that
supported servers actually resolve to. The original change only touched
the legacy default/ bucket (PG < 12), so it had no effect on any
supported server (13-18, which resolve to 13_plus/15_plus); the
create/properties/update templates are now updated in the 12_plus,
13_plus and 15_plus buckets too. Template output is byte-identical to
the previous SQL when toast_tuple_target is unset.
Adds a 9.16 release note.
Wire the relpersistence change through to the table update SQL across all
version buckets (default, 11_plus, 12_plus, 15_plus) so the Unlogged toggle
emits ALTER TABLE ... SET LOGGED/UNLOGGED on every supported server.
Closes#9677.
Schema Diff's "Generate Script" emitted CREATE TABLE DDL with
the libpq-expanded form of SERIAL columns:
col integer NOT NULL DEFAULT nextval('table_col_seq'::regclass)
instead of preserving the SERIAL declaration. Applied to a clean
target, that script fails with `relation "table_col_seq" does
not exist` because the sequence is never created — exactly the
symptom in issue #9896.
SERIAL detection already existed in
columns/utils.py:get_formatted_columns (added in 115208c8d for
ERD generation, Nov 2023), but was gated behind a
``with_serial_cols`` parameter that defaulted to False. Only ERD
and the Schema Diff *comparison* phase opted in via explicit
True. The Schema Diff *Generate Script* path and the browser
tree's *CREATE Script* view went through `_get_resql_for_table`
→ `_formatter(data)` without specifying it, so SERIAL detection
was skipped and #9896 reproduced.
There is no use case where pgAdmin should knowingly emit the
expanded libpq form: SERIAL is purely a CREATE-time macro that
PostgreSQL expands into an integer column plus an implicit
sequence owned by the column. The detection logic is reversing
that expansion, which is the correct interpretation in all
contexts (DDL emission, comparison, ERD, properties dialog).
Flip the default to True at all four declaration sites:
- columns/utils.py: get_formatted_columns
- utils.py: BaseTableView._formatter
- utils.py: BaseTableView.fetch_tables
- __init__.py: TableView.fetch_tables
All four existing explicit-True callers (ERD x2, Schema Diff
compare x2) are unaffected. Silent callers that previously got
the buggy False — `_get_resql_for_table` (Schema Diff Generate
Script and browser CREATE Script), `select_sql` / `insert_sql` /
`update_sql` in the table node, and one path in the partition
flow — now correctly get SERIAL detection. The SELECT/INSERT/
UPDATE generators are unaffected regardless: they emit only
column names, which SERIAL detection never touches (it rewrites
the column type and clears `defval`, leaving the name intact).
The table edit path is likewise safe: `get_sql` diffs each
changed column against a freshly re-fetched old column state
that bypasses SERIAL detection, so the reprojected `old_data`
columns are never consulted for column DDL.
Adds a resql regression in pg/{12_plus, 14_plus, 16_plus}: a
CREATE TABLE scenario covering ``serial`` / ``bigserial`` /
``smallserial`` columns plus a plain ``text`` column, with the
expected reverse-engineered DDL preserving the SERIAL keywords.
Would have failed before this fix.
Known coverage gaps (not blockers for this PR):
- PG11 (uses ``pg/11_plus`` / ``pg/default``): test intentionally
not added there because PG11 still emits ``WITH (OIDS = FALSE)``
which the existing expected-output convention reflects, and
without a live PG11 instance I can't verify the expected SQL
variant. The source-side fix still applies to PG11.
- PPAS (``ppas/*`` test dirs): not added because PPAS output may
diverge subtly (oraplus / hyphenated identifiers) and I don't
have a PPAS instance to verify against. The source-side fix
applies equally to PPAS.
- The SERIAL detection heuristic is name-based
(``<table>_<col>_seq``); sequence/table/column renames break
detection. Pre-existing in 115208c8d; could be replaced with
proper ``pg_depend``-based detection in a follow-up.
Fixes#9896
In Tools > Schema Diff, the top-level group rows ("Schema Objects",
"Database Objects") rendered blank difference counts even when their
child object-type groups (Functions, Tables, Sequences, ...) listed
Source-Only / Target-Only / Different objects, giving no signal at the
parent level that anything differed.
The results tree has three levels: top-level object-group rows, mid-level
object-type rows, and leaf object rows carrying a status. setRecordCount()
only handled leaf children, so mid-level rows counted correctly but
top-level rows (whose children are mid-level rows with no status) stayed
at zero. The render gate 'identicalCount' in row also skipped count
rendering for top-level rows because generateGridData() never seeded
those fields on them.
Fix: setRecordCount() now recognises counted-group children (recursing to
refresh their counts so filter changes propagate, then rolling the totals
up); leaf-only behaviour is unchanged. generateGridData() seeds the four
count fields on every top-level group row so the render gate fires. Adds
Jest coverage driving the now-exported setRecordCount directly, including
the reporter's exact tree and a filter-refresh case.
Fixes#9892.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the EDB BigAnimal cloud-deployment integration from the Cloud
Wizard. AWS RDS, Azure Database, and Google Cloud SQL remain as the
supported deployment targets. Covers the backend blueprint and pgacloud
provider, the frontend wizard steps/components/constants/icons, the
documentation page and screenshots, and stray BigAnimal references in
docstrings.
The BigAnimal-only "Cluster Type" wizard step is removed and the
remaining steps renumber from 0-5 to 0-4. As a side effect, the Azure
cluster-name availability check (previously dead code at activeStep == 2,
skipped past by the now-removed step) is now exercised. Two latent bugs
in that check are fixed at the same time: it inspected res.data.success
although checkClusternameAvailbility already resolves res.data, so a name
collision was never reported; and it passed an Error object to gettext(),
which cannot render it. It now uses res.success and surfaces
error.message with a readable fallback.
A 9.16 Housekeeping release-note entry is added documenting the removal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Create release_notes_9_16.rst with empty sections and add it to the
release notes toctree, so that individual fix/feature PRs can add their
entries without each recreating the file.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix exponential backoff logic and ping race condition in desktop startup
* Adaptive polling mechanism in startDesktopMode().
- Fix exponential backoff to cap at 1000ms instead of cycling back to 100ms
- Move pingInProgress flag before the async pingServer() call to prevent race conditions
* Update showErrorDialog to use clearTimeout consistent with setTimeout-based scheduling
psycopg.Connection.execute(query, params, *, prepare=None, binary=False)
delegates to its underlying cursor as
cur.execute(query, params, prepare=prepare). The DictCursor /
AsyncDictCursor in pgadmin.utils.driver.psycopg3.cursor narrowed the
signature to (self, query, params=None), so any caller that uses the
high-level Connection.execute() path with a cursor_factory=DictCursor
connection hits
TypeError: execute() got an unexpected keyword argument 'prepare'
The most visible victim is psycopg_pool.ConnectionPool.check_connection,
which sends conn.execute("") to validate every checkout — every
connection then looks broken to the pool and getconn() times out.
Forward prepare and binary (keyword-only) to the underlying
psycopg.Cursor / psycopg.AsyncCursor. Defaults match psycopg's own,
so existing callers see no behavior change.
Adds a regression test asserting both classes expose the kwargs as
keyword-only parameters. The test docstring leads with psycopg.Cursor
substitutability (DictCursor is a Cursor subclass; both kwargs must be
accepted to remain substitutable) and notes that Connection.execute is
just the most visible failure path — it forwards prepare but handles
binary by setting cur.format instead of forwarding it.
The previous commit (2e14bd95d) made the MFA test package discoverable
and resilient but left one scenario --
'Validation view of a MFA method should return a HTML tags' -- as a
documented unittest.SkipTest. The skip reason was that
validate.html -> security/render_page.html -> base.html references
'current_app' and 'csrf_token()' in Jinja, and the dummy Flask app
built by test_create_dummy_app() does not expose either: pgAdmin's
real create_app() injects current_app via an @app.context_processor
(web/pgadmin/__init__.py:922) and Flask-WTF registers csrf_token() in
the Jinja env. The bare Flask(name, ...) used by the dummy app has
neither, so the GET-path render fails with
UndefinedError: 'current_app' is undefined.
Add the same two globals to the dummy app:
* A @app.context_processor returning {'current_app': current_app} so
the Flask proxy is bound at render time, mirroring what
create_app() does in production.
* A jinja_env.globals['csrf_token'] = lambda: 'dummy-csrf-token' that
matches what Flask-WTF would otherwise install. Tests asserting on
the rendered HTML can rely on the marker being present and stable.
With both globals available the template renders, /mfa/validate
returns a 200 HTML response, and the scenario goes from SkipTest to
a real assertion. The fix is intentionally scoped to the dummy harness
in tests/utils.py -- no production code change.
Result on this worktree: pgadmin.authenticate.mfa.tests now runs as
13 passed / 0 failed / 0 skipped (was 12/0/1). Full suite goes from
1818/0/444 to 1819/0/443.
The MFA test directory was missing __init__.py, so
find_modules('pgadmin', False, True) in regression/runtests.py could
not walk into it. The test classes therefore never reached the
TestsGeneratorRegistry registry and the entire pgadmin.authenticate.mfa
test suite was silently absent from the default test run. The
regression for #10028 added in this branch was caught by that gap.
Adding the missing __init__.py exposes a second pre-existing problem:
TestMFATests.setUp called BaseTestGenerator.setUp, which posts to
/browser/server/connect/... and asserts a 200 response. The MFA
scenarios all run against a dummy Flask app (or pure mocks), so that
endpoint is not registered and the connect_server assertion fires
before any check_*() function runs. Skip the BaseTestGenerator setUp
since these scenarios deliberately do not need a real PostgreSQL
server.
Three further fixes that surface once the suite actually runs:
* mfa_enabled() and init_app() both short-circuit when SERVER_MODE
is False. test_config.json defaults to DESKTOP mode, so every
scenario in the suite was taking the disabled path. Force
SERVER_MODE=True for the duration of the TestMFATests class and
restore the previous value in tearDownClass.
* check_validation_view_content patched flask.current_app to capture
logger.exception() calls. Those only fire on the POST path of
/mfa/validate; the test exercises only the GET path, so the patch
was dead code -- and additionally turned flask.current_app into a
MagicMock, which broke Jinja's lookup of current_app in
validate.html. Drop the patch and the now-unused ValidationException
import.
* check_validation_view_content still cannot render validate.html
against the bare dummy Flask app because the template references
current_app.config and extends Flask-Security's
security/render_page.html, neither of which the dummy app provides.
Skip the scenario with a clear unittest.SkipTest reason rather than
paper over with a brittle patch; rebuilding the dummy harness to
expose those globals is its own follow-up.
Result on this worktree: pgadmin.authenticate.mfa.tests now runs as
12 passed / 0 failed / 1 skipped (the dummy-app/template gap),
up from a silent 0/0/0. Full suite goes from 1806/0/443 to 1818/0/444.
The MFA flow honoured the user-supplied "next" query/form parameter
without checking that it pointed back inside pgAdmin, so an attacker
who got a logged-in user to click /mfa/validate?next=<external> could
land them on an attacker-controlled host straight out of the auth
flow -- a trusted-domain redirect ideal for credential-phishing
follow-on.
Add a single _is_safe_redirect_url helper that allows only same-origin
http(s) targets (relative paths included) and rejects:
* external hosts in absolute and protocol-relative ("//host") form;
* non-http schemes a browser will still follow (javascript:, data:,
mailto:);
* userinfo tricks ("http://localhost@attacker.example/");
* backslash variants ("/\\host", "\\\\host") that some browsers
normalize to forward slashes, enabling protocol-relative bypasses;
* empty / missing targets.
Gate every redirect that consumes the user-supplied next value through
the helper -- the GET and POST branches of /mfa/validate and the POST
branch of /mfa/register -- falling back to the internal browser index
when the target is anything other than safe. The registration POST
keeps its existing 'internal' sentinel ("rendered from the in-app
dialog") since that string is matched exactly and is not a URL.
Ship the validator with a dedicated table-driven unit test covering
each accept/reject category and an integration test that POCs the
attacker payload from the report and asserts the response redirects
to the internal index instead of attacker.example.
Reported by: Mai Phạm Hiền <mai.phamhien171@gmail.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
The SQL injection fix for create_restore_point now calls
pg_catalog.pg_create_restore_point so that a non-default search_path
on the connection cannot redirect the call to a shadow definition.
Apply the same hardening to the other pg_* calls in the same file
that were still unqualified: pg_reload_conf in reload_configuration,
and pg_xlog_replay_pause / pg_wal_replay_pause / pg_xlog_replay_resume
/ pg_wal_replay_resume in wal_replay.
These were not exploitable on their own -- the SQL string is static
and not user-derived -- but resolving them via pg_catalog removes any
dependency on the connection's search_path being trustworthy.
ServerNode.create_restore_point interpolated the user-supplied "value"
field directly into a SQL string with str.format(), so an authenticated
pgAdmin user could inject additional statements through that endpoint.
The injection ran with the user's own database role -- so it did not
cross a privilege boundary and granted no capability the role does not
already have via the Query Tool -- but it bypassed the documented SQL-
execution path and any application-layer controls a deployment may
have built around the Query Tool.
Pass the name as a bound parameter, and qualify the function with
pg_catalog so a non-default search_path on the connection cannot
redirect the call to a shadow definition.
Add a regression test that mocks the driver and asserts the call
arrives as ("SELECT pg_catalog.pg_create_restore_point(%s);", (name,))
with no payload spliced into the SQL text, plus defence-in-depth
assertions that the SQL string contains neither pg_sleep nor the PoC's
closing-quote signature, so a future refactor that reintroduces string
formatting is caught.
Reported by: Geo <cve@sageby.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
The AI Assistant's execute_sql_query tool runs LLM-generated SQL inside
a BEGIN TRANSACTION READ ONLY wrapper. However, the LLM-supplied query
was sent to psycopg as-is, so a multi-statement payload beginning with
COMMIT, END, ROLLBACK, or ABORT terminated the read-only transaction
and ran subsequent statements in autocommit mode. With ordinary write
privileges this allowed unauthorised data modification; for a superuser
or pg_execute_server_program role this chained to remote code execution
on the database host via COPY ... TO PROGRAM.
Validate the LLM-supplied query before any connection work happens:
* The input must parse to exactly one non-empty/non-comment statement.
* The leading real token (after stripping leading whitespace, comments,
and punctuation) must be one of SELECT, WITH, EXPLAIN, SHOW, VALUES,
TABLE. Everything else -- DML, DDL, CALL, COPY, DO, SET/RESET, the
transaction-control verbs, and the rest -- is rejected up front.
PostgreSQL's READ ONLY mode continues to enforce the remaining cases
(data-modifying CTEs, EXPLAIN ANALYZE on writes, volatile side effects)
at runtime, so the validator is the load-bearing check for multi-
statement / top-level escapes and READ ONLY is the backstop for the
rest.
Add a 60-scenario regression suite under
web/pgadmin/llm/tests/test_database_tool_security.py covering the
original PoC payloads (COMMIT/END/ROLLBACK/ABORT/SET/BEGIN), multi-
statement masked by comments, the full allow- and deny-list of leading
keywords, dollar-quoted literals containing semicolons, and degenerate
inputs (empty, whitespace-only, comment-only, quoted identifier).
Reported by: Isaac Chen <isaac9503@gmail.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
Combined fix for 8 packages flagged by GitHub Dependabot (collapsing
6 of them from open dependabot bump PRs and 4 from transitive
vulnerabilities with no existing PR). All eight are transitive — no
direct dep changes — so we override via `resolutions` in web/package.json
and let yarn collapse duplicate-version entries during install.
Resolved (pre → post via resolution):
Runtime:
ws 8.20.0 -> 8.21.0 (patched 8.20.1)
Dev:
@xmldom/xmldom 0.7.13 -> 0.8.13 (patched 0.8.13)
serialize-javascript 6.0.2,
7.0.5 -> 7.0.5 (patched 7.0.5)
ip-address 10.1.0,
10.2.0 -> 10.2.0 (patched 10.1.1)
postcss 8.5.8,
8.5.15 -> 8.5.15 (patched 8.5.10)
qs 6.15.0 -> 6.15.2 (patched 6.15.2)
@tootallnate/once 2.0.0 -> 2.0.1 (patched 2.0.1)
tar (7.x lineage) 7.5.13 -> 7.5.16 (patched 7.5.11)
The tar 6.2.1 lineage (consumed via ^6.1.2/^6.1.11) is unaffected by
these CVEs (alert ranges are 7.x-only), so the resolution is scoped
`tar@npm:^7.5.4` to leave it on 6.2.1.
Supersedes open dependabot PRs #9956 (ws), #9962 (tar), #9966
(@tootallnate/once), and #9974 (qs) — one CI cycle instead of four.
Verification:
- yarn install — clean (only pre-existing peer-dep warnings about
@mui/system, aspen-core, eve, etc.; no new ones)
- yarn run test:js-once — 824 / 824 pass across 140 test suites
- yarn run bundle:dev — webpack compiled successfully
- All 8 packages confirmed at safe versions via lockfile audit;
duplicate entries collapsed (yarn.lock net -64 lines)
Out of scope (cannot fix here):
- paramiko (#276#278): no patched version exists; bump-to-5.0.0
PRs #9927/#9930 audited 2026-05-20 and deferred to Q4 2026 over
SSH bastion compat risk
- elliptic (#176): no patched version, dev-only, low severity
- flatted (#224): alert is stale; lockfile already at 3.4.2 (patched);
will auto-dismiss on next dependabot rescan
* chore(deps): bump Python deps to latest 3.9-compatible
Picks up five Python dependency bumps that are 3.9-safe (still resolve
under Python 3.9 per PyPI requires_python). Four supersede open
dependabot PRs:
- certifi 2026.4.22 -> 2026.5.20
(no gate; CA bundle refresh; supersedes dependabot #9977 / #9979)
- typer 0.25.* -> 0.26.*
(py > 3.9 row only; supersedes dependabot #9995 / #9999)
- testscenarios 0.6.1 -> 0.6.2
(py > 3.9 row only; supersedes dependabot #9980)
- urllib3 2.6.* -> 2.7.* (py > 3.9 row only)
Picks up two HIGH-severity security fixes in urllib3 2.7.0
(2026-05-07): GHSA-mf9v-mfxr-j63j (decompression-bomb safeguards
bypassed under drain_conn / Brotli stream patterns) and
GHSA-qccp-gfcp-xxvc (ProxyManager.connection_from_url did not
strip sensitive headers on cross-host redirects). 2.7.0 requires
Python >=3.10, which the existing 'python_version > 3.9' gate
already enforces.
- Flask-Security-Too 5.4.* -> 5.6.* (py <= 3.9 row only)
Closes a roughly 2-year gap between the 3.9 row (last pin from
March 2024) and the py > 3.9 row (already on 5.8.*). 5.5/5.6
only touched flows pgAdmin doesn't use (register V2, MFA / WebAuthn
templates, username recovery/changing, secret_key rotation) and
config pgAdmin overrides (default hash bcrypt->argon2 sidestepped
by SECURITY_PASSWORD_HASH = 'pbkdf2_sha512'). The contract changes
that mattered (LoginForm.validate -> is_active, UserMixin.is_locked
hook, single-kwarg find_user) are all already exercised in
production via the existing FST 5.8.* / Python 3.10+ deployments.
The existing run-python-tests-pg.yml workflow hardcodes
SERVER_MODE = False in config_local.py. Every test that gates
itself on `config.SERVER_MODE` — including the data-isolation
suites — skips itself in CI today. That gap is what allowed the
admin-bypass regression in 9a76ed8 to ship (see #9933, #10006):
the change to web/pgadmin/utils/server_access.py changed
access-control behaviour but the only tests covering it were
server-mode-only and therefore never ran.
This workflow plugs that gap with a narrow, cheap server-mode
CI job:
- Single OS (ubuntu-22.04), single PG version (18) — no matrix
- SERVER_MODE = True in config_local.py
- Runs only the two data-isolation test modules:
browser.server_groups.tests.test_sg_data_isolation
browser.server_groups.servers.tests.test_server_data_isolation
Locally with SERVER_MODE=True both modules finish in well under
half a second (3 + 6 tests), so the marginal CI cost is dominated
by the PG/python setup, not the tests themselves.
Future access-control changes to server_access.py (or related
helpers) will fail this workflow if they regress the existing
isolation guarantees, before they reach master
Two related bits of long-stale plumbing in web/regression/:
* test_advanced_config.json.in — the README has told users for years to
copy this template to test_advance_config.json (no "d") and customise
it, but a repo-wide grep for either spelling shows zero code, test,
or CI references. The .in template is dead, and the README/.gitignore
also disagree about whether the copied filename has a "d" in it,
which is a typo trap that has gone unnoticed precisely because nobody
actually performs the copy step.
* test_greenplum_config.json — Greenplum support was removed from
pgAdmin years ago (only references left in the tree are historical
release-notes entries from versions 1.4 through 4.12). Nothing reads
the config any more.
Drop the dead template, prune both stale lines from
web/regression/.gitignore, and simplify the README to describe just
the server-side test_config.json that the framework actually consumes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>