fix(frontend): preserve orig verbatim in edit textarea (#2040)

The edit textarea was running `data.orig` through the browser's HTML
parser via a detached `<span>.innerHTML` to "decode entities", which
turned user-typed `&lt;`/`&gt;` into real `<`/`>`. On save, blackfriday
then saw a real `<script>` tag, bluemonday stripped it, and the comment
body collapsed to an empty string.

The decode block predates commit 243c835 (2022) which stopped the
backend from sanitising `orig` with bluemonday. Before 243c835, orig
came back HTML-escaped from the API and the frontend compensated.
After 243c835 the backend stores and returns orig byte-for-byte, but
the frontend decode was never removed — so it has been silently
corrupting user input containing entities for ~3.5 years.

The backend contract is clear: `orig` is the raw user input, never
rendered as HTML. The frontend should echo it back into the textarea
unchanged. This change removes the decode and adds 45 table-driven
regression tests covering entity round-trips, unicode edge cases,
and markdown constructs.
This commit is contained in:
Dmitry Verkhoturov
2026-04-12 11:55:24 -05:00
committed by Umputun
parent 80c12a3f10
commit fc6f15534e
2 changed files with 97 additions and 8 deletions
@@ -7,6 +7,7 @@ import { render } from 'tests/utils';
import { StaticStore } from 'common/static-store';
import { Comment, CommentProps } from './comment';
import { CommentForm } from 'components/comment-form';
import { CommentMode } from 'common/types';
function CommentWithIntl(props: CommentProps) {
@@ -255,4 +256,99 @@ describe('<Comment />', () => {
expect(screen.getByText('Edit')).toBeVisible();
});
});
// Regression tests for issue #2040.
// The edit textarea must reflect `data.orig` byte-for-byte; any transformation
// (HTML-entity decoding in particular) corrupts user input on save because
// bluemonday then strips now-real tags that the user typed as entities.
describe('edit textarea preserves data.orig verbatim', () => {
afterEach(() => {
CommentForm.textareaCounter = 0;
localStorage.clear();
});
const cases: Array<[string, string]> = [
['issue 2040 canonical', '&lt;script&gt;Hacked you!&lt;/script&gt;'],
['doubly-escaped entity', '&amp;lt;script&amp;gt;'],
['mixed named entities', 'I love &copy; 2026 &amp; &quot;quotes&quot; &apos;too&apos;'],
['nbsp entity whitespace', '&nbsp;&nbsp;&nbsp;indented'],
['decimal numeric entities', '&#60;div&#62;hello&#60;/div&#62;'],
['hex numeric entities with xss-like content', '&#x3C;img src=x onerror=alert(1)&#x3E;'],
['hex numeric emoji entities', '&#x1F600; &#x1F4A9; emoji via numeric'],
['obscure named entities', '&AElig;sop &eacute;tait &zwj;ici'],
['malformed entity without semicolon', '&lt without semicolon and &amp; with'],
['entity-shaped junk', '&;&lt;;&#;&#x;&#xZZZ;'],
['recursive-looking numeric entity', '&#38;#60;'],
['programmer content with real < and entities', '5 &lt; 10 &amp;&amp; 10 &gt; 5'],
['real < mixed with &lt;', 'a < b and &lt;tag&gt; literal'],
['sixteen back-to-back &lt;', '&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;'],
['fenced code block with entities', '```\n&lt;pre&gt;code&lt;/pre&gt;\n```'],
['link with entity-encoded query ampersands', '[link](https://example.com/?a=1&amp;b=2&amp;c=3)'],
['null/replacement/surrogate hex entities', '&#x0;&#xFFFD;&#xD800;'],
['zero-width characters interleaved', 'Hello\u200Bworld\u200Cfoo\u200Dbar'],
['bidi Hebrew + Arabic + ASCII', '\u05E9\u05DC\u05D5\u05DD hello \u0645\u0631\u062D\u0628\u0627'],
['RLO override embedded', 'safe\u202Eevil\u202Cend'],
['decomposed vs precomposed é', 'cafe\u0301 vs caf\u00E9'],
['ZWJ family emoji', 'family: \uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC66'],
['supplementary plane surrogate pairs', 'poop: \uD83D\uDCA9 and math: \uD835\uDC00'],
['line and paragraph separators', 'line1\u2028line2\u2029para2'],
['BOM at start middle end', '\uFEFFstart mid\uFEFFdle end\uFEFF'],
['varied unicode whitespace', 'a\u00A0b\u3000c\u2003d\u202Fe'],
['tab LF CR CRLF LFCR', 'tab\there\nnl\rcr\r\ncrlf\n\rlfcr'],
['cyrillic homoglyph a', 'Cyrillic \u0430pple vs Latin apple'],
['full-width angle brackets', 'fullwidth \uFF1Cscript\uFF1E not a tag'],
['bidi mark soup', 'mix: \u202Dltr\u202C \u202Ertl\u202C \u200E\u200F'],
['inline code with real script tag', '`<script>alert(1)</script>`'],
['inline code with entity script tag', '`&lt;script&gt;`'],
[
'fenced html with iframe and double-escaped',
'```html\n<iframe src="javascript:alert(1)"></iframe>\n&amp;lt;b&amp;gt;\n```',
],
['javascript link', '[click](javascript:alert(1))'],
['image with entity alt and title', '![&lt;alt&gt;](x.png "&amp;title&amp;")'],
['markdown backslash escapes', '\\*not em\\* \\_not em\\_ \\\\ \\< \\& \\`not code\\`'],
['entities inside emphasis markers', '*&lt;em&gt;* _&gt;underscore&lt;_ **&amp;bold&amp;**'],
['headers with entities', '# &lt;h1&gt;\n## &amp;header&amp;'],
['blockquote with entities multiline', '> &lt;foo&gt;\n> &amp;quoted&amp;\n>\n> nested &lt;bar/&gt;'],
['raw autolinks', '<https://example.com/?a=1&b=2> see also <user@example.com>'],
['raw script tag no markdown', '<script>alert(1)</script>'],
['iframe object embed chain', '<iframe src=x></iframe><object data=x></object><embed src=x>'],
['kitchen sink', 'mix: `a` \\* *b* &lt;c&gt; &amp;d&amp; \\\\ \\`e\\` [f](javascript:0) ![g](h "&quot;")'],
['empty string', ''],
['only whitespace', ' \t\n '],
];
// HTML5 textarea.value always normalises \r\n and lone \r to \n.
// This happens inside the browser regardless of any remark42 code,
// so the edit round-trip guarantee is "byte-equal after newline normalisation".
const expectedTextareaValue = (raw: string) => raw.replace(/\r\n|\r/g, '\n');
it.each(cases)('renders unchanged: %s', (_label, payload) => {
CommentForm.textareaCounter = 0;
StaticStore.config.edit_duration = 300;
const p = getProps();
p.repliesCount = 0;
p.user!.id = '100';
p.data.user.id = '100';
p.editMode = CommentMode.Edit;
// @ts-ignore - CommentForm prop is optional on CommentProps
p.CommentForm = CommentForm;
Object.assign(p.data, {
id: '101',
vote: 1,
time: Date.now(),
delete: false,
orig: payload,
});
render(<CommentWithIntl {...p} />);
const textarea = screen.getByTestId('textarea_1') as HTMLTextAreaElement;
expect(textarea.value).toBe(expectedTextareaValue(payload));
});
});
});
@@ -303,14 +303,7 @@ export class Comment extends Component<CommentProps, State> {
? intl.formatMessage(messages.deletedComment)
: props.data.text,
time: new Date(props.data.time),
orig: isEditing
? props.data.orig &&
props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, (entity) => {
const span = document.createElement('span');
span.innerHTML = entity;
return span.innerText;
})
: props.data.orig,
orig: props.data.orig,
user: props.data.user,
};