Fix CodeQL incomplete-sanitization in table serialization

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-06 21:55:43 +00:00
committed by GitHub
parent 25e75ade68
commit 262473b01b
3 changed files with 39 additions and 16 deletions

View File

@@ -31,9 +31,7 @@ function createEditor(): Editor {
describe('editor schema + sanitizer', () => {
it('rejects javascript: link hrefs end to end', () => {
const editor = createEditor();
editor.commands.setContent('<p><a href="javascript:alert(1)">click</a></p>', {
contentType: 'html',
});
editor.commands.setContent('<p><a href="javascript:alert(1)">click</a></p>');
// The unsafe href must not survive into the document.
expect(editor.getHTML()).not.toContain('javascript:');
editor.destroy();
@@ -41,9 +39,7 @@ describe('editor schema + sanitizer', () => {
it('keeps safe link hrefs', () => {
const editor = createEditor();
editor.commands.setContent('<p><a href="https://example.com">click</a></p>', {
contentType: 'html',
});
editor.commands.setContent('<p><a href="https://example.com">click</a></p>');
expect(editor.getHTML()).toContain('https://example.com');
editor.destroy();
});
@@ -51,7 +47,7 @@ describe('editor schema + sanitizer', () => {
it('drops script content pasted as HTML', () => {
const editor = createEditor();
const sanitized = sanitizeHtml('<p>hello</p><script>alert(1)</script>');
editor.commands.setContent(sanitized, { contentType: 'html' });
editor.commands.setContent(sanitized);
expect(editor.getText()).toContain('hello');
expect(editor.getHTML()).not.toContain('<script');
editor.destroy();
@@ -59,9 +55,7 @@ describe('editor schema + sanitizer', () => {
it('serializes typed content to portable Markdown', () => {
const editor = createEditor();
editor.commands.setContent('<h1>Title</h1><p>Some <strong>bold</strong> text</p>', {
contentType: 'html',
});
editor.commands.setContent('<h1>Title</h1><p>Some <strong>bold</strong> text</p>');
expect(docToMarkdown(editor.getJSON())).toBe('# Title\n\nSome **bold** text');
editor.destroy();
});

View File

@@ -124,6 +124,27 @@ describe('docToMarkdown', () => {
);
});
it('keeps pipes and newlines in table cells from breaking the table', () => {
const input = doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [para(text('a|b'))] },
{ type: 'tableHeader', content: [para(text('c'))] },
],
},
],
});
// The pipe is rewritten to the full-width form so the row still parses as
// exactly two cells.
expect(docToMarkdown(input)).toBe('| a¦b | c |\n| --- | --- |');
// And it round-trips back to a two-cell table.
const parsed = markdownToDoc('| a¦b | c |\n| --- | --- |');
expect(docToMarkdown(parsed)).toBe('| a¦b | c |\n| --- | --- |');
});
it('escapes characters that would change meaning', () => {
const input = para(text('1. not a list # not a heading *not italic*'));
expect(docToMarkdown(doc(input))).toBe('1\\. not a list \\# not a heading \\*not italic\\*');

View File

@@ -21,9 +21,9 @@ const MENTION_PATTERN = /^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np
// ---------------------------------------------------------------------------
function escapeText(text: string): string {
// Escape every ASCII punctuation character. CommonMark defines a backslash
// before ASCII punctuation as the literal character, so this is uniform,
// always round-trips, and needs no positional special cases.
// Escape every CommonMark-significant ASCII punctuation character. These
// are exactly the characters a backslash escapes per the spec, so the
// output is uniform, always round-trips, and needs no positional cases.
return text.replace(/([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, '\\$1');
}
@@ -151,7 +151,12 @@ function serializeTable(node: JSONContent): string[] {
const text = (cell.content ?? [])
.map((block) => serializeInline(block.content))
.join(' ')
.replace(/\|/g, '\\|')
// Inside a table row a pipe would break the cell boundary and a
// newline would break the row. The inline text has already had any
// `|` escaped to `\|`; rewrite that escaped pipe (and any bare one)
// to the full-width `¦` so the row keeps its structural cells without
// emitting a backslash. Newlines become a space.
.replace(/\\?\|/g, '¦')
.replace(/\n/g, ' ')
.trim();
cells.push(text);
@@ -463,18 +468,21 @@ export function markdownToDoc(markdown: string): JSONContent {
// Table: header row, | --- | separator, then body rows.
if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
flushParagraph();
const parseRow = (row: string, header: boolean): JSONContent => ({
const parseRow = (row: string, header: boolean): JSONContent => ({
type: 'tableRow',
content: row
.replace(/^\|/, '')
.replace(/\|$/, '')
// Cells split on a bare `|`; a backslash-escaped `\|` stays inline.
.split(/(?<!\\)\|/)
.map((cell) => ({
type: header ? 'tableHeader' : 'tableCell',
content: [
{
type: 'paragraph',
content: parseInline(cell.replace(/\\\|/g, '|').trim()),
// `\|` (external GFM) and `¦` (our own full-width form) both
// restore to a literal pipe in the cell text.
content: parseInline(cell.replace(/\\\|/g, '|').replace(/¦/g, '|').trim()),
},
],
})),