Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Selection, TextSelection } from '@tiptap/pm/state';
import { closeHistory } from '@tiptap/pm/history';
import { embeddedSource } from '../utils/embeddedSource';
import { sourceBlockPreview } from '../utils/sourceBlockPreview';
import { sanitizeDetailsSummaryHtml } from '../utils/sanitizeDetailsSummaryHtml';
import { MarkdownRenderer } from '@/infrastructure/markdown';
import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService';

Expand Down Expand Up @@ -116,81 +117,6 @@ function parseDetailsSource(markdown: string): {
};
}

function isSafePreviewUrl(value: string): boolean {
const normalized = value.trim().toLowerCase();
return !normalized.startsWith('javascript:') && !normalized.startsWith('vbscript:');
}

function sanitizeDetailsSummaryHtml(summaryHtml: string): string {
if (typeof document === 'undefined') {
return summaryHtml;
}

const template = document.createElement('template');
template.innerHTML = summaryHtml;
const allowedTags = new Set(['A', 'STRONG', 'B', 'EM', 'I', 'CODE', 'BR', 'IMG']);

const sanitizeNode = (node: globalThis.Node) => {
if (!(node instanceof HTMLElement)) {
return;
}

if (!allowedTags.has(node.tagName)) {
const parent = node.parentNode;
if (!parent) {
return;
}

while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
parent.removeChild(node);
return;
}

Array.from(node.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value;

if (name.startsWith('on')) {
node.removeAttribute(attr.name);
return;
}

if (node.tagName === 'A') {
if (!['href', 'title'].includes(name)) {
node.removeAttribute(attr.name);
return;
}
if (name === 'href' && !isSafePreviewUrl(value)) {
node.removeAttribute(attr.name);
}
return;
}

if (node.tagName === 'IMG') {
if (!['src', 'alt', 'title', 'width', 'height', 'align'].includes(name)) {
node.removeAttribute(attr.name);
return;
}
if (name === 'src' && !isSafePreviewUrl(value)) {
node.removeAttribute(attr.name);
}
return;
}

if (name !== 'class') {
node.removeAttribute(attr.name);
}
});

Array.from(node.children).forEach((child) => sanitizeNode(child));
};

Array.from(template.content.children).forEach((child) => sanitizeNode(child));
return template.innerHTML;
}

function executeTextareaAction(
textarea: HTMLTextAreaElement | null,
action: 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @vitest-environment jsdom

import { describe, expect, it, vi } from 'vitest';
import { sanitizeDetailsSummaryHtml } from './sanitizeDetailsSummaryHtml';

function preview(source: string): HTMLSpanElement {
const span = document.createElement('span');
// Reparse at the same kind of sink as the production details summary.
span.innerHTML = sanitizeDetailsSummaryHtml(source);
return span;
}

describe('details summary HTML security', () => {
it.each([
'<img src="x" onerror="window.__security_probe=1">',
'<span><img src="x" onerror="window.__security_probe=1"></span>',
'<div><span><img src="x" onerror="window.__security_probe=1"></span></div>',
'<strong><span><img src="x" onerror="window.__security_probe=1"></span></strong>',
])('removes event handlers at every wrapper depth: %s', (source) => {
const result = preview(source);
expect(result.querySelector('img')?.getAttribute('src')).toBe('x');
expect(result.querySelector('[onerror]')).toBeNull();
expect(result.querySelector('div, span')).toBeNull();
});

it.each([
'javascript:window.__security_probe=1',
'java&#x09;script:window.__security_probe=1',
'java&#x0a;script:window.__security_probe=1',
'java&#x0d;script:window.__security_probe=1',
' &#x01;JaVaScRiPt:window.__security_probe=1',
'vbscript:msgbox(1)',
'data:text/html,&lt;script&gt;alert(1)&lt;/script&gt;',
'blob:https://example.com/attacker-document',
'unknown-app:command',
])('removes active or unapproved link protocols: %s', (href) => {
const result = preview(`<span><a href="${href}">Open</a></span>`);
expect(result.querySelector('a')?.hasAttribute('href')).toBe(false);
expect(result.textContent).toBe('Open');
});

it.each(['java&#x09;script:alert(1)', 'vbscript:alert(1)', 'unknown-app:command'])(
'removes active or unapproved image protocols: %s', (src) => {
expect(preview(`<img src="${src}">`).querySelector('img')?.hasAttribute('src')).toBe(false);
},
);

it.each([
'<svg onload="alert(1)"><foreignObject><img src="x" onerror="alert(1)"></foreignObject></svg>',
'<span><svg><a href="javascript:alert(1)">Open</a></svg></span>',
'<math><mtext><img src="x" onerror="alert(1)"></mtext></math>',
'<template><img src="x" onerror="alert(1)"></template>',
'<script>alert(1)</script><iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>',
])('does not retain active elements or foreign namespaces: %s', (source) => {
const result = preview(source);
expect(result.querySelector('svg, math, template, script, iframe, [onerror], [onload]')).toBeNull();
});

it('preserves existing safe formatting and image metadata while dropping unsafe attributes', () => {
const result = preview('<span>Text <strong class="highlight" style="color:red">bold</strong> '
+ '<b>B</b><em>E</em><i>I</i><code class="language-text">&lt;code&gt;</code><br>'
+ '<a href="https://example.com" title="Docs" target="_blank" onclick="alert(1)">Docs</a>'
+ '<img src="../image.png" alt="Image" title="Title" width="32" height="24" align="left" srcset="bad" id="location"></span>');
expect(result.querySelector('strong')?.className).toBe('highlight');
expect(result.querySelector('code')?.textContent).toBe('<code>');
expect(result.querySelectorAll('b, em, i, br')).toHaveLength(4);
expect(result.querySelector('a')?.getAttribute('title')).toBe('Docs');
expect(result.querySelector('img')?.outerHTML).toBe('<img src="../image.png" alt="Image" title="Title" width="32" height="24" align="left">');
expect(result.querySelector('[style], [onclick], [target], [srcset], [id]')).toBeNull();
});

it.each(['https://example.com/docs', 'mailto:dev@example.com', '#section', '../README.md',
'/workspace/README.md', 'file:///workspace/README.md', 'openbitfun-canvas:example', 'tab:example'])(
'preserves supported links: %s', (href) => {
expect(preview(`<a href="${href}">Open</a>`).querySelector('a')?.getAttribute('href')).toBe(href);
},
);

it.each(['https://example.com/a.png', '../a.png', '/workspace/a.png', 'asset://localhost/a.png',
'tauri://localhost/a.png', 'file:///workspace/a.png', 'blob:https://example.com/id', 'data:image/png;base64,aGVsbG8='])(
'preserves supported image sources: %s', (src) => {
expect(preview(`<img src="${src}">`).querySelector('img')?.getAttribute('src')).toBe(src);
},
);

it('fails closed without a DOM', () => {
vi.stubGlobal('document', undefined);
try {
expect(sanitizeDetailsSummaryHtml('<img onerror="alert(1)">&lt;'))
.toBe('&lt;img onerror="alert(1)"&gt;&amp;lt;');
} finally {
vi.unstubAllGlobals();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const linkProtocols = new Set([
'http:', 'https:', 'mailto:', 'tel:', 'sms:', 'xmpp:', 'irc:', 'ircs:',
'file:', 'openbitfun-canvas:', 'computer:', 'tab:', 'visualization:',
]);
const imageProtocols = new Set(['http:', 'https:', 'file:', 'asset:', 'tauri:', 'blob:', 'data:']);

function isSafePreviewUrl(value: string, tagName: string): boolean {
try {
// URL parsing applies the browser's control-character normalization and
// resolves relative paths without depending on the local/remote host URL.
const url = new URL(value, 'https://markdown-preview.invalid/');
return (tagName === 'IMG' ? imageProtocols : linkProtocols).has(url.protocol);
} catch {
return false;
}
}

export function sanitizeDetailsSummaryHtml(summaryHtml: string): string {
if (typeof document === 'undefined') {
// Never hand unsanitized markup to the HTML sink when a DOM is unavailable.
return summaryHtml.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

const template = document.createElement('template');
template.innerHTML = summaryHtml;
const allowedTags = new Set(['A', 'STRONG', 'B', 'EM', 'I', 'CODE', 'BR', 'IMG']);

const sanitizeNode = (node: Element) => {
// SVG/MathML are not HTMLElements, but can contain active content. Drop the
// whole foreign subtree rather than letting it bypass the HTML allowlist.
if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
node.remove();
return;
}

// Sanitize descendants before unwrapping an unsupported parent. Moving
// them first would skip them in the parent's snapshot of child nodes.
Array.from(node.children).forEach(sanitizeNode);

if (!allowedTags.has(node.tagName)) {
while (node.firstChild) {
node.before(node.firstChild);
}
node.remove();
return;
}

const allowedAttributes = node.tagName === 'A'
? ['href', 'title']
: node.tagName === 'IMG'
? ['src', 'alt', 'title', 'width', 'height', 'align']
: ['class'];

Array.from(node.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
if (!allowedAttributes.includes(name)
|| ((name === 'href' || name === 'src') && !isSafePreviewUrl(attr.value, node.tagName))) {
node.removeAttribute(attr.name);
}
});
};

Array.from(template.content.children).forEach(sanitizeNode);
return template.innerHTML;
}
41 changes: 41 additions & 0 deletions tests/e2e/browser/markdown-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ describe('Markdown rich text browser E2E', () => {

});

it('sanitizes loaded and edited details summaries without changing their Markdown source', async () => {
const heading = '# Untrusted document\n\n';
const details = '<details open>\n<summary>'
+ '<span><img src="data:image/png;base64,broken"><a href="java&#x09;script:window.__markdownXss=1">Open</a></span>'
+ '<strong>Safe title</strong></summary>\n\nBody\n\n</details>';
await browser.url('about:blank');
await fetch('http://127.0.0.1:1450/file', { method: 'PUT', body: heading + details });
await editor.open();

const summary = editor.block('details').$('summary');
await expect(summary.$('strong')).toHaveText('Safe title');
await expect(summary.$('a')).not.toHaveAttribute('href');
await browser.execute(() => {
document.querySelector<HTMLAnchorElement>('[data-testid="md-embed-block"] summary a')?.click();
});
expect(await browser.execute(() => (window as Window & { __markdownXss?: number }).__markdownXss ?? 0)).toBe(0);

// Pasting source into an existing details embed renders live without going
// through the initial document parser's source-only classification.
const editedDetails = details.replace('<span>', '<span><img src="data:image/png;base64,broken" onerror="window.__markdownXss=1">')
.replace('</summary>', '<svg onload="window.__markdownXss=1"></svg></summary>');
const block = editor.block('details');
if (!(await block.$('[data-testid="md-embed-source"]').isDisplayed())) {
if (await block.$('details').getAttribute('open') === null) {
await summary.click();
}
await block.$('p').click();
}
await block.$('[data-testid="md-embed-source"]').waitForDisplayed();
await block.$('[data-testid="md-embed-source"]').setValue(editedDetails);
await browser.waitUntil(async () => browser.execute(() =>
document.querySelector<HTMLImageElement>('[data-testid="md-embed-block"] summary img')?.complete === true));
await expect(summary.$('img')).not.toHaveAttribute('onerror');
await expect(summary.$('svg')).not.toExist();
expect(await browser.execute(() => (window as Window & { __markdownXss?: number }).__markdownXss ?? 0)).toBe(0);
await editor.save();
expect(await editor.savedSource()).toBe(heading + editedDetails);
await editor.mode(1);
await expect(editor.source).toHaveValue(heading + editedDetails, { trim: false });
});

it('retains the original standard preview typography and embedded appearance', async () => {
// The math renderer adds an asynchronous wrapper. Compare the standard
// document layout here; formula editing/rendering has separate coverage.
Expand Down
Loading