diff --git a/frontend/apps/remark42/app/components/comment/comment.test.tsx b/frontend/apps/remark42/app/components/comment/comment.test.tsx index 3e52f471..a1dae1d2 100644 --- a/frontend/apps/remark42/app/components/comment/comment.test.tsx +++ b/frontend/apps/remark42/app/components/comment/comment.test.tsx @@ -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('', () => { 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', '<script>Hacked you!</script>'], + ['doubly-escaped entity', '&lt;script&gt;'], + ['mixed named entities', 'I love © 2026 & "quotes" 'too''], + ['nbsp entity whitespace', '   indented'], + ['decimal numeric entities', '<div>hello</div>'], + ['hex numeric entities with xss-like content', '<img src=x onerror=alert(1)>'], + ['hex numeric emoji entities', '😀 💩 emoji via numeric'], + ['obscure named entities', 'Æsop était ‍ici'], + ['malformed entity without semicolon', '< without semicolon and & with'], + ['entity-shaped junk', '&;<;&#;&#x;&#xZZZ;'], + ['recursive-looking numeric entity', '&#60;'], + ['programmer content with real < and entities', '5 < 10 && 10 > 5'], + ['real < mixed with <', 'a < b and <tag> literal'], + ['sixteen back-to-back <', '<<<<<<<<<<<<<<<<'], + ['fenced code block with entities', '```\n<pre>code</pre>\n```'], + ['link with entity-encoded query ampersands', '[link](https://example.com/?a=1&b=2&c=3)'], + ['null/replacement/surrogate hex entities', '���'], + + ['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', '``'], + ['inline code with entity script tag', '`<script>`'], + [ + 'fenced html with iframe and double-escaped', + '```html\n\n&lt;b&gt;\n```', + ], + ['javascript link', '[click](javascript:alert(1))'], + ['image with entity alt and title', '![<alt>](x.png "&title&")'], + ['markdown backslash escapes', '\\*not em\\* \\_not em\\_ \\\\ \\< \\& \\`not code\\`'], + ['entities inside emphasis markers', '*<em>* _>underscore<_ **&bold&**'], + ['headers with entities', '# <h1>\n## &header&'], + ['blockquote with entities multiline', '> <foo>\n> &quoted&\n>\n> nested <bar/>'], + ['raw autolinks', ' see also '], + ['raw script tag no markdown', ''], + ['iframe object embed chain', ''], + ['kitchen sink', 'mix: `a` \\* *b* <c> &d& \\\\ \\`e\\` [f](javascript:0) ![g](h """)'], + + ['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(); + + const textarea = screen.getByTestId('textarea_1') as HTMLTextAreaElement; + expect(textarea.value).toBe(expectedTextareaValue(payload)); + }); + }); }); diff --git a/frontend/apps/remark42/app/components/comment/comment.tsx b/frontend/apps/remark42/app/components/comment/comment.tsx index a2d23141..2b49340d 100644 --- a/frontend/apps/remark42/app/components/comment/comment.tsx +++ b/frontend/apps/remark42/app/components/comment/comment.tsx @@ -303,14 +303,7 @@ export class Comment extends Component { ? 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, };