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.