`;
});
diff --git a/dt-assets/js/comments.js b/dt-assets/js/comments.js
index d52c25ad2e..15d35c25f1 100644
--- a/dt-assets/js/comments.js
+++ b/dt-assets/js/comments.js
@@ -408,23 +408,21 @@ jQuery(document).ready(function ($) {
let array = [];
displayed.forEach((d) => {
+ let objectNote = d.object_note;
let baptismDateRegex = /\{(\d+)\}+/;
- if (baptismDateRegex.test(d.object_note)) {
+ if (baptismDateRegex.test(objectNote)) {
if (d.field_type === 'datetime') {
- d.object_note = d.object_note.replace(
+ objectNote = objectNote.replace(
baptismDateRegex,
formatTimestampToDateTime,
);
} else {
- d.object_note = d.object_note.replace(
+ objectNote = objectNote.replace(
baptismDateRegex,
formatTimestampToDate,
);
}
}
- if (d.object_note) {
- d.object_note = formatComment(d.object_note);
- }
let first = window.lodash.first(array);
let name = d.comment_author || d.name;
let gravatar = d.gravatar || '';
@@ -436,7 +434,7 @@ jQuery(document).ready(function ($) {
true,
),
gravatar,
- text: d.object_note || formatComment(d.comment_content),
+ text: formatComment(objectNote || d.comment_content),
comment: !!d.comment_content,
comment_ID: d.comment_ID,
is_own_comment: d.user_id === commentsSettings.current_user_id,
@@ -611,17 +609,6 @@ jQuery(document).ready(function ($) {
.moment(comment.comment_date_gmt + 'Z')
.add(5, 'seconds');
- /* comment_content should be HTML. However, we want to make sure that
- * HTML like "
Hello" gets transformed to "
Hello
", that
- * is, that all tags are closed, so that the comment_content can be
- * included in HTML without any nasty surprises. This is one way to do
- * that. This is not sufficient for malicious input, but hopefully we
- * can trust the contents of the database to have been sanitized
- * thanks to wp_new_comment . */
-
- // .DT lets strip out the tags provided from the submited comment and treat it as pure text.
- comment.comment_content = $('
').text(comment.comment_content).text();
-
if (!typesCount[comment.comment_type]) {
typesCount[comment.comment_type] = 0;
}
diff --git a/dt-assets/js/shared-functions.js b/dt-assets/js/shared-functions.js
index e760a4fc33..e5fc74b087 100644
--- a/dt-assets/js/shared-functions.js
+++ b/dt-assets/js/shared-functions.js
@@ -769,19 +769,76 @@ window.SHAREDFUNCTIONS = {
*/
formatComment(comment) {
if (comment) {
- // becomes */
+ // WordPress comments already contain HTML entities. Preserve one level
+ // of entity encoding: references stay inert in text and quoted attributes
+ // and must not become visible as e.g. "&" after rendering.
+ const escapeHTML = (value) =>
+ window.SHAREDFUNCTIONS.escapeHTML(value).replace(
+ /&((?:#\d+|#x[\da-f]+|[a-z][a-z\d]+);)/gi,
+ '&$1',
+ );
+ const formatEmphasis = (text) =>
+ text
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/__(.*?)__/g, '$1')
+ .replace(/\*(.*?)\*/g, '$1');
+ const isHttpUrl = (url) => {
+ try {
+ return (
+ /^https?:\/\//i.test(url) &&
+ ['http:', 'https:'].includes(new URL(url).protocol)
+ );
+ } catch (error) {
+ return false;
+ }
+ };
+
+ // Keep generated HTML out of subsequent markdown replacements. Remove
+ // input NULs so a comment cannot impersonate one of these placeholders.
+ const html = [];
+ const keepHtml = (value) => `\u0000${html.push(value) - 1}\u0000`;
+ comment = comment.split('\u0000').join('');
+
+ // Parse image, mention, and link markdown together so generated markup
+ // can never become part of another markdown capture group.
comment = comment.replace(
- /!\[(.*?)\]\((.*?)\)/g,
- "",
+ /([!@]?)\[(.*?)\]\((.*?)\)/g,
+ (match, type, text, url) => {
+ if (type === '!') {
+ return keepHtml(
+ isHttpUrl(url)
+ ? ``
+ : escapeHTML(match),
+ );
+ }
+ if (!url) {
+ return keepHtml(escapeHTML(match));
+ }
+ if (type === '@') {
+ // Keep the @ beside the name for both LTR and RTL mentions.
+ return keepHtml(
+ `@${formatEmphasis(escapeHTML(text))}`,
+ );
+ }
+ if (isHttpUrl(text) && !isHttpUrl(url)) {
+ [url, text] = [text, url];
+ }
+ if (!isHttpUrl(url)) {
+ // Preserve record-relative links, but never accept other schemes or
+ // protocol-relative URLs, including ones containing "http" later on.
+ if (/^[a-z][a-z\d+.-]*:|^[/\\]{2}/i.test(url.trim())) {
+ return keepHtml(escapeHTML(match));
+ }
+ url = `${window.wpApiShare.site_url}/${window.wpApiShare.post_type}/${url}`;
+ }
+ return keepHtml(
+ isHttpUrl(url)
+ ? `${formatEmphasis(escapeHTML(text))}`
+ : escapeHTML(match),
+ );
+ },
);
- let mentionRegex = /\@\[(.*?)\]\((.+?)\)/g;
- comment = comment.replace(mentionRegex, (match, text, id) => {
- /* dir=auto means that @ will be put to the left of the name if the
- * mentioned name is LTR, and to the right if the mentioned name is
- * RTL, instead of letting the surrounding dir determine the placement
- * of @ */
- return `@${text}`;
- });
+
let urlRegex =
/((href=('|"))|(\[|\()?|(http(s)?:((\/)|(\\))*.))*(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//\\=]*)/g;
@@ -798,31 +855,17 @@ window.SHAREDFUNCTIONS = {
} else if (match.indexOf('http') === -1) {
url = 'http://' + match;
}
- if (comment.includes('${match}`;
- }
+ return keepHtml(
+ isHttpUrl(url)
+ ? `${escapeHTML(match)}`
+ : escapeHTML(match),
+ );
}
return match;
});
- let linkRegex = /\[(.*?)\]\((.+?)\)/g; //format [text](link)
- comment = comment.replace(linkRegex, (match, text, url) => {
- if (text.includes('http') && !url.includes('http')) {
- [url, text] = [text, url];
- }
- url = url.includes('http')
- ? url
- : `${window.wpApiShare.site_url}/${window.wpApiShare.post_type}/${url}`;
- return `${text}`;
- });
- //**comment** becomes comment */
- comment = comment.replace(/\*\*(.*?)\*\*/g, '$1');
- //__comment__ becomes comment */
- comment = comment.replace(/\_\_(.*?)\_\_/g, '$1');
- //*comment* becomes comment */
- comment = comment.replace(/\*(.*?)\*/g, '$1');
+ // Escape all remaining text before adding the supported formatting.
+ comment = formatEmphasis(escapeHTML(comment));
//- comment or * comment or + comment becomes comment */
comment = comment.replace(/^\s*[*+-]\s+(.*)$/gm, '
$1
');
// also, #. becomes a numbered list
@@ -867,7 +910,10 @@ window.SHAREDFUNCTIONS = {
}
//return modified comment
- return comment;
+ return comment
+ .split('\u0000')
+ .map((part, index) => (index % 2 ? html[part] : part))
+ .join('');
}
return comment;
diff --git a/package.json b/package.json
index 5dfd858a4a..78cb697898 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"not IE 11"
],
"scripts": {
+ "test:js": "node --test tests/js/*.test.js",
"postinstall": "npm run build",
"build:scripts": "vite build --config vite.config.scripts.js",
"build": "vite build",
diff --git a/tests/README.md b/tests/README.md
index 6e2bba85b1..d5ea21372c 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,5 +1,8 @@
# Running Tests
+Run the JavaScript helper regression tests with `npm run test:js` (Node.js 22).
+These tests do not require a running WordPress site.
+
See [PHPUnit Docs](https://phpunit.readthedocs.io/en/9.5/textui.html) for all command line options
## Test Output
diff --git a/tests/js/format-comment.test.js b/tests/js/format-comment.test.js
new file mode 100644
index 0000000000..2d66b0e1e6
--- /dev/null
+++ b/tests/js/format-comment.test.js
@@ -0,0 +1,251 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+// Load the production helpers without running page initialization or requiring
+// WordPress, jQuery, or a live site.
+const source = readFileSync(
+ new URL('../../dt-assets/js/shared-functions.js', import.meta.url),
+ 'utf8',
+);
+const context = vm.createContext({
+ URL,
+ window: {
+ wpApiShare: { site_url: 'https://dt.example/site', post_type: 'contacts' },
+ },
+});
+vm.runInContext(
+ source.slice(
+ source.indexOf('window.SHAREDFUNCTIONS = {'),
+ source.indexOf('\nlet date_ranges ='),
+ ),
+ context,
+);
+const { formatComment, escapeHTML } = context.window.SHAREDFUNCTIONS;
+
+test('escapes raw markup without double encoding WordPress entities', () => {
+ const text = ` & 'quotes' " &`;
+ assert.equal(
+ formatComment(text),
+ '<img src=x onerror="alert(1)"> & 'quotes' " &',
+ );
+ assert.equal(
+ formatComment('Tom & Jerry <tag>'),
+ 'Tom & Jerry <tag>',
+ );
+ assert.equal(
+ formatComment('[Tom & Jerry](https://example.org/?a=1&b=2)'),
+ 'Tom & Jerry',
+ );
+ assert.equal(
+ formatComment(''),
+ '',
+ );
+});
+
+test('escapes image URLs and alt text at their attribute boundaries', () => {
+ const url = `https://example.org/image.png?' onerror='alert&x="y"`;
+ const alt = `portrait' onload='alert & "name"`;
+ assert.equal(
+ formatComment(``),
+ ``,
+ );
+});
+
+test('escapes markdown link destinations and labels', () => {
+ const url = 'https://example.org/" onmouseover="alert?x=1&y=2';
+ const label = `