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="..."> -> <iframe srcdoc="...">
Real Postgres errors (which never contain literal markup) round-
trip unchanged for human readability. Protects third-party JSON
consumers (audit logs, API clients, support tickets) that do not
have the frontend's DOMPurify.
Tests
- components/SafeMessage.spec.js — 25 payloads (iframe srcdoc, SVG
onload, MathML href javascript:, embed/object data:, marquee
onstart, template/noscript, CSS expression, etc.).
- Notifier.spec.js — 9 cases (errorText / alertText plumbing,
pgRespErrorNotify 410 vs non-410, pgNotifier raw errormsg pass-
through, "Unknown error" fallback).
- ToolErrorView.spec.js — 3 cases.
- ModalProvider.spec.js — 4 cases.
- FormComponents.spec.js extended with NotifierMessage plainText
mode + FormInput errorMessage plain-text rendering.
- Explain regression test renders Analysis with HTML-laden extraInfo
and asserts no <iframe> / <script> reaches the DOM.
- Python: test_sanitize_driver_message.py (21 sanitiser + 5
execute_post_connection_sql wiring scenarios with a real Flask +
flask-babel app context and a stand-in connection).
- test_post_connection_sql_xss.py — end-to-end integration: register
a server, set post_connection_sql to a query referencing a relation
whose name contains an iframe payload, POST to the connect endpoint,
and assert the response JSON errormsg is entity-encoded and never
raw markup.
- fake_pgadmin.js notifier mocks and enable_disable_triggers_spec.js
spies updated for the new API.
875 / 875 JS tests pass; ESLint clean.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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="<script>alert(1)</script>">'
|
||||
|
||||
|
||||
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('<iframe', errmsg)
|
||||
# Ampersand of the inner <script> payload must be
|
||||
# double-encoded (&lt;) — proves the escape ran end-to-end
|
||||
# rather than the input simply being passed through.
|
||||
self.assertIn('&lt;script', errmsg)
|
||||
|
||||
def tearDown(self):
|
||||
utils.delete_server_with_api(self.tester, self.server_id)
|
||||
@@ -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}`));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
() => {},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}/>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>}
|
||||
</>);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
30
web/pgadmin/static/js/components/SafeMessage.jsx
Normal file
30
web/pgadmin/static/js/components/SafeMessage.jsx
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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}` ));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }))} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
} );
|
||||
|
||||
|
||||
@@ -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('');
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
8
web/pgadmin/utils/driver/psycopg3/tests/__init__.py
Normal file
8
web/pgadmin/utils/driver/psycopg3/tests/__init__.py
Normal 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
|
||||
#
|
||||
##########################################################################
|
||||
@@ -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="<script>alert(1)</script>">',
|
||||
expected=(
|
||||
'<iframe srcdoc="'
|
||||
'&lt;script&gt;alert(1)&lt;/script&gt;'
|
||||
'">'),
|
||||
)),
|
||||
('HTML-escapes a bare script tag',
|
||||
dict(input='<script>alert(1)</script>',
|
||||
expected='<script>alert(1)</script>')),
|
||||
('HTML-escapes ampersand and quotes',
|
||||
dict(input='a & b "c" \'d\'',
|
||||
expected='a & b "c" 'd'')),
|
||||
('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='<script>')),
|
||||
('null byte adjacent to ampersand escapes correctly',
|
||||
dict(input='a\x00&b', expected='a&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="<script>alert(1)</script>">'),
|
||||
expected_contains='<iframe srcdoc="',
|
||||
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)
|
||||
32
web/pgadmin/utils/driver/psycopg3/text_sanitize.py
Normal file
32
web/pgadmin/utils/driver/psycopg3/text_sanitize.py
Normal 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)
|
||||
131
web/regression/javascript/Explain/Explain_xss.spec.js
Normal file
131
web/regression/javascript/Explain/Explain_xss.spec.js
Normal 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('<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('<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('<i>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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="<script>window.__xss=1</script>">',
|
||||
'<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();
|
||||
});
|
||||
});
|
||||
67
web/regression/javascript/ModalProvider.spec.js
Normal file
67
web/regression/javascript/ModalProvider.spec.js
Normal 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="<script>alert(1)</script>">';
|
||||
const ctrl = await renderAlert({
|
||||
text: 'Failed:\n' + xss, plainText: true, onOkClick: () => {}});
|
||||
expect(ctrl.container.querySelector('iframe')).toBeNull();
|
||||
expect(ctrl.container.querySelector('script')).toBeNull();
|
||||
expect(ctrl.container.textContent).toContain('iframe');
|
||||
});
|
||||
|
||||
it('preserves newlines in plainText mode (no <br/> substitution needed)',
|
||||
async () => {
|
||||
const body = 'line one\nline two\nline three';
|
||||
const ctrl = await renderAlert({
|
||||
text: body, plainText: true, onOkClick: () => {}});
|
||||
const safeSpan = ctrl.container.querySelector('span');
|
||||
expect(safeSpan).not.toBeNull();
|
||||
expect(safeSpan.textContent).toBe(body);
|
||||
expect(safeSpan.style.whiteSpace).toBe('pre-wrap');
|
||||
});
|
||||
|
||||
it('renders HTML body when plainText is false (default, sanitized)',
|
||||
async () => {
|
||||
const ctrl = await renderAlert({
|
||||
text: 'Welcome <b>admin</b>', onOkClick: () => {}});
|
||||
expect(ctrl.container.querySelector('b')).not.toBeNull();
|
||||
expect(ctrl.container.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips script tag even in default HTML mode', async () => {
|
||||
const ctrl = await renderAlert({
|
||||
text: '<script>window.__xss=true</script>x', onOkClick: () => {}});
|
||||
expect(ctrl.container.querySelector('script')).toBeNull();
|
||||
expect(window.__xss).toBeUndefined();
|
||||
});
|
||||
});
|
||||
147
web/regression/javascript/Notifier.spec.js
Normal file
147
web/regression/javascript/Notifier.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
63
web/regression/javascript/ToolErrorView.spec.js
Normal file
63
web/regression/javascript/ToolErrorView.spec.js
Normal 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="<script>alert(1)</script>">';
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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="<script>window.__xss=true</script>">';
|
||||
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="<script>alert(1)</script>">';
|
||||
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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
68
web/regression/javascript/components/SafeMessage.spec.js
Normal file
68
web/regression/javascript/components/SafeMessage.spec.js
Normal 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="<script>alert(1)</script>">';
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user