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 `<`/`>` 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 commit243c835(2022) which stopped the backend from sanitising `orig` with bluemonday. Before243c835, orig came back HTML-escaped from the API and the frontend compensated. After243c835the 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:
committed by
Umputun
parent
80c12a3f10
commit
fc6f15534e
@@ -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', '<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', '`<script>alert(1)</script>`'],
|
||||
['inline code with entity script tag', '`<script>`'],
|
||||
[
|
||||
'fenced html with iframe and double-escaped',
|
||||
'```html\n<iframe src="javascript:alert(1)"></iframe>\n&lt;b&gt;\n```',
|
||||
],
|
||||
['javascript link', '[click](javascript:alert(1))'],
|
||||
['image with entity alt and 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', '<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* <c> &d& \\\\ \\`e\\` [f](javascript:0) '],
|
||||
|
||||
['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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user