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

fix(xss): comprehensive XSS hardening across notification and Explain flows

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="..."> -> &lt;iframe srcdoc=&quot;...&quot;&gt;
    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.
This commit is contained in:
Dave Page
2026-05-19 17:04:40 +05:30
committed by Ashesh Vashi
parent 8383c1220b
commit 9e370d3cb6
78 changed files with 1164 additions and 182 deletions

View File

@@ -27,7 +27,7 @@ export default function AboutComponent() {
api.get(about_url).then((res)=>{
setAboutData(res.data.data);
}).catch((err)=>{
pgAdmin.Browser.notifier.error(err);
pgAdmin.Browser.notifier.errorText(err);
});
}, []);

View File

@@ -168,7 +168,7 @@ define('pgadmin.node.foreign_table', ['pgadmin.tables.js/enable_disable_triggers
t.updateAndReselectNode(i, data);
}
if (res.success == 2) {
pgBrowser.notifier.error(res.info);
pgBrowser.notifier.errorText(res.info);
}
})
.catch((error)=>{

View File

@@ -39,7 +39,7 @@ function setTriggers(tree, generateUrl, args, params) {
try {
const err = xhr.response.data;
if (err.success === 0) {
pgAdmin.Browser.notifier.error(err.errormsg);
pgAdmin.Browser.notifier.errorText(err.errormsg);
}
} catch (e) {
console.warn(e.stack || e);

View File

@@ -202,7 +202,7 @@ define('pgadmin.node.table', [
t.updateAndReselectNode(i, data);
}
if (res.success == 2) {
pgAdmin.Browser.notifier.error(res.info);
pgAdmin.Browser.notifier.errorText(res.info);
}
})
.catch((error)=>{

View File

@@ -201,7 +201,7 @@ define('pgadmin.node.mview', [
api.get(obj.generate_url(i, 'check_utility_exists' , d, true))
.then(({data: res})=>{
if (!res.success) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Utility not found'),
res.errormsg
);
@@ -214,7 +214,7 @@ define('pgadmin.node.mview', [
//Do nothing as we are creating the job and exiting from the main dialog
pgBrowser.BgProcessManager.startProcess(refreshed_res.data.job_id, refreshed_res.data.desc);
} else {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to create materialized view refresh job.'),
refreshed_res.errormsg
);

View File

@@ -550,7 +550,7 @@ define('pgadmin.node.database', [
} else {
try {
pgAdmin.Browser.notifier.error(res.errormsg);
pgAdmin.Browser.notifier.errorText(res.errormsg);
} catch (e) {
console.warn(e.stack || e);
}

View File

@@ -107,7 +107,7 @@ define('pgadmin.node.subscription', [
);
}else if(!_.isNull(res.data.errormsg) && _.isNull(res.data.data)){
reject(new Error(res.data.errormsg));
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Check connection?'),
gettext(res.data.errormsg)
);

View File

@@ -319,7 +319,7 @@ define('pgadmin.node.server', [
pgAdmin.Browser.notifier.success(res.data.result);
}
else {
pgAdmin.Browser.notifier.error(res.data.result);
pgAdmin.Browser.notifier.errorText(res.data.result);
}
}).catch(function(error) {
pgAdmin.Browser.notifier.pgRespErrorNotify(error);
@@ -452,7 +452,7 @@ define('pgadmin.node.server', [
});
}
else {
pgAdmin.Browser.notifier.error(res.info);
pgAdmin.Browser.notifier.errorText(res.info);
}
}).catch(function(error) {
pgAdmin.Browser.notifier.pgRespErrorNotify(error);
@@ -486,7 +486,7 @@ define('pgadmin.node.server', [
t.itemData(i).is_tunnel_password_saved=res.data.is_tunnel_password_saved;
}
else {
pgAdmin.Browser.notifier.error(res.info);
pgAdmin.Browser.notifier.errorText(res.info);
}
}).catch(function(error) {
pgAdmin.Browser.notifier.pgRespErrorNotify(error);
@@ -701,7 +701,12 @@ define('pgadmin.node.server', [
/* Below code is added to show the error message if
connection is successful, but there is an error to
run the post connection sql queries. */
run the post connection sql queries. The backend has
already HTML-escaped the driver-returned text in
execute_post_connection_sql, so HTML-mode rendering
here decodes the entities to literal text. A
malicious PostgreSQL ErrorResponse therefore appears
as readable text, not executable markup. */
if (res?.errormsg) {
pgAdmin.Browser.notifier.error(res.errormsg, null);
}
@@ -792,7 +797,7 @@ define('pgadmin.node.server', [
serverInfo[data._id] = _.extend({}, data);
if(data.errmsg) {
pgAdmin.Browser.notifier.error(data.errmsg);
pgAdmin.Browser.notifier.errorText(data.errmsg);
}
// Generate the event that server is connected
pgBrowser.Events.trigger(
@@ -856,7 +861,7 @@ define('pgadmin.node.server', [
}
else {
try {
pgAdmin.Browser.notifier.error(res.errormsg);
pgAdmin.Browser.notifier.errorText(res.errormsg);
} catch (e) {
console.warn(e.stack || e);
}

View File

@@ -0,0 +1,98 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""
Integration test for the XSS fix in the post-connection-SQL flow.
When an attacker-controlled (or malicious) PostgreSQL server returns an
HTML-laden ErrorResponse for a post-connection-SQL query, the JSON the
connect endpoint returns must contain the HTML-escaped (entity-encoded)
text in `errormsg`, not the raw markup. This exercises the real
execute_post_connection_sql → sanitize_driver_message → make_json_response
chain through the Flask test client, not a unit-level mock of either side.
The test sets `post_connection_sql` to a query referencing a relation
whose name contains HTML; real PostgreSQL responds with an error message
that includes the relation name verbatim, which is exactly the situation
the fix protects against.
"""
import sqlite3
import json
import config
from pgadmin.utils.route import BaseTestGenerator
from regression import parent_node_dict
from regression.python_test_utils import test_utils as utils
XSS_RELATION = '<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">'
class TestPostConnectionSqlXssEscaped(BaseTestGenerator):
"""Connect to a server whose post-connection SQL fails with a PG error
that quotes back an HTML payload, and verify the JSON response
`errormsg` field is HTML-escaped (entities) rather than raw markup."""
scenarios = [
('post-connection SQL error is HTML-escaped in the JSON response',
dict()),
]
def setUp(self):
self.server_id = utils.create_server(self.server)
# post_connection_sql isn't supported by create_server — add it
# directly. The crafted SQL references a relation that does not
# exist; real PostgreSQL responds with an error message of the
# form relation "<iframe ...>" does not exist which is exactly
# the kind of attacker-influenced content the fix has to neutralise.
conn = sqlite3.connect(config.TEST_SQLITE_PATH)
try:
cur = conn.cursor()
cur.execute(
'UPDATE server SET post_connection_sql=? WHERE id=?',
('SELECT * FROM "' + XSS_RELATION + '"', self.server_id))
conn.commit()
finally:
conn.close()
utils.write_node_info(
"sid", {"server_id": self.server_id})
def runTest(self):
url = '/browser/server/connect/{0}/{1}'.format(
utils.SERVER_GROUP, self.server_id)
payload = dict(self.server)
payload['password'] = self.server['db_password']
response = self.tester.post(
url, data=json.dumps(payload), content_type='html/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
# The connection itself must succeed (the failure is in the
# post-connection SQL, not the connection handshake).
self.assertEqual(data.get('success'), 1)
self.assertTrue(data.get('data', {}).get('connected'))
errmsg = data.get('errormsg') or ''
# The PostgreSQL error must have been observed and embedded.
self.assertIn('Failed to execute the post connection SQL', errmsg)
# The raw HTML payload must NOT survive verbatim — it must be
# HTML-escaped to inert entities by sanitize_driver_message.
self.assertNotIn('<iframe', errmsg)
self.assertNotIn('<script', errmsg)
# The entity-encoded form must be present.
self.assertIn('&lt;iframe', errmsg)
# Ampersand of the inner &lt;script&gt; payload must be
# double-encoded (&amp;lt;) — proves the escape ran end-to-end
# rather than the input simply being passed through.
self.assertIn('&amp;lt;script', errmsg)
def tearDown(self):
utils.delete_server_with_api(self.tester, self.server_id)

View File

@@ -300,12 +300,12 @@ define('pgadmin.browser', [
}
} else if(data.outdated) {
//This is for server mode or auto-update not supported desktop installer or not mentioned auto_update_url
pgAdmin.Browser.notifier.warning(
`
${gettext('You are currently running version %s of %s, <br/>however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)}
<br/><br/>
${gettext('Please click <a href="%s" target="_new" style="color:inherit">here</a> for more information.', data.download_url)}
`,
// Render as plain text so the server-supplied download URL
// cannot inject markup into the notification body.
pgAdmin.Browser.notifier.warningText(
gettext('You are currently running version %s of %s, however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)
+ '\n\n'
+ gettext('For more information, please visit: %s', data.download_url),
null
);
}
@@ -316,7 +316,7 @@ define('pgadmin.browser', [
}
}).catch((error)=>{
console.error('Error during version check', error);
pgAdmin.Browser.notifier.error(gettext(`${error.response?.data?.errormsg || error?.message}`));
pgAdmin.Browser.notifier.errorText(gettext(`${error.response?.data?.errormsg || error?.message}`));
});
},

View File

@@ -27,9 +27,9 @@ export function send_heartbeat(_server_id, _item) {
})
.catch((error) => {
if (error && error.message == 'Network Error') {
pgAdmin.Browser.notifier.error(gettext(`pgAdmin server not responding, try to login again: ${error.message || error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`pgAdmin server not responding, try to login again: ${error.message || error.response.data.errormsg}`));
} else if (!error.response?.data?.data?.includes('Manager not found')) {
pgAdmin.Browser.notifier.error(gettext(`Server heartbeat logging error: ${error.message || error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`Server heartbeat logging error: ${error.message || error.response.data.errormsg}`));
}
stop_heartbeat(_item);
});

View File

@@ -566,7 +566,7 @@ define('pgadmin.browser.node', [
if (!(_.isFunction(obj.canDropCascade) ?
obj.canDropCascade(d, i) : obj.canDropCascade)) {
pgAdmin.Browser.notifier.error(
pgAdmin.Browser.notifier.errorText(
gettext('The %s "%s" cannot be dropped.', obj.label, d.label),
10000
);
@@ -583,7 +583,7 @@ define('pgadmin.browser.node', [
if (!(_.isFunction(obj.canDrop) ?
obj.canDrop(d, i) : obj.canDrop)) {
pgAdmin.Browser.notifier.error(
pgAdmin.Browser.notifier.errorText(
gettext('The %s "%s" cannot be dropped/removed.', obj.label, d.label),
10000
);
@@ -596,11 +596,11 @@ define('pgadmin.browser.node', [
obj.generate_url(i, input.url, d, true),
).then(({data: res})=> {
if(res.success == 2){
pgAdmin.Browser.notifier.error(res.info, null);
pgAdmin.Browser.notifier.errorText(res.info, null);
return;
}
if (res.success == 0) {
pgAdmin.Browser.notifier.alert(res.errormsg, res.info);
pgAdmin.Browser.notifier.alertText(res.errormsg, res.info);
} else {
// Remove the node from tree and set collection node as selected.
let selectNextNode = true;
@@ -630,7 +630,7 @@ define('pgadmin.browser.node', [
console.warn(e.stack || e);
}
}
pgAdmin.Browser.notifier.alert(gettext('Error dropping/removing %s: "%s"', obj.label, objName), errmsg);
pgAdmin.Browser.notifier.alertText(gettext('Error dropping/removing %s: "%s"', obj.label, objName), errmsg);
});
},
() => {},

View File

@@ -153,7 +153,7 @@ function getTerminateCell(pgAdmin, sid, did, canTakeAction, onSuccess) {
}
})
.catch(function (error) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to perform the operation.'),
parseApiError(error)
);
@@ -217,7 +217,7 @@ function getCancelCell(pgAdmin, sid, did, canTakeAction, onSuccess) {
}
})
.catch(function (error) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to perform the operation.'),
parseApiError(error)
);
@@ -813,7 +813,7 @@ function Dashboard({
setLogConfigFormat(_frm);
})
.catch((error) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
_.isUndefined(error.response) ? error.message : error.response.data.errormsg
);
@@ -874,7 +874,7 @@ function Dashboard({
}
})
.catch((error) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
_.isUndefined(error.response) ? error.message : error.response.data.errormsg
);
@@ -1009,7 +1009,7 @@ function Dashboard({
}
})
.catch((error) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
_.isUndefined(error.response) ? error.message : error.response.data.errormsg
);

View File

@@ -153,7 +153,7 @@ export default function LogReplication({treeNodeInfo, pageVisible}) {
})
.catch((error)=>{
console.error(error);
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
});
};

View File

@@ -303,7 +303,7 @@ export default function PGDReplication({preferences, treeNodeInfo, pageVisible,
})
.catch((error)=>{
console.error(error);
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
});
};

View File

@@ -445,7 +445,7 @@ export default function AIReport({
errMsg = err.message;
}
setError(errMsg);
pgAdmin.Browser.notifier.error(errMsg);
pgAdmin.Browser.notifier.errorText(errMsg);
})
.finally(() => {
setLoading(false);

View File

@@ -253,7 +253,7 @@ export default function SecurityReport({
errMsg = err.message;
}
setError(errMsg);
pgAdmin.Browser.notifier.error(errMsg);
pgAdmin.Browser.notifier.errorText(errMsg);
})
.finally(() => {
setLoading(false);

View File

@@ -134,7 +134,7 @@ export default class BgProcessManager {
this._eventManager.fireEvent(BgProcessManagerEvents.LIST_UPDATED);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
pgAdmin.Browser.notifier.errorText(parseApiError(err));
});
}
@@ -155,7 +155,7 @@ export default class BgProcessManager {
/* Object not available */
removeJob(jobId);
} else {
pgAdmin.Browser.notifier.error(parseApiError(err));
pgAdmin.Browser.notifier.errorText(parseApiError(err));
}
});
});

View File

@@ -96,7 +96,7 @@ export default function CloudWizard({ nodeInfo, nodeData, onClose, cloudPanelId}
}
})
.catch((error) => {
pgAdmin.Browser.notifier.error(gettext(`Error while getting the host ip: ${error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error while getting the host ip: ${error.response.data.errormsg}`));
});
}, [cloudProvider]);
@@ -150,7 +150,7 @@ export default function CloudWizard({ nodeInfo, nodeData, onClose, cloudPanelId}
onClose();
})
.catch((error) => {
pgAdmin.Browser.notifier.error(gettext(`Error while saving cloud wizard data: ${error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error while saving cloud wizard data: ${error.response.data.errormsg}`));
});
};

View File

@@ -90,7 +90,7 @@ define('pgadmin.misc.cloud', [
axiosApi.post(_url)
.then(() => {/*This is intentional (SonarQube)*/})
.catch((error) => {
pgAdmin.Browser.notifier.error(gettext(`Error while clearing cloud wizard data: ${error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error while clearing cloud wizard data: ${error.response.data.errormsg}`));
});
pgAdmin.Browser.docker.default_workspace.close(panelId, true);
}}/>

View File

@@ -127,7 +127,7 @@ function Dependencies({ nodeData, nodeItem, node, treeNodeInfo, isActive, isStal
}
})
.catch((e) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
parseApiError(e)
);

View File

@@ -126,7 +126,7 @@ function Dependents({ nodeData, nodeItem, node, treeNodeInfo, isActive, isStale,
}
})
.catch((e) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
parseApiError(e)
);

View File

@@ -180,14 +180,14 @@ export default function CollectionNodeProperties({
})
.then(function (res) {
if (res.success == 0) {
pgAdmin.Browser.notifier.alert(res.errormsg, res.info);
pgAdmin.Browser.notifier.alertText(res.errormsg, res.info);
}
pgAdmin.Browser.tree.refresh(selItem);
setIsStale(true);
setSelectedObject({});
})
.catch(function (error) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Error deleting %s', selectedItemData._label.toLowerCase()),
_.isUndefined(error.response) ? error.message : error.response.data.errormsg
);
@@ -275,7 +275,7 @@ export default function CollectionNodeProperties({
.catch((err) => {
setData([]);
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
parseApiError(err)
);

View File

@@ -73,7 +73,7 @@ function SQL({nodeData, node, treeNodeInfo, isActive, isStale, setIsStale}) {
}
})
.catch((e) => {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Error'),
gettext(e.response.data.errormsg)
);

View File

@@ -192,7 +192,7 @@ function Statistics({ nodeData, nodeItem, node, treeNodeInfo, isActive, isStale,
}, 100);
});
} else {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Failed to retrieve data from the server.'),
parseApiError(err)
);

View File

@@ -18,6 +18,7 @@ import { getBrowser } from '../../../../static/js/utils';
import SaveSharpIcon from '@mui/icons-material/SaveSharp';
import CloseIcon from '@mui/icons-material/CloseRounded';
import HTMLReactParser from 'html-react-parser/lib/index';
import DOMPurify from 'dompurify';
import getApiInstance from '../../../../static/js/api_instance';
// Cache for dynamically loaded options to avoid repeated API calls
@@ -349,7 +350,7 @@ export function showResetPrefModal(api, pgAdmin, preferencesStore, onReset) {
return (
<StyledBox display="flex" flexDirection="column" height="100%">
<Box flexGrow="1" p={2}>
{HTMLReactParser(text)}
{HTMLReactParser(DOMPurify.sanitize(text))}
</Box>
<Box className='Alert-footer'>
<DefaultButton className='Alert-margin' startIcon={<CloseIcon />} onClick={modalClose}>

View File

@@ -68,7 +68,7 @@ export default class BinaryPathSchema extends BaseUISchema {
api.post(url_for('misc.validate_binary_path'),
JSON.stringify({ 'utility_path': data }))
.then(function (res) {
pgAdmin.Browser.notifier.alert(gettext('Validate binary path'), gettext(res.data.data));
pgAdmin.Browser.notifier.alertText(gettext('Validate binary path'), gettext(res.data.data));
})
.catch(function (error) {
pgAdmin.Browser.notifier.pgNotifier('error', error, gettext('Failed to validate binary path.'));

View File

@@ -6,12 +6,13 @@ import CloseIcon from '@mui/icons-material/CloseRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import HTMLReactParser from 'html-react-parser';
import DOMPurify from 'dompurify';
import PropTypes from 'prop-types';
export default function ConfirmSaveContent({closeModal, text, onDontSave, onSave}) {
return (
<ModalContent>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(DOMPurify.sanitize(text)) : text}</Box>
<ModalFooter>
<DefaultButton data-test="close" startIcon={<CloseIcon />} onClick={()=>{
closeModal();

View File

@@ -88,10 +88,10 @@ export function checkMasterPassword(data, masterpass_callback_queue, cancel_call
masterPassCallbacks(masterpass_callback_queue);
if(isKeyring) {
if(error){
pgAdmin.Browser.notifier.alert(gettext('Migration failed'),
pgAdmin.Browser.notifier.alertText(gettext('Migration failed'),
gettext(`Passwords previously saved cannot be re-encrypted using the encryption key stored in the ${res.data.data.keyring_name} due to ${error}.`));
}else{
pgAdmin.Browser.notifier.alert(gettext('Migration successful'),
pgAdmin.Browser.notifier.alertText(gettext('Migration successful'),
gettext(`Passwords previously saved are re-encrypted using encryption key stored in the ${res.data.data.keyring_name}.`));
}
}
@@ -112,7 +112,7 @@ export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_que
if(master_password_hook){
if(errmsg){
pgAdmin.Browser.notifier.error(errmsg);
pgAdmin.Browser.notifier.errorText(errmsg);
return true;
}else{
pgAdmin.Browser.notifier.confirm(gettext('Reset Master Password'),
@@ -127,7 +127,7 @@ export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_que
pgAdmin.Browser.notifier.info('The master password has been reset.');
})
.catch((err) => {
pgAdmin.Browser.notifier.error(err.message);
pgAdmin.Browser.notifier.errorText(err.message);
});
return true;
},
@@ -159,7 +159,7 @@ export function showMasterPassword(isPWDPresent, errmsg, masterpass_callback_que
}
})
.catch((err) => {
pgAdmin.Browser.notifier.error(err.message);
pgAdmin.Browser.notifier.errorText(err.message);
});
return true;
},

View File

@@ -4,6 +4,7 @@ import _ from 'lodash';
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import HTMLReactParse from 'html-react-parser';
import DOMPurify from 'dompurify';
import PropTypes from 'prop-types';
import gettext from 'sources/gettext';
import Table from '../components/Table';
@@ -55,7 +56,7 @@ function NodeText({displayText, extraInfo}) {
<ArrowRightAltIcon fontSize="small" style={{marginLeft: '-24px'}} /> {displayText}
{extraInfo?.length > 0 && <ul style={{fontSize: '13px'}}>
{extraInfo.map((item, i)=>{
return <li key={i} style={{opacity: '0.8'}}>{HTMLReactParse(item)}</li>;
return <li key={i} style={{opacity: '0.8'}}>{HTMLReactParse(DOMPurify.sanitize(item))}</li>;
})}
</ul>}
</>);

View File

@@ -39,7 +39,7 @@ let xMargin = 25,
const DEFAULT_ARROW_SIZE = 2;
function nodeExplainTableData(_planData, _ctx) {
export function nodeExplainTableData(_planData, _ctx) {
let node_info,
display_text = [],
tooltip = [],
@@ -166,11 +166,11 @@ function nodeExplainTableData(_planData, _ctx) {
}
if ('Recheck Cond' in _planData) {
node_extra_info.push('<strong>' + gettext('Recheck Cond') + '</strong>: ' + _planData['Recheck Cond']);
node_extra_info.push('<strong>' + gettext('Recheck Cond') + '</strong>: ' + _.escape(_planData['Recheck Cond']));
}
if ('Exact Heap Blocks' in _planData) {
node_extra_info.push('<strong>' + gettext('Heap Blocks') + '</strong>: exact=' + _planData['Exact Heap Blocks']);
node_extra_info.push('<strong>' + gettext('Heap Blocks') + '</strong>: exact=' + _.escape(_planData['Exact Heap Blocks']));
}
info.rows.push({

View File

@@ -3,7 +3,7 @@ import gettext from 'sources/gettext';
import { LAYOUT_EVENTS } from './helpers/Layout';
import { styled } from '@mui/material/styles';
import { FormHelperText, Box } from '@mui/material';
import HTMLReactParse from 'html-react-parser';
import { SafeMessage } from './components/SafeMessage';
import { useApplicationState } from '../../settings/static/ApplicationStateProvider';
const StyledBox = styled(Box)(({theme}) => ({
@@ -24,9 +24,9 @@ export default function ToolErrorView({error, panelId, panelDocker}){
}
});
let err_msg = gettext(`An error occurred while opening the tool: ${error}`);
let err_msg = gettext('An error occurred while opening the tool: %s', error ?? '');
return (<StyledBox>
<FormHelperText variant="outlined" error= {true} style={{marginLeft: '4px'}} >{HTMLReactParse(err_msg)}</FormHelperText>
<FormHelperText variant="outlined" error={true} style={{marginLeft: '4px'}}><SafeMessage text={err_msg} /></FormHelperText>
</StyledBox>);
}

View File

@@ -48,7 +48,7 @@ export default function UtilityView({dockerObj}) {
onClose={onClose}
onSave={dialogProps.onSave ?? ((data)=>{
if(data.errormsg) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Error'),
gettext(data.errormsg)
);

View File

@@ -26,6 +26,8 @@ import Select, { components as RSComponents } from 'react-select';
import CreatableSelect from 'react-select/creatable';
import PropTypes from 'prop-types';
import HTMLReactParse from 'html-react-parser';
import DOMPurify from 'dompurify';
import { SafeMessage } from './SafeMessage';
import { DateTimePicker, DatePicker, TimePicker} from '@mui/x-date-pickers';
import * as DateFns from 'date-fns';
@@ -138,7 +140,7 @@ export function FormInput({ children, error, className, label, helpMessage, requ
<FormControl error={Boolean(error)} fullWidth>
{React.cloneElement(children, { cid, helpid })}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(helpMessage || '')}</FormHelperText>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}</FormHelperText>
</StyledGrid>
</>)
);
@@ -162,8 +164,8 @@ export function FormInput({ children, error, className, label, helpMessage, requ
<FormControl error={Boolean(error)} fullWidth>
{React.cloneElement(children, { cid, helpid })}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(helpMessage || '')}</FormHelperText>
{errorMessage && <FormHelperText variant="outlined" error= {true} style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}>{HTMLReactParse(errorMessage)}</FormHelperText> }
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}</FormHelperText>
{errorMessage && <FormHelperText variant="outlined" error={true} style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}><SafeMessage text={errorMessage} /></FormHelperText> }
</Grid>
</StyledGrid>
);
@@ -1154,7 +1156,7 @@ export function FormNote({ text, className, controlProps }) {
<Box className={className}>
<Paper elevation={0} className={controlProps?.raw ? '' : 'Form-noteRoot'}>
{!controlProps?.raw && <Box paddingRight="0.25rem"><DescriptionIcon fontSize="small" /></Box>}
<Box>{HTMLReactParse(text || '')}</Box>
<Box>{HTMLReactParse(DOMPurify.sanitize(text || ''))}</Box>
</Paper>
</Box>
</Root>
@@ -1310,12 +1312,15 @@ const StyledNotifierMessageBox = styled(Box)(({theme}) => ({
}));
export function NotifierMessage({
type = MESSAGE_TYPE.SUCCESS, message, style, closable = true, showIcon=true, textCenter=false,
type = MESSAGE_TYPE.SUCCESS, message, style, closable = true, showIcon=true, textCenter=false, plainText=false,
onClose = () => {/*This is intentional (SonarQube)*/ }}) {
const body = plainText
? <SafeMessage text={message} />
: HTMLReactParse(DOMPurify.sanitize(message || ''));
return (
<StyledNotifierMessageBox className={`FormFooter-container${type}`} style={style} data-test="notifier-message">
{showIcon && <FormIcon type={type} className={`FormFooter-icon${type}`} />}
<Box className={textCenter ? 'FormFooter-messageCenter' : 'FormFooter-message'}>{HTMLReactParse(message || '')}</Box>
<Box className={textCenter ? 'FormFooter-messageCenter' : 'FormFooter-message'}>{body}</Box>
{closable && <IconButton title={gettext('Close Message')} className={'FormFooter-closeButton ' + `FormFooter-icon${type}`} onClick={onClose}>
<FormIcon close={true} />
</IconButton>}
@@ -1329,6 +1334,7 @@ NotifierMessage.propTypes = {
closable: PropTypes.bool,
showIcon: PropTypes.bool,
textCenter: PropTypes.bool,
plainText: PropTypes.bool,
onClose: PropTypes.func,
style: PropTypes.object,
};

View File

@@ -0,0 +1,30 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import PropTypes from 'prop-types';
// SafeMessage renders externally-derived text without interpreting any
// HTML. Newlines are preserved via white-space: pre-wrap so a multi-line
// PostgreSQL error stays readable. Use this for any string whose source is
// a database server, driver, OS process, remote API, or other untrusted
// channel.
export function SafeMessage({text, ...rest}) {
return (
<span
style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word',
overflowWrap: 'anywhere'}}
{...rest}>
{text ?? ''}
</span>
);
}
SafeMessage.propTypes = {
text: PropTypes.node,
};

View File

@@ -117,7 +117,7 @@ export function SelectRefresh({ required, className, label, helpMessage, testcid
if (res.data?.data?.error) {
// Server returned an error message - clear options and show error
setOptionsState((prev) => ({ options: [], reloadBasis: prev.reloadBasis + 1 }));
pgAdmin.Browser.notifier.error(res.data.data.error);
pgAdmin.Browser.notifier.errorText(res.data.data.error);
} else if (res.data?.data?.models) {
const models = res.data.data.models;
// Clear the cache so next time preferences opens, it uses the refreshed data
@@ -134,7 +134,7 @@ export function SelectRefresh({ required, className, label, helpMessage, testcid
// Network or other error - clear options and show error
setOptionsState((prev) => ({ options: [], reloadBasis: prev.reloadBasis + 1 }));
const errMsg = err.response?.data?.errormsg || err.message || gettext('Failed to refresh models');
pgAdmin.Browser.notifier.error(errMsg);
pgAdmin.Browser.notifier.errorText(errMsg);
})
.finally(() => {
setIsRefreshing(false);
@@ -149,7 +149,7 @@ export function SelectRefresh({ required, className, label, helpMessage, testcid
.catch((err) => {
setOptionsState((prev) => ({ options: [], reloadBasis: prev.reloadBasis + 1 }));
const errMsg = err.message || gettext('Failed to refresh options');
pgAdmin.Browser.notifier.error(errMsg);
pgAdmin.Browser.notifier.errorText(errMsg);
})
.finally(() => {
setIsRefreshing(false);

View File

@@ -14,6 +14,7 @@ import {InputSelect } from './FormComponents';
import PropTypes from 'prop-types';
import CustomPropTypes from '../custom_prop_types';
import HTMLReactParse from 'html-react-parser';
import DOMPurify from 'dompurify';
export default function SelectThemes({onChange, helpMessage, ...props}) {
@@ -27,7 +28,7 @@ export default function SelectThemes({onChange, helpMessage, ...props}) {
</Box>
<Box>
{ previewSrc && <>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(helpMessage || '')}</FormHelperText>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}</FormHelperText>
<img style={{display: 'block', margin: 'auto', paddingTop: '4px'}} src={previewSrc} alt={gettext('Preview not available...')} />
</> }
</Box>

View File

@@ -18,6 +18,8 @@ import CustomPropTypes from '../custom_prop_types';
import PropTypes from 'prop-types';
import gettext from 'sources/gettext';
import HTMLReactParser from 'html-react-parser';
import DOMPurify from 'dompurify';
import { SafeMessage } from '../components/SafeMessage';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { Rnd } from 'react-rnd';
import { ExpandDialogIcon, MinimizeDialogIcon, DisconnectedIcon } from '../components/ExternalIcon';
@@ -47,10 +49,15 @@ export function useModal() {
return React.useContext(ModalContext);
}
function AlertContent({ text, confirm, okLabel = gettext('OK'), cancelLabel = gettext('Cancel'), onOkClick, onCancelClick, okIcon = 'default'}) {
export function AlertContent({ text, confirm, okLabel = gettext('OK'), cancelLabel = gettext('Cancel'), onOkClick, onCancelClick, okIcon = 'default', plainText = false}) {
const body = (typeof text === 'string')
? (plainText
? <SafeMessage text={text} />
: HTMLReactParser(DOMPurify.sanitize(text)))
: text;
return (
<StyledBox display="flex" flexDirection="column" height="100%">
<Box flexGrow="1" p={2} whiteSpace='pre-line'>{typeof (text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box flexGrow="1" p={2} whiteSpace='pre-line'>{body}</Box>
<Box className='Alert-footer'>
{confirm &&
<DefaultButton startIcon={<CloseIcon />} onClick={onCancelClick}>{cancelLabel}</DefaultButton>
@@ -67,10 +74,12 @@ AlertContent.propTypes = {
onCancelClick: PropTypes.func,
okLabel: PropTypes.string,
cancelLabel: PropTypes.string,
okIcon : PropTypes.string
okIcon : PropTypes.string,
plainText: PropTypes.bool,
};
function alert(title, text, onOkClick, okLabel = gettext('OK')) {
function alert(title, text, onOkClick, okLabel = gettext('OK'), options = {}) {
const {plainText = false} = options;
// bind the modal provider before calling
this.showModal(title, (closeModal) => {
const onOkClickClose = () => {
@@ -78,7 +87,7 @@ function alert(title, text, onOkClick, okLabel = gettext('OK')) {
closeModal();
};
return (
<AlertContent text={text} onOkClick={onOkClickClose} okLabel={okLabel} />
<AlertContent text={text} onOkClick={onOkClickClose} okLabel={okLabel} plainText={plainText} />
);
});
}
@@ -107,7 +116,7 @@ function confirmDelete(title, text, onDeleteClick, onCancelClick, deleteLabel =
return (
<StyledBox display="flex" flexDirection="column" height="100%">
<Box flexGrow="1" p={2}>
{typeof (text) == 'string' ? HTMLReactParser(text) : text}
{typeof (text) == 'string' ? HTMLReactParser(DOMPurify.sanitize(text)) : text}
</Box>
<Box className='Alert-footer'>
<DefaultButton className='Alert-margin' startIcon={<CloseIcon />} onClick={() => handleOkClose(onCancelClick)} autoFocus>{cancelLabel}</DefaultButton>

View File

@@ -9,17 +9,11 @@
import { SnackbarProvider, SnackbarContent } from 'notistack';
import { styled } from '@mui/material/styles';
import {Box} from '@mui/material';
import CloseIcon from '@mui/icons-material/CloseRounded';
import { DefaultButton, PrimaryButton } from '../components/Buttons';
import HTMLReactParser from 'html-react-parser';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import PropTypes from 'prop-types';
import React, { useEffect } from 'react';
import { NotifierMessage, MESSAGE_TYPE } from '../components/FormComponents';
import CustomPropTypes from '../custom_prop_types';
import gettext from 'sources/gettext';
import _ from 'lodash';
import { useModal } from './ModalProvider';
import { parseApiError } from '../api_instance';
@@ -51,28 +45,6 @@ FinalNotifyContent.propTypes = {
children: CustomPropTypes.children,
};
function AlertContent({text, confirm, okLabel=gettext('OK'), cancelLabel=gettext('Cancel'), onOkClick, onCancelClick}) {
return (
<Box display="flex" flexDirection="column" height="100%">
<Box flexGrow="1" p={2}>{HTMLReactParser(text)}</Box>
<Box className='Notifier-footer'>
{confirm &&
<DefaultButton startIcon={<CloseIcon />} onClick={onCancelClick} >{cancelLabel}</DefaultButton>
}
<PrimaryButton className='Notifier-margin' startIcon={<CheckRoundedIcon />} onClick={onOkClick} autoFocus={true} >{okLabel}</PrimaryButton>
</Box>
</Box>
);
}
AlertContent.propTypes = {
text: PropTypes.string,
confirm: PropTypes.bool,
onOkClick: PropTypes.func,
onCancelClick: PropTypes.func,
okLabel: PropTypes.string,
cancelLabel: PropTypes.string,
};
// This can be called from iframe,
// so need to separate the context to avoid hooks error
class SnackbarNotifier {
@@ -90,9 +62,9 @@ class SnackbarNotifier {
}
}
callNotify(msg, type, autoHideDuration) {
callNotify(msg, type, autoHideDuration, {plainText = false} = {}) {
this.notify(
<NotifierMessage style={{maxWidth: '50vw'}} type={type} message={msg} closable={true} />,
<NotifierMessage style={{maxWidth: '50vw'}} type={type} message={msg} closable={true} plainText={plainText} />,
autoHideDuration
);
}
@@ -120,6 +92,22 @@ class Notifier {
this.snackbar.callNotify(msg, MESSAGE_TYPE.ERROR, autoHideDuration);
}
// Plain-text snackbar variants. Use these when `msg` is, or may contain,
// text from an untrusted source (PostgreSQL server, driver, remote API,
// user input). Newlines are preserved; no HTML is interpreted.
successText(msg, autoHideDuration = AUTO_HIDE_DURATION) {
this.snackbar.callNotify(msg, MESSAGE_TYPE.SUCCESS, autoHideDuration, {plainText: true});
}
warningText(msg, autoHideDuration = AUTO_HIDE_DURATION) {
this.snackbar.callNotify(msg, MESSAGE_TYPE.WARNING, autoHideDuration, {plainText: true});
}
infoText(msg, autoHideDuration = AUTO_HIDE_DURATION) {
this.snackbar.callNotify(msg, MESSAGE_TYPE.INFO, autoHideDuration, {plainText: true});
}
errorText(msg, autoHideDuration = AUTO_HIDE_DURATION) {
this.snackbar.callNotify(msg, MESSAGE_TYPE.ERROR, autoHideDuration, {plainText: true});
}
// proxy
notify(...args) {
this.snackbar.notify(...args);
@@ -127,12 +115,12 @@ class Notifier {
pgRespErrorNotify(error, prefixMsg='') {
if (error.response?.status === 410) {
this.alert(
this.alertText(
gettext('Error: Object not found - %s.', error.response.statusText),
parseApiError(error)
);
} else {
this.error(prefixMsg + ' ' + parseApiError(error));
this.errorText(prefixMsg + ' ' + parseApiError(error));
}
}
@@ -160,17 +148,17 @@ class Notifier {
onJSONResult && typeof(onJSONResult) == 'function') {
return onJSONResult(resp.result);
}
msg = _.escape(resp.result) || _.escape(resp.errormsg) || 'Unknown error';
msg = resp.result || resp.errormsg || 'Unknown error';
} else {
if (type === 'error') {
this.alert('Error', promptmsg);
this.alertText('Error', promptmsg);
}
return;
}
if(type == 'error-noalert' && onJSONResult && typeof(onJSONResult) == 'function') {
return onJSONResult();
}
this.alert(promptmsg, msg.replace(new RegExp(/\r?\n/, 'g'), '<br />'));
this.alertText(promptmsg, msg);
onJSONResult?.('ALERT_CALLED');
}
@@ -180,6 +168,12 @@ class Notifier {
this.modal.alert(title, text, onOkClick, okLabel);
}
// Plain-text modal alert. Use whenever `text` may contain untrusted
// content (driver / API error messages, server-supplied strings, etc.).
alertText(title, text, onOkClick, okLabel=gettext('OK')) {
this.modal.alert(title, text, onOkClick, okLabel, {plainText: true});
}
confirm(title, text, onOkClick, onCancelClick, okLabel=gettext('Yes'), cancelLabel=gettext('No'), okIcon='default', modalId=null) {
/* Use this if you want to use pgAdmin global notifier.
Or else, if you want to use modal inside iframe only then use ModalProvider eg- query tool */

View File

@@ -115,7 +115,9 @@ export function appAutoUpdateNotifier(desc, type, onClick, hideDuration=null, ti
} else if(type == 'info') {
pgAdmin.Browser.notifier.info(desc, hideDuration);
} else if(type == 'error') {
pgAdmin.Browser.notifier.error(desc, hideDuration);
// `desc` here can be a runtime error string from the Electron auto-
// updater (`data.errMsg`), so render as plain text.
pgAdmin.Browser.notifier.errorText(desc, hideDuration);
}
// Remove from active keys for valid hideDuration passed in args

View File

@@ -107,7 +107,7 @@ export default function ObjectExplorerFilter() {
onClose();
} catch(error) {
console.error('Error applying object explorer filter:', error);
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
setLoadingText('');
}
};

View File

@@ -198,7 +198,7 @@ define([
method: 'GET'
}).then((res)=>{
if (!res.data.success) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Utility not found'),
gettext(res.data.errormsg)
);

View File

@@ -393,7 +393,7 @@ export default class DebuggerModule {
);
})
.catch(function (e) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger Target Initialization Error'),
e.responseJSON.errormsg
);
@@ -401,7 +401,7 @@ export default class DebuggerModule {
}
})
.catch((err) => {
pgAdmin.Browser.notifier.alert(gettext('Debugger Error'), err.response.data.errormsg);
pgAdmin.Browser.notifier.alertText(gettext('Debugger Error'), err.response.data.errormsg);
});
}
@@ -561,7 +561,7 @@ export default class DebuggerModule {
}
);
} else if (err.success == 0) {
pgAdmin.Browser.notifier.alert(gettext('Debugger Error'), err.errormsg);
pgAdmin.Browser.notifier.alertText(gettext('Debugger Error'), err.errormsg);
}
} catch (e) {
console.warn(e.stack || e);
@@ -611,7 +611,7 @@ export default class DebuggerModule {
try {
let err = xhr.response.data;
if (err.success == 0) {
pgAdmin.Browser.notifier.alert(gettext('Debugger Error'), err.errormsg);
pgAdmin.Browser.notifier.alertText(gettext('Debugger Error'), err.errormsg);
}
} catch (e) {
console.warn(e.stack || e);

View File

@@ -489,7 +489,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
}, 100);
}).catch(function (er) {
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Clear failed'),
er.responseJSON.errormsg
);
@@ -737,7 +737,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
.then(function () {/*This is intentional (SonarQube)*/ })
.catch((error) => {
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Error occurred: '),
parseApiError(error)
);
@@ -748,7 +748,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
})
.catch(function (error) {
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger Target Initialization Error'),
parseApiError(error)
);
@@ -769,7 +769,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
})
.catch(function (error) {
props.closeModal();
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger Listener Startup Error'),
parseApiError(error)
);
@@ -793,7 +793,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
})
.catch(function (error) {
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger Listener Startup Set Arguments Error'),
parseApiError(error)
);
@@ -801,7 +801,7 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
}
} catch (err) {
setLoaderText('');
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger Error'),
gettext(err.message)
);

View File

@@ -168,7 +168,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
+ 'On clicking the ok button, debugger panel will be closed.');
}
pgAdmin.Browser.notifier.alert(header_msg, err_msg, () => {
pgAdmin.Browser.notifier.alertText(header_msg, err_msg, () => {
if (panelId) {
panelDocker.close(panelId, true);
}
@@ -224,7 +224,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
// If status is Busy then poll the result by recursive call to the poll function
messages(transId);
} else if (res.data.data.status === 'NotConnected') {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Not connected to the server or the connection to the server has been closed.'),
res.data.result
);
@@ -563,7 +563,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
//Set the notification message to inform the user that execution is
// completed with error.
if (!params.directDebugger.is_user_aborted_debugging) {
pgAdmin.Browser.notifier.error(res.data.info, 3000);
pgAdmin.Browser.notifier.errorText(res.data.info, 3000);
}
// Update the message tab of the debugger
@@ -683,7 +683,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
// Update the message tab of the debugger
updateMessages(res.data.data.status_message);
} else if (res.data.status === 'NotConnected') {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Debugger poll end execution error'),
res.data.result
);
@@ -876,7 +876,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
try {
updateBreakpoint(transId);
} catch (err) {
pgAdmin.Browser.notifier.alert(gettext('Error in update'), err);
pgAdmin.Browser.notifier.alertText(gettext('Error in update'), err?.message ?? String(err));
}
}
@@ -1029,7 +1029,7 @@ export default function DebuggerComponent({ pgAdmin, selectedNodeInfo, panelId,
if (res.data.data.result) {
pgAdmin.Browser.notifier.success(res.data.data.info, 3000);
} else {
pgAdmin.Browser.notifier.error(res.data.data.info, 3000);
pgAdmin.Browser.notifier.errorText(res.data.data.info, 3000);
}
}
})

View File

@@ -730,7 +730,7 @@ export default class ERDTool extends React.Component {
this.registerModelEvents();
} else {
console.error('Failed to load file:', fileData.error);
pgAdmin.Browser.notifier.error(fileData.error);
pgAdmin.Browser.notifier.errorText(fileData.error);
}
this.setLoading(null);
}

View File

@@ -189,7 +189,7 @@ export default function GrantWizard({ sid, did, nodeInfo, nodeData, onClose }) {
})
.catch((error) => {
setLoaderText('');
pgAdmin.Browser.notifier.error(gettext(`Error while saving grant wizard data: ${error.response.data.errormsg}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error while saving grant wizard data: ${error.response.data.errormsg}`));
});
};

View File

@@ -96,7 +96,7 @@ define([
importExportCallBack: function(data) {
if(data.errormsg) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Error'),
gettext(data.errormsg)
);
@@ -161,7 +161,7 @@ define([
})
.then((res)=>{
if (!res.data.success) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Utility not found'),
res.data.errormsg
);

View File

@@ -82,7 +82,7 @@ export default function ImportExportServers({onClose}) {
pgAdmin.Browser.notifier.alert(gettext('Export Servers'), gettext('The selected servers were exported successfully.'));
})
.catch((err) => {
pgAdmin.Browser.notifier.alert(gettext('Export Error'), err.response.data.errormsg);
pgAdmin.Browser.notifier.alertText(gettext('Export Error'), err.response.data.errormsg);
});
} else if (selectionFormData.imp_exp == 'i') {
// Remove the random number added to create unique tree item,
@@ -107,7 +107,7 @@ export default function ImportExportServers({onClose}) {
pgAdmin.Browser.notifier.alert(gettext('Import Servers'), msg);
})
.catch((err) => {
pgAdmin.Browser.notifier.alert(gettext('Import Error'), err.response.data.errormsg);
pgAdmin.Browser.notifier.alertText(gettext('Import Error'), err.response.data.errormsg);
});
}

View File

@@ -174,7 +174,7 @@ define([
})
.then(function(res) {
if (!res.data.success) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Utility not found'),
res.data.errormsg
);

View File

@@ -131,7 +131,7 @@ define('tools.restore', [
method: 'GET'
}).then((res)=>{
if (!res.data.success) {
pgAdmin.Browser.notifier.alert(
pgAdmin.Browser.notifier.alertText(
gettext('Utility not found'),
gettext(res.data.errormsg)
);

View File

@@ -720,7 +720,7 @@ export function ResultGridComponent({ gridData, allRowIds, filterParams, selecte
};
eventBus.fireEvent(SCHEMA_DIFF_EVENT.TRIGGER_CHANGE_RESULT_SQL, row.ddlData);
}).catch((err) => {
pgAdmin.Browser.notifier.alert(err.message);
pgAdmin.Browser.notifier.alertText(err.message);
});
} else {
eventBus.fireEvent(SCHEMA_DIFF_EVENT.TRIGGER_CHANGE_RESULT_SQL, {});

View File

@@ -75,10 +75,10 @@ function raiseErrorOnFail(pgAdmin, alertTitle, xhr) {
pgAdmin.Browser.notifier.alert(alertTitle, gettext('Unable to get the response text.'));
} else {
let err = JSON.parse(xhr.response.data);
pgAdmin.Browser.notifier.alert(alertTitle, err.errormsg);
pgAdmin.Browser.notifier.alertText(alertTitle, err.errormsg);
}
} catch (e) {
pgAdmin.Browser.notifier.alert(alertTitle, gettext(e.message));
pgAdmin.Browser.notifier.alertText(alertTitle, gettext(e.message));
}
}
@@ -135,7 +135,7 @@ export function SchemaDiffCompare({ params }) {
setSourceGroupServerList(groupedOptions);
}).catch((err) => {
pgAdmin.Browser.notifier.alert(err.message);
pgAdmin.Browser.notifier.alertText(err.message);
});
}, []);
@@ -341,7 +341,7 @@ export function SchemaDiffCompare({ params }) {
} catch (error) {
setLoaderText(null);
setShowResultGrid(false);
pgAdmin.Browser.notifier.alert(gettext('Error'), parseApiError(error));
pgAdmin.Browser.notifier.alertText(gettext('Error'), parseApiError(error));
}
socket?.disconnect();
}
@@ -619,7 +619,7 @@ export function SchemaDiffCompare({ params }) {
}
}
}).catch((error) => {
pgAdmin.Browser.notifier.error(gettext(`Error in connect database ${error.response.data}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error in connect database ${error.response.data}`));
});
};
@@ -654,7 +654,7 @@ export function SchemaDiffCompare({ params }) {
showConnectServer(error.response?.data.result, sid, diff_type, serverList);
});
} catch (error) {
pgAdmin.Browser.notifier.error(gettext(`Error in connect server ${error.response.data}` ));
pgAdmin.Browser.notifier.errorText(gettext(`Error in connect server ${error.response.data}` ));
}
};

View File

@@ -69,7 +69,7 @@ export default function SchemaDiffComponent({params}) {
'trans_id': params.transId})
)
.catch((err) => {
pgAdmin.Browser.notifier.error(gettext(`Error in schema diff initialize ${err.response.data}`));
pgAdmin.Browser.notifier.errorText(gettext(`Error in schema diff initialize ${err.response.data}`));
});
};

View File

@@ -360,7 +360,7 @@ export default function SearchObjects({nodeData}) {
})
.catch((err)=>{
setLoaderText(null);
pgAdmin.Browser.notifier.error(parseApiError(err));
pgAdmin.Browser.notifier.errorText(parseApiError(err));
});
};
@@ -388,11 +388,11 @@ export default function SearchObjects({nodeData}) {
resolve(typeOpt);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
pgAdmin.Browser.notifier.errorText(parseApiError(err));
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
});
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
reject(error instanceof Error ? error : Error(gettext('Something went wrong')));
}
});

View File

@@ -570,7 +570,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
eventBus.current.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, err);
});
} else if(error.response?.status == 403 && error.response?.data.info == 'ACCESS_DENIED') {
pgAdmin.Browser.notifier.error(error.response.data.errormsg);
pgAdmin.Browser.notifier.errorText(error.response.data.errormsg);
}else if(error?.response?.status == 428) {
connectServerModal(modal, error.response?.data?.result, async (passwordData)=>{

View File

@@ -5,12 +5,13 @@ import { DefaultButton, PrimaryButton } from '../../../../../../static/js/compon
import CloseIcon from '@mui/icons-material/CloseRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import HTMLReactParser from 'html-react-parser';
import DOMPurify from 'dompurify';
import PropTypes from 'prop-types';
export default function CloseRunningDialog({closeModal, text, onYes}) {
return (
<ModalContent>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(DOMPurify.sanitize(text)) : text}</Box>
<ModalFooter>
<DefaultButton data-test="no" startIcon={<CloseIcon />} onClick={()=>{
closeModal();

View File

@@ -5,6 +5,7 @@ import { Box } from '@mui/material';
import { DefaultButton, PrimaryButton } from '../../../../../../static/js/components/Buttons';
import CloseIcon from '@mui/icons-material/CloseRounded';
import HTMLReactParser from 'html-react-parser';
import DOMPurify from 'dompurify';
import PropTypes from 'prop-types';
import CheckRounded from '@mui/icons-material/CheckRounded';
import { InputCheckbox } from '../../../../../../static/js/components/FormComponents';
@@ -27,7 +28,7 @@ export default function ConfirmPromotionContent({ onContinue, onClose, closeModa
return (
<ModalContent>
<Box flexGrow="1" p={2}>{typeof (text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box flexGrow="1" p={2}>{typeof (text) == 'string' ? HTMLReactParser(DOMPurify.sanitize(text)) : text}</Box>
<StyledFooter>
<InputCheckbox controlProps={{ label: gettext('Don\'t ask again') }} value={formData['save_user_choice']}
onChange={(e) => setFormData((prev) => ({ ...prev, 'save_user_choice': e.target.checked }))} />

View File

@@ -4,13 +4,14 @@ import { Box } from '@mui/material';
import { DefaultButton, PrimaryButton } from '../../../../../../static/js/components/Buttons';
import CloseIcon from '@mui/icons-material/CloseRounded';
import HTMLReactParser from 'html-react-parser';
import DOMPurify from 'dompurify';
import { CommitIcon, RollbackIcon } from '../../../../../../static/js/components/ExternalIcon';
import PropTypes from 'prop-types';
export default function ConfirmTransactionContent({closeModal, text, onRollback, onCommit}) {
return (
<ModalContent>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(DOMPurify.sanitize(text)) : text}</Box>
<ModalFooter>
<DefaultButton data-test="cancel" startIcon={<CloseIcon />} onClick={()=>{
closeModal();

View File

@@ -197,7 +197,7 @@ export default function Query({onTextSelect, setQtStatePartial}) {
} else {
console.error('Failed to load file:', result.error);
eventBus.fireEvent(QUERY_TOOL_EVENTS.LOAD_FILE_DONE, null, false, storage);
pgAdmin.Browser.notifier.error(result.error);
pgAdmin.Browser.notifier.errorText(result.error);
}
} );

View File

@@ -430,7 +430,7 @@ export function QueryHistory() {
setSelectedItemKey(qhu.current.getNextItemKey());
} catch (error) {
console.error(error);
pgAdmin.Browser.notifier.error(gettext('Failed to fetch query history.') + parseApiError(error));
pgAdmin.Browser.notifier.errorText(gettext('Failed to fetch query history.') + parseApiError(error));
}
setLoaderText('');
@@ -469,7 +469,7 @@ export function QueryHistory() {
setSelectedItemKey(qhu.current.clear(selectedItemKey));
} catch (error) {
console.error(error);
pgAdmin.Browser.notifier.error(gettext('Failed to remove query history.') + parseApiError(error));
pgAdmin.Browser.notifier.errorText(gettext('Failed to remove query history.') + parseApiError(error));
}
setLoaderText('');
};
@@ -488,7 +488,7 @@ export function QueryHistory() {
setSelectedItemKey(null);
} catch (error) {
console.error(error);
pgAdmin.Browser.notifier.error(gettext('Failed to remove query history.') + parseApiError(error));
pgAdmin.Browser.notifier.errorText(gettext('Failed to remove query history.') + parseApiError(error));
}
setLoaderText('');
},

View File

@@ -1044,7 +1044,7 @@ export function ResultSet() {
eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_CONNECTION_STATUS, CONNECTION_STATUS.TRANSACTION_STATUS_IDLE);
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_END);
} catch(e) {
pgAdmin.Browser.notifier.error(parseApiError(e));
pgAdmin.Browser.notifier.errorText(parseApiError(e));
}
});
@@ -1288,7 +1288,7 @@ export function ResultSet() {
if(!respData.data.status) {
pageDataOutOfSync.current = false;
eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_MESSAGE, respData.data.result);
pgAdmin.Browser.notifier.error(respData.data.result, 20000);
pgAdmin.Browser.notifier.errorText(respData.data.result, 20000);
// If the transaction is not idle, notify the user that previous queries are not rolled back,
// only the failed save queries.
if (respData.data.transaction_status != CONNECTION_STATUS.TRANSACTION_STATUS_IDLE) {

View File

@@ -141,7 +141,7 @@ export function generateTitle(title_placeholder, title_data) {
* This function is used refresh the db node after showing alert to the user
*/
export function refresh_db_node(message, dbNode) {
pgAdmin.Browser.notifier.alert(gettext('Database moved/renamed'), gettext(message), ()=>{
pgAdmin.Browser.notifier.alertText(gettext('Database moved/renamed'), gettext(message), ()=>{
pgAdmin.Browser.Nodes.database.callbacks.refresh(undefined, dbNode);
});
}

View File

@@ -114,7 +114,7 @@ export default function Permissions({roles, updateRolePermissions}) {
updateRolePermissions(selectedRole, resp.data.permissions);
pgAdmin.Browser.notifier.success(gettext('Permissions saved successfully'));
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
console.error(error);
}
setLoading('');
@@ -127,7 +127,7 @@ export default function Permissions({roles, updateRolePermissions}) {
setAllPermissions(response.data);
})
.catch(error => {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
console.error(error);
});
}, []);

View File

@@ -89,7 +89,7 @@ export default function Roles({roles, updateRoles}) {
pgAdmin.Browser.notifier.success(gettext('Role deleted successfully.'));
updateRoles();
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
}
setLoading('');
});

View File

@@ -95,7 +95,7 @@ export default function Users({roles}) {
pgAdmin.Browser.notifier.success(gettext('User deleted successfully.'));
updateList();
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
}
setLoading('');
};
@@ -134,7 +134,7 @@ export default function Users({roles}) {
}
}
catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
}
setLoading('');
});
@@ -252,7 +252,7 @@ export default function Users({roles}) {
const res = await api.get(url_for('user_management.users'));
setTableData(res.data);
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
}
setLoading('');
};
@@ -265,7 +265,7 @@ export default function Users({roles}) {
updateList();
} catch (error) {
setLoading('');
pgAdmin.Browser.notifier.error(parseApiError(error));
pgAdmin.Browser.notifier.errorText(parseApiError(error));
}
};

View File

@@ -38,6 +38,7 @@ from .typecast import register_binary_data_typecasters,\
register_binary_typecasters, register_array_to_string_typecasters,\
register_numeric_typecasters, ALL_JSON_TYPES
from .encoding import get_encoding, configure_driver_encodings
from .text_sanitize import sanitize_driver_message
from pgadmin.utils import csv_lib as csv
from pgadmin.utils.master_password import get_crypt_key
from io import StringIO
@@ -683,10 +684,17 @@ WHERE db.datname = current_database()""")
if manager.post_connection_sql and manager.post_connection_sql != '':
status = self._execute(cur, manager.post_connection_sql)
if status is not None:
errmsg = gettext(("Failed to execute the post connection SQL "
"with below error message:\n{msg}").format(
msg=status))
current_app.logger.error(errmsg)
# Log the raw PG-returned text so the server log stays
# human-readable. HTML-escape only the value that crosses
# into the JSON response, where downstream consumers may
# render it as HTML.
current_app.logger.error(
"Failed to execute the post connection SQL "
"with below error message:\n%s", status)
errmsg = gettext(
"Failed to execute the post connection SQL "
"with below error message:\n{msg}").format(
msg=sanitize_driver_message(status))
return errmsg
def __cursor(self, server_cursor=False, scrollable=False):

View File

@@ -0,0 +1,8 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

View File

@@ -0,0 +1,151 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
from pgadmin.utils.route import BaseTestGenerator
from pgadmin.utils.driver.psycopg3.text_sanitize \
import sanitize_driver_message
class TestSanitizeDriverMessage(BaseTestGenerator):
"""Defense-in-depth scrub for driver-returned text used in user-facing
error messages. Strips C0 control characters and DEL (preserving TAB,
LF, CR) and HTML-escapes the result so any markup-like sequence
rendered downstream is inert text."""
scenarios = [
('strips NUL byte',
dict(input='hello\x00world', expected='helloworld')),
('strips ESC for ANSI escape sequence',
dict(input='red \x1b[31malert\x1b[0m', expected='red [31malert[0m')),
('strips DEL (0x7F)',
dict(input='a\x7fb', expected='ab')),
('strips backspace and other C0 controls except TAB/LF/CR',
dict(input='a\x01b\x02c\x08d', expected='abcd')),
('preserves TAB',
dict(input='col\tvalue', expected='col\tvalue')),
('preserves LF',
dict(input='line one\nline two', expected='line one\nline two')),
('preserves CR',
dict(input='ret\rurn', expected='ret\rurn')),
('preserves CRLF as a pair',
dict(input='one\r\ntwo', expected='one\r\ntwo')),
('HTML-escapes angle-bracket payload',
dict(
input='<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">',
expected=(
'&lt;iframe srcdoc=&quot;'
'&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;'
'&quot;&gt;'),
)),
('HTML-escapes a bare script tag',
dict(input='<script>alert(1)</script>',
expected='&lt;script&gt;alert(1)&lt;/script&gt;')),
('HTML-escapes ampersand and quotes',
dict(input='a & b "c" \'d\'',
expected='a &amp; b &quot;c&quot; &#x27;d&#x27;')),
('preserves unicode',
dict(input='naïve résumé 中文', expected='naïve résumé 中文')),
('None input is returned unchanged',
dict(input=None, expected=None)),
('non-string is stringified then scrubbed',
dict(input=12345, expected='12345')),
('empty string round-trips',
dict(input='', expected='')),
('preserves non-C0 whitespace (NBSP, narrow no-break space)',
dict(input='a b c', expected='a b c')),
('preserves emoji and surrogate pairs',
dict(input='warn \U0001f6a8 \U0001f4a5 done',
expected='warn \U0001f6a8 \U0001f4a5 done')),
('strips controls interleaved with multibyte UTF-8 without '
'corrupting the surrounding characters',
dict(input='中\x00文 \x01naïve',
expected='中文 naïve')),
('handles a long multi-line PG error',
dict(input='ERROR: ' + 'x' * 4096 + '\nDETAIL: y',
expected='ERROR: ' + 'x' * 4096 + '\nDETAIL: y')),
('escape order: controls stripped first, then HTML escape',
dict(input='<\x00script\x01>',
expected='&lt;script&gt;')),
('null byte adjacent to ampersand escapes correctly',
dict(input='a\x00&b', expected='a&amp;b')),
]
def runTest(self):
observed = sanitize_driver_message(self.input)
self.assertEqual(observed, self.expected)
class TestExecutePostConnectionSqlScrubsControlChars(BaseTestGenerator):
"""Wiring test: execute_post_connection_sql must route the
driver-returned status string through sanitize_driver_message before
embedding it into the user-facing errmsg. The body is exercised
directly with a small stand-in for self._execute and a patched
current_app.logger so the test does not need a real psycopg
connection."""
scenarios = [
('no post-connection SQL configured returns None',
dict(post_sql='', execute_result='ignored',
expected_is_none=True)),
('successful execute returns None',
dict(post_sql='SELECT 1', execute_result=None,
expected_is_none=True)),
('control bytes are scrubbed from the error text',
dict(post_sql='SELECT 1',
execute_result='boom\x00\x1b[31m\x7falert',
expected_contains='boom[31malert',
expected_excludes=['\x00', '\x1b', '\x7f'])),
('HTML payload is escaped to inert entities in the errmsg',
dict(post_sql='SELECT 1',
execute_result=(
'<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">'),
expected_contains='&lt;iframe srcdoc=&quot;',
expected_excludes=['<iframe', '<script'])),
('multi-line PG error text is preserved',
dict(post_sql='SELECT 1',
execute_result='line one\nline two',
expected_contains='line one\nline two',
expected_excludes=[])),
]
def runTest(self):
from unittest.mock import MagicMock
from flask import Flask
from flask_babel import Babel
from pgadmin.utils.driver.psycopg3.connection import Connection
class FakeConn:
def __init__(self, result):
self._result = result
def _execute(self, cur, sql):
return self._result
fake = FakeConn(self.execute_result)
manager = MagicMock(post_connection_sql=self.post_sql)
cur = MagicMock()
# Real Flask app + Flask-Babel — the production code path uses
# flask_babel.gettext, and an uninitialised Babel raises KeyError.
# Initialising Babel here exercises the real gettext fallback
# (return the source string verbatim when no translation matches).
app = Flask(__name__)
Babel(app)
with app.app_context():
errmsg = Connection.execute_post_connection_sql(
fake, cur, manager)
if getattr(self, 'expected_is_none', False):
self.assertIsNone(errmsg)
return
self.assertIsNotNone(errmsg)
self.assertIn(self.expected_contains, errmsg)
for needle in self.expected_excludes:
self.assertNotIn(needle, errmsg)

View File

@@ -0,0 +1,32 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""
Sanitization helpers for text returned by the PostgreSQL driver.
Used as a defense-in-depth scrub when an attacker-controlled PostgreSQL
server (or any untrusted source on the connection) returns text that will
be embedded in a user-facing message. The output is HTML-escaped so it is
safe to render as HTML by any downstream consumer (frontend toast, log
viewer, API client) without further processing.
"""
import html
import re
# C0 control characters except TAB (0x09), LF (0x0A), CR (0x0D), plus DEL
# (0x7F). The retained whitespace characters are needed to keep multi-line
# PostgreSQL error messages readable.
_CONTROL_CHARS_RE = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]')
def sanitize_driver_message(text):
if text is None:
return None
return html.escape(_CONTROL_CHARS_RE.sub('', str(text)), quote=True)

View File

@@ -0,0 +1,131 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { render } from '@testing-library/react';
import Analysis from '../../../pgadmin/static/js/Explain/Analysis';
import { nodeExplainTableData } from '../../../pgadmin/static/js/Explain/index';
import { withTheme } from '../fake_theme';
const ThemedAnalysis = withTheme(Analysis);
function newExplainContext() {
return {
explainTable: {
rows: [],
show_timings: false,
show_rowsx: false,
show_rows: false,
show_plan_rows: false,
statistics: {
nodes: {},
tables: {},
},
},
};
}
function lastRow(ctx) {
return ctx.explainTable.rows[ctx.explainTable.rows.length - 1];
}
describe('Explain nodeExplainTableData escaping', () => {
// Regression: prior code concatenated Recheck Cond and Exact Heap Blocks
// into the HTML string without _.escape(). All sibling extra-info fields
// escaped, but those two did not. An attacker who could influence the
// plan output (column names, expressions in a Recheck Cond) could inject
// markup that would later flow through HTMLReactParse in Analysis.jsx.
it('escapes Recheck Cond before storing as HTML', () => {
const ctx = newExplainContext();
nodeExplainTableData({
'Node Type': 'Bitmap Heap Scan',
'image_text': 'Bitmap Heap Scan',
'Recheck Cond': '<img src=x onerror="alert(1)">',
}, ctx);
const extra = lastRow(ctx).node_extra_info.join('|');
expect(extra).toContain('Recheck Cond');
// _.escape converts < / > / " to entities so the markup cannot be
// re-parsed into a real <img> element downstream. Asserting on the
// angle-bracket entity is what actually matters here.
expect(extra).not.toContain('<img');
expect(extra).toContain('&lt;img');
});
it('escapes Exact Heap Blocks before storing as HTML', () => {
const ctx = newExplainContext();
nodeExplainTableData({
'Node Type': 'Bitmap Heap Scan',
'image_text': 'Bitmap Heap Scan',
'Exact Heap Blocks': '<script>alert(1)</script>',
}, ctx);
const extra = lastRow(ctx).node_extra_info.join('|');
expect(extra).toContain('Heap Blocks');
expect(extra).not.toContain('<script');
expect(extra).toContain('&lt;script');
});
it('escapes all previously-safe sibling fields too (regression)', () => {
// Belt-and-braces: confirm the existing _.escape sites still work
// after the diff so we don't accidentally regress any of them.
const ctx = newExplainContext();
nodeExplainTableData({
'Node Type': 'Hash Join',
'image_text': 'Hash Join',
'Filter': '<i>filter</i>',
'Index Cond': '<i>icond</i>',
'Hash Cond': '<i>hcond</i>',
'Join Filter': '<i>jfilter</i>',
}, ctx);
const extra = lastRow(ctx).node_extra_info.join('|');
expect(extra).not.toContain('<i>');
expect(extra).toContain('&lt;i&gt;');
});
});
describe('Explain Analysis renders extraInfo sanitized', () => {
// Verifies that the DOMPurify wrap added to Analysis NodeText actually
// strips dangerous markup from extraInfo items before they reach the DOM.
it('strips iframe/script from extraInfo items', () => {
const explainTable = {
rows: [{
data: {
'Node Type': 'Bitmap Heap Scan',
'image_text': 'Bitmap Heap Scan',
arr_id: 'row-1',
level: [1],
parent_node: '',
_serial: 1,
},
display_text: 'Bitmap Heap Scan',
tooltip_text: 'Bitmap Heap Scan',
node_extra_info: [
'<strong>Recheck Cond</strong>: ' +
'<iframe srcdoc="&lt;script&gt;window.__xss=1&lt;/script&gt;">',
'<script>window.__xss2=1</script>after',
],
}],
show_timings: false,
show_rowsx: false,
show_rows: false,
show_plan_rows: false,
};
const ctrl = render(<ThemedAnalysis explainTable={explainTable} />);
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('script')).toBeNull();
// Safe markup (<strong>) is preserved — extraInfo is developer-authored.
expect(ctrl.container.querySelector('strong')).not.toBeNull();
expect(window.__xss).toBeUndefined();
expect(window.__xss2).toBeUndefined();
});
});

View File

@@ -0,0 +1,67 @@
/////////////////////////////////////////////////////////////
//
// 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="&lt;script&gt;alert(1)&lt;/script&gt;">';
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();
});
});

View File

@@ -0,0 +1,147 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import Notifier from '../../pgadmin/static/js/helpers/Notifier';
import { MESSAGE_TYPE } from '../../pgadmin/static/js/components/FormComponents';
function makeStubs() {
const modal = {
alert: jest.fn(),
confirm: jest.fn(),
confirmDelete: jest.fn(),
showModal: jest.fn(),
};
const snackbar = {
callNotify: jest.fn(),
notify: jest.fn(),
};
return { modal, snackbar };
}
describe('Notifier safe-variant snackbar methods', () => {
it('errorText plumbs plainText: true to the snackbar', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
n.errorText('boom', 3000);
expect(snackbar.callNotify).toHaveBeenCalledWith(
'boom', MESSAGE_TYPE.ERROR, 3000, { plainText: true });
});
it('warningText, infoText, successText also set plainText: true', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
n.warningText('w');
n.infoText('i');
n.successText('s');
expect(snackbar.callNotify.mock.calls[0])
.toEqual(['w', MESSAGE_TYPE.WARNING, expect.anything(), { plainText: true }]);
expect(snackbar.callNotify.mock.calls[1])
.toEqual(['i', MESSAGE_TYPE.INFO, expect.anything(), { plainText: true }]);
expect(snackbar.callNotify.mock.calls[2])
.toEqual(['s', MESSAGE_TYPE.SUCCESS, expect.anything(), { plainText: true }]);
});
it('error (default) does not set plainText', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
n.error('boom', 3000);
expect(snackbar.callNotify).toHaveBeenCalledWith(
'boom', MESSAGE_TYPE.ERROR, 3000);
});
});
describe('Notifier safe-variant modal alert', () => {
it('alertText plumbs plainText: true to modal.alert', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
const cb = () => {};
n.alertText('Error', 'boom', cb);
expect(modal.alert).toHaveBeenCalledWith(
'Error', 'boom', cb, 'OK', { plainText: true });
});
it('alert (default) does not pass plainText option', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
n.alert('Title', '<b>safe</b>');
expect(modal.alert).toHaveBeenCalledWith(
'Title', '<b>safe</b>', undefined, 'OK');
});
});
describe('Notifier pgRespErrorNotify', () => {
it('uses errorText for non-410 errors', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
const apiError = {
response: {
status: 500,
headers: { 'content-type': 'application/json' },
data: { errormsg: 'pg boom' },
},
};
n.pgRespErrorNotify(apiError);
expect(snackbar.callNotify).toHaveBeenCalledWith(
expect.stringContaining('pg boom'), MESSAGE_TYPE.ERROR,
expect.anything(), { plainText: true });
});
it('uses alertText for 410 Gone', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
const apiError = {
response: {
status: 410,
statusText: 'Gone',
headers: { 'content-type': 'application/json' },
data: { errormsg: 'pg boom' },
},
};
n.pgRespErrorNotify(apiError);
expect(modal.alert).toHaveBeenCalledWith(
expect.stringContaining('Gone'), 'pg boom',
undefined, 'OK', { plainText: true });
});
});
describe('Notifier pgNotifier', () => {
it('alertText receives the raw errormsg — no _.escape, no \\n -> <br/>', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
const apiError = {
response: {
headers: { 'content-type': 'application/json' },
data: { errormsg: 'line1\nline2 with <b>tag</b>' },
},
};
n.pgNotifier('error', apiError, 'Title');
expect(modal.alert).toHaveBeenCalledWith(
'Title', 'line1\nline2 with <b>tag</b>',
undefined, 'OK', { plainText: true });
});
it('falls back to "Unknown error" when result and errormsg are absent', () => {
const { modal, snackbar } = makeStubs();
const n = new Notifier(modal, snackbar);
const apiError = {
response: {
headers: { 'content-type': 'application/json' },
data: {},
},
};
n.pgNotifier('error', apiError, 'Title');
expect(modal.alert).toHaveBeenCalledWith(
'Title', 'Unknown error', undefined, 'OK', { plainText: true });
});
});

View File

@@ -0,0 +1,63 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { render } from '@testing-library/react';
jest.mock('../../pgadmin/settings/static/ApplicationStateProvider', () => ({
useApplicationState: () => ({ deleteToolData: jest.fn() }),
}));
import ToolErrorView from '../../pgadmin/static/js/ToolErrorView';
import { withTheme } from './fake_theme';
const ThemedToolErrorView = withTheme(ToolErrorView);
function makePanelDocker() {
return {
eventBus: { registerListener: jest.fn() },
close: jest.fn(),
};
}
describe('ToolErrorView', () => {
it('renders the error string as plain text, no HTML parsing', () => {
const error = '<b>bold</b><br/>line two';
const ctrl = render(
<ThemedToolErrorView
error={error}
panelId="p1"
panelDocker={makePanelDocker()} />);
expect(ctrl.container.querySelector('b')).toBeNull();
expect(ctrl.container.querySelector('br')).toBeNull();
expect(ctrl.container.textContent).toContain(error);
});
it('does not execute iframe srcdoc payload', () => {
const xss = '<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">';
const ctrl = render(
<ThemedToolErrorView
error={xss}
panelId="p1"
panelDocker={makePanelDocker()} />);
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('script')).toBeNull();
});
it('handles missing error gracefully', () => {
const ctrl = render(
<ThemedToolErrorView
error={undefined}
panelId="p1"
panelDocker={makePanelDocker()} />);
// Should render the wrapper without crashing.
expect(ctrl.container.textContent)
.toContain('An error occurred while opening the tool');
});
});

View File

@@ -13,7 +13,7 @@ import { withTheme } from '../fake_theme';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {FormInputText, FormInputFileSelect, FormInputSQL,
import {FormInput, FormInputText, FormInputFileSelect, FormInputSQL,
FormInputSwitch, FormInputCheckbox, FormInputToggle, FormInputSelect,
FormInputColor,
FormFooterMessage,
@@ -597,4 +597,150 @@ describe('FormComponents', ()=>{
expect(ctrl.container).toBeEmptyDOMElement();
});
});
describe('XSS sanitization', ()=>{
let ThemedFormFooterMessage = withTheme(FormFooterMessage);
let ThemedFormInput = withTheme(FormInput);
it('NotifierMessage strips iframe srcdoc payload', ()=>{
const xss = '<iframe srcdoc="&lt;script&gt;window.__xss=true&lt;/script&gt;">';
const ctrl = render(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.ERROR}
message={'Failed to connect:\n' + xss}
closable={false}
/>);
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('script')).toBeNull();
});
it('FormInput strips script tag in errorMessage', ()=>{
const xss = '<script>window.__xss=true</script>Bad input';
const ctrl = render(
<ThemedFormInput label="Field" errorMessage={xss}>
<input />
</ThemedFormInput>);
expect(ctrl.container.querySelector('script')).toBeNull();
});
it('preserves safe HTML formatting', ()=>{
const ctrl = render(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.INFO}
message={'Connected to <b>prod</b><br/>OK'}
closable={false}
/>);
expect(ctrl.container.querySelector('b')).not.toBeNull();
expect(ctrl.container.querySelector('br')).not.toBeNull();
});
it('NotifierMessage in plainText mode renders raw text, no HTML parsing', ()=>{
const ctrl = render(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.ERROR}
message={'oops: <b>bold</b><br/>line two'}
plainText={true}
closable={false}
/>);
// Even <b> and <br> tags must NOT be interpreted as DOM.
expect(ctrl.container.querySelector('b')).toBeNull();
expect(ctrl.container.querySelector('br')).toBeNull();
// The raw characters appear in the text content instead.
expect(ctrl.container.textContent)
.toContain('oops: <b>bold</b><br/>line two');
});
it('NotifierMessage in plainText mode strips iframe payload', ()=>{
const xss = '<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">';
const ctrl = render(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.ERROR}
message={'Failed to connect:\n' + xss}
plainText={true}
closable={false}
/>);
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('script')).toBeNull();
});
it('FormInput errorMessage is rendered as plain text', ()=>{
// The errorMessage prop is always treated as a validation / driver
// error string. Even safe-looking HTML must render literally.
const ctrl = render(
<ThemedFormInput label="Field" errorMessage={'bad: <b>field</b>'}>
<input />
</ThemedFormInput>);
expect(ctrl.container.querySelector('b')).toBeNull();
expect(ctrl.container.textContent).toContain('bad: <b>field</b>');
});
// Exercise common XSS vectors beyond the iframe srcdoc payload cited
// in the original report. DOMPurify's default config (used by the
// HTML-mode NotifierMessage / AlertContent renderers) should
// neutralise all of them — these tests pin that behavior so a
// future config tweak (ADD_TAGS, FORBID_* relaxation, etc.) can't
// silently re-open the class of bug.
const VECTORS = [
['svg onload', '<svg/onload=alert(1)>'],
['img onerror', '<img src=x onerror=alert(1)>'],
['body onload', '<body onload=alert(1)>'],
['math href javascript',
'<math href="javascript:alert(1)"><mtext>x</mtext></math>'],
['anchor javascript URI',
'<a href="javascript:alert(1)">click</a>'],
['form action javascript',
'<form action="javascript:alert(1)"><input></form>'],
['object data javascript',
'<object data="javascript:alert(1)"></object>'],
['embed src javascript', '<embed src="javascript:alert(1)">'],
['style expression',
'<div style="background:url(javascript:alert(1))">x</div>'],
['data URI in iframe src',
'<iframe src="data:text/html,<script>alert(1)</script>"></iframe>'],
['marquee onstart', '<marquee onstart=alert(1)>x</marquee>'],
['details ontoggle', '<details ontoggle=alert(1) open>x</details>'],
['template script',
'<template><script>alert(1)</script></template>'],
['xss in noscript',
'<noscript><script>alert(1)</script></noscript>'],
];
for (const [name, payload] of VECTORS) {
it(`NotifierMessage neutralises XSS vector: ${name}`, ()=>{
window.__xss = undefined;
const ctrl = render(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.ERROR}
message={payload}
closable={false}
/>);
// No script element should reach the DOM.
expect(ctrl.container.querySelector('script')).toBeNull();
// No iframe or embed escape.
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('embed')).toBeNull();
// Inline event handlers stripped — verify nothing fires by
// re-checking the global sentinel.
expect(window.__xss).toBeUndefined();
// No element should keep a javascript: / data: URL in
// href / src / action.
ctrl.container.querySelectorAll('[href], [src], [action]')
.forEach((el) => {
for (const attr of ['href', 'src', 'action']) {
const v = el.getAttribute(attr);
if (v) {
expect(v.toLowerCase()).not.toMatch(/^\s*javascript:/);
expect(v.toLowerCase()).not.toMatch(/^\s*data:/);
}
}
});
// No on* event-handler attributes survive.
ctrl.container.querySelectorAll('*').forEach((el) => {
for (const attr of el.attributes) {
expect(attr.name.toLowerCase()).not.toMatch(/^on/);
}
});
});
}
});
});

View File

@@ -0,0 +1,68 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { render } from '@testing-library/react';
import { SafeMessage } from '../../../pgadmin/static/js/components/SafeMessage';
describe('SafeMessage', () => {
it('renders plain text', () => {
const ctrl = render(<SafeMessage text="hello world" />);
expect(ctrl.container.textContent).toBe('hello world');
});
it('does not interpret HTML in text', () => {
const ctrl = render(
<SafeMessage text={'<script>window.__xss=true</script>bad'} />);
expect(ctrl.container.querySelector('script')).toBeNull();
expect(ctrl.container.textContent)
.toBe('<script>window.__xss=true</script>bad');
});
it('does not interpret iframe srcdoc payload', () => {
const xss = '<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">';
const ctrl = render(<SafeMessage text={'Failed to connect:\n' + xss} />);
expect(ctrl.container.querySelector('iframe')).toBeNull();
expect(ctrl.container.querySelector('script')).toBeNull();
expect(ctrl.container.textContent).toContain('iframe');
});
it('preserves newlines via pre-wrap', () => {
const ctrl = render(<SafeMessage text={'line one\nline two'} />);
const span = ctrl.container.querySelector('span');
expect(span).not.toBeNull();
expect(span.style.whiteSpace).toBe('pre-wrap');
expect(span.textContent).toBe('line one\nline two');
});
it('renders empty for null and undefined', () => {
const a = render(<SafeMessage text={null} />);
const b = render(<SafeMessage text={undefined} />);
expect(a.container.textContent).toBe('');
expect(b.container.textContent).toBe('');
});
it('applies overflow-safe CSS for long unbreakable tokens', () => {
// PostgreSQL error messages can contain very long identifiers or
// hex strings. The combination of overflowWrap: anywhere and
// wordBreak: break-word ensures the message wraps inside its
// container instead of forcing horizontal scroll.
const longToken = 'x'.repeat(500);
const ctrl = render(<SafeMessage text={longToken} />);
const span = ctrl.container.querySelector('span');
expect(span.style.overflowWrap).toBe('anywhere');
expect(span.style.wordBreak).toBe('break-word');
});
it('preserves consecutive spaces (pre-wrap, not pre-line)', () => {
const ctrl = render(<SafeMessage text={'a b'} />);
expect(ctrl.container.querySelector('span').textContent).toBe('a b');
});
});

View File

@@ -75,10 +75,20 @@ const Browser = {
},
notifier: {
alert: ()=>{/*This is intentional (SonarQube)*/},
alertText: ()=>{/*This is intentional (SonarQube)*/},
error: ()=>{/*This is intentional (SonarQube)*/},
errorText: ()=>{/*This is intentional (SonarQube)*/},
warning: ()=>{/*This is intentional (SonarQube)*/},
warningText: ()=>{/*This is intentional (SonarQube)*/},
info: ()=>{/*This is intentional (SonarQube)*/},
infoText: ()=>{/*This is intentional (SonarQube)*/},
success: ()=>{/*This is intentional (SonarQube)*/},
successText: ()=>{/*This is intentional (SonarQube)*/},
confirm: ()=>{/*This is intentional (SonarQube)*/},
confirmDelete: ()=>{/*This is intentional (SonarQube)*/},
notify: ()=>{/*This is intentional (SonarQube)*/},
pgRespErrorNotify: ()=>{/*This is intentional (SonarQube)*/},
pgNotifier: ()=>{/*This is intentional (SonarQube)*/},
},
stdH: {
sm: 200,

View File

@@ -56,7 +56,7 @@ describe('#enableTriggers', () => {
tree.addChild(schema1, tableNoData);
jest.spyOn(pgAdmin.Browser.notifier, 'success');
jest.spyOn(pgAdmin.Browser.notifier, 'error');
jest.spyOn(pgAdmin.Browser.notifier, 'errorText');
generateUrlSpy = jest.fn();
generateUrlSpy.mockReturnValue('/some/place');
@@ -137,11 +137,11 @@ describe('#enableTriggers', () => {
});
it('displays an error alert', async () => {
pgAdmin.Browser.notifier.error.mockClear();
pgAdmin.Browser.notifier.errorText.mockClear();
tree.selectNode([{id: 'table1'}]);
enableTriggers(tree, generateUrlSpy, {});
await waitFor(()=>{
expect(pgAdmin.Browser.notifier.error).toHaveBeenCalledWith('some error message');
expect(pgAdmin.Browser.notifier.errorText).toHaveBeenCalledWith('some error message');
});
});
@@ -260,11 +260,11 @@ describe('#disableTriggers', () => {
});
it('displays an error alert', async () => {
pgAdmin.Browser.notifier.error.mockClear();
pgAdmin.Browser.notifier.errorText.mockClear();
tree.selectNode([{id: 'table1'}]);
disableTriggers(tree, generateUrlSpy, {});
await waitFor(()=>{
expect(pgAdmin.Browser.notifier.error).toHaveBeenCalledWith('some error message');
expect(pgAdmin.Browser.notifier.errorText).toHaveBeenCalledWith('some error message');
});
});