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.
68 lines
2.5 KiB
JavaScript
68 lines
2.5 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import { act, render } from '@testing-library/react';
|
|
|
|
import { AlertContent } from '../../pgadmin/static/js/helpers/ModalProvider';
|
|
import { withTheme } from './fake_theme';
|
|
|
|
const ThemedAlertContent = withTheme(AlertContent);
|
|
|
|
|
|
// MUI <Button> triggers an async TouchRipple update on mount that
|
|
// otherwise leaks past the test boundary and trips
|
|
// `console.error` (setup-jest treats any console.error as a failure).
|
|
// Wrap render in act() and flush microtasks so the ripple effect settles.
|
|
async function renderAlert(props) {
|
|
let ctrl;
|
|
await act(async () => {
|
|
ctrl = render(<ThemedAlertContent {...props} />);
|
|
});
|
|
return ctrl;
|
|
}
|
|
|
|
|
|
describe('ModalProvider AlertContent', () => {
|
|
it('renders plain-text body when plainText is true (no HTML parsing)',
|
|
async () => {
|
|
const xss = '<iframe srcdoc="<script>alert(1)</script>">';
|
|
const ctrl = await renderAlert({
|
|
text: 'Failed:\n' + xss, plainText: true, onOkClick: () => {}});
|
|
expect(ctrl.container.querySelector('iframe')).toBeNull();
|
|
expect(ctrl.container.querySelector('script')).toBeNull();
|
|
expect(ctrl.container.textContent).toContain('iframe');
|
|
});
|
|
|
|
it('preserves newlines in plainText mode (no <br/> substitution needed)',
|
|
async () => {
|
|
const body = 'line one\nline two\nline three';
|
|
const ctrl = await renderAlert({
|
|
text: body, plainText: true, onOkClick: () => {}});
|
|
const safeSpan = ctrl.container.querySelector('span');
|
|
expect(safeSpan).not.toBeNull();
|
|
expect(safeSpan.textContent).toBe(body);
|
|
expect(safeSpan.style.whiteSpace).toBe('pre-wrap');
|
|
});
|
|
|
|
it('renders HTML body when plainText is false (default, sanitized)',
|
|
async () => {
|
|
const ctrl = await renderAlert({
|
|
text: 'Welcome <b>admin</b>', onOkClick: () => {}});
|
|
expect(ctrl.container.querySelector('b')).not.toBeNull();
|
|
expect(ctrl.container.querySelector('script')).toBeNull();
|
|
});
|
|
|
|
it('strips script tag even in default HTML mode', async () => {
|
|
const ctrl = await renderAlert({
|
|
text: '<script>window.__xss=true</script>x', onOkClick: () => {}});
|
|
expect(ctrl.container.querySelector('script')).toBeNull();
|
|
expect(window.__xss).toBeUndefined();
|
|
});
|
|
});
|