# Conflicts: # craft/src/editor/CanvasOverlays.tsx # craft/src/styles/editor.css
This commit is contained in:
commit
98e32a68b9
14 files changed
+1091
-68
No files matched your search
@@ -22,3 +22,21 @@ describe('summarizeCanvas', () => {
|
||||
expect(out).toContain('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeCanvas with inline rich text (#46)', () => {
|
||||
test('summarises the plain text mirror, never the html markup', () => {
|
||||
const state = {
|
||||
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['h'] },
|
||||
h: {
|
||||
type: { resolvedName: 'Heading' },
|
||||
props: { text: 'Hello World', html: 'Hello <b>World</b>', level: 'h2' },
|
||||
nodes: [],
|
||||
},
|
||||
};
|
||||
const out = summarizeCanvas(state as any);
|
||||
expect(out).toContain('text=Hello World');
|
||||
expect(out).toContain('level=h2');
|
||||
expect(out).not.toContain('<b>');
|
||||
expect(out).not.toContain('html=');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,8 @@ export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string
|
||||
const nodeId = (props as any).node_id ?? id;
|
||||
const interesting: string[] = [];
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (k === 'aiName' || k === 'node_id' || k === 'style') continue;
|
||||
// `html` (#46) is markup for the plain `text` mirror -- summarise text only.
|
||||
if (k === 'aiName' || k === 'node_id' || k === 'style' || k === 'html') continue;
|
||||
if (v == null) continue;
|
||||
const repr = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeInlineHtml,
|
||||
inlineHtmlToText,
|
||||
textToInlineHtml,
|
||||
isPlainInlineHtml,
|
||||
} from './inline-html';
|
||||
|
||||
describe('sanitizeInlineHtml allowlist', () => {
|
||||
test.each([
|
||||
['<script>alert(1)</script>hi', 'hi'],
|
||||
['<b onclick="alert(1)">x</b>', '<b>x</b>'],
|
||||
['<span style="position:fixed;top:0">x</span>', 'x'],
|
||||
['<span style="color:var(--wsb-primary)">x</span>', '<span style="color:var(--wsb-primary)">x</span>'],
|
||||
['<span style="color:#ff0000">x</span>', '<span style="color:#ff0000">x</span>'],
|
||||
['<span style="color: rgb(255, 0, 0);">x</span>', '<span style="color:rgb(255, 0, 0)">x</span>'],
|
||||
['<span style="color:red;position:fixed">x</span>', 'x'],
|
||||
['<span style="color:expression(alert(1))">x</span>', 'x'],
|
||||
['<span style="color:var(--evil)">x</span>', 'x'],
|
||||
['<span style="color:#fff;background:url(javascript:alert(1))">x</span>', '<span style="color:#fff">x</span>'],
|
||||
['<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>', '<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>'],
|
||||
['<div>outer <div>inner</div></div>', 'outer <br>inner'],
|
||||
['a<div>b</div>', 'a<br>b'],
|
||||
['<img src=x onerror=alert(1)>t', 't'],
|
||||
['<svg><script>alert(1)</script></svg>t', 't'],
|
||||
['<iframe src="https://e.com"></iframe>t', 't'],
|
||||
['<b data-x="1" id="a" class="c">x</b>', '<b>x</b>'],
|
||||
['<script>alert(1)</script>', '<script>alert(1)</script>'],
|
||||
['<style>body{display:none}</style>t', 't'],
|
||||
['<b>x</b><h2>unwrapped</h2>', '<b>x</b>unwrapped'],
|
||||
])('%s -> %s', (input, expected) => {
|
||||
expect(sanitizeInlineHtml(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('empty / non-string input -> empty string', () => {
|
||||
expect(sanitizeInlineHtml('')).toBe('');
|
||||
expect(sanitizeInlineHtml(' ')).toBe('');
|
||||
expect(sanitizeInlineHtml(undefined as any)).toBe('');
|
||||
expect(sanitizeInlineHtml({} as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeInlineHtml links', () => {
|
||||
test.each([
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="JaVaScRiPt:alert(1)">x</a>',
|
||||
'<a href=" javascript:alert(1)">x</a>',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="java\tscript:alert(1)">x</a>',
|
||||
'<a href="vbscript:msgbox(1)">x</a>',
|
||||
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
|
||||
'<a href="data:image/svg+xml;base64,PHN2Zz4=">x</a>',
|
||||
'<a>x</a>',
|
||||
])('unsafe/missing href unwraps to text: %s', (input) => {
|
||||
expect(sanitizeInlineHtml(input)).toBe('x');
|
||||
});
|
||||
|
||||
test('target=_blank forces rel=noopener (author rel is replaced)', () => {
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a>'))
|
||||
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank" rel="opener">x</a>'))
|
||||
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
|
||||
});
|
||||
|
||||
test('non-_blank targets and stray rel are dropped', () => {
|
||||
expect(sanitizeInlineHtml('<a href="/about" target="_top" rel="x">x</a>')).toBe('<a href="/about">x</a>');
|
||||
});
|
||||
|
||||
test('href/target/rel on a non-anchor are stripped', () => {
|
||||
expect(sanitizeInlineHtml('<b href="https://e.com" target="_blank">x</b>')).toBe('<b>x</b>');
|
||||
});
|
||||
|
||||
test('safe relative, mailto and https links survive', () => {
|
||||
expect(sanitizeInlineHtml('<a href="mailto:a@b.co">m</a>')).toBe('<a href="mailto:a@b.co">m</a>');
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com/?a=1&b=2">q</a>')).toBe('<a href="https://e.com/?a=1&b=2">q</a>');
|
||||
});
|
||||
|
||||
test('a link carrying an event handler keeps only the href', () => {
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" onmouseover="alert(1)">x</a>')).toBe('<a href="https://e.com">x</a>');
|
||||
});
|
||||
|
||||
test('quote breakout in an attribute cannot inject markup', () => {
|
||||
const out = sanitizeInlineHtml('<a href="https://e.com/"onmouseover="alert(1)">x</a>');
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = out;
|
||||
expect(tpl.content.querySelector('a')!.getAttributeNames()).toEqual(['href']);
|
||||
});
|
||||
|
||||
test('output is idempotent', () => {
|
||||
const once = sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a> <b>y</b><div>z</div>');
|
||||
expect(sanitizeInlineHtml(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text <-> inline html', () => {
|
||||
test.each(['plain', 'a<b & "c" \'d\' >e', 'line 1\nline 2', '<already>', '', 'x\n\ny'])(
|
||||
'round-trip inlineHtmlToText(textToInlineHtml(%j))',
|
||||
(s) => {
|
||||
expect(inlineHtmlToText(textToInlineHtml(s))).toBe(s);
|
||||
},
|
||||
);
|
||||
|
||||
test('inlineHtmlToText strips tags, decodes entities, <br> -> newline', () => {
|
||||
expect(inlineHtmlToText('Hello <b>World</b>')).toBe('Hello World');
|
||||
expect(inlineHtmlToText('a & b<br>c <d>')).toBe('a & b\nc <d>');
|
||||
});
|
||||
|
||||
test('textToInlineHtml escapes and turns newlines into <br>', () => {
|
||||
expect(textToInlineHtml('a<b\nc')).toBe('a<b<br>c');
|
||||
});
|
||||
|
||||
test('isPlainInlineHtml: only text and <br> count as no marks', () => {
|
||||
expect(isPlainInlineHtml('a & b<br>c')).toBe(true);
|
||||
expect(isPlainInlineHtml('a <b>b</b>')).toBe(false);
|
||||
expect(isPlainInlineHtml('<br>')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import createDOMPurify from 'dompurify';
|
||||
import { escapeHtml, safeUrl } from './escape';
|
||||
|
||||
/**
|
||||
* Inline rich text (#46) for Heading / Text blocks.
|
||||
*
|
||||
* Stored on the `html` prop and emitted RAW into the published page, so it is
|
||||
* an XSS sink: everything goes through `sanitizeInlineHtml`, both when the
|
||||
* editor commits an edit and again at export (defence in depth -- a saved
|
||||
* blob or an AI `update_props` op never passed through the editor).
|
||||
*
|
||||
* Allowlist only: b/strong/i/em/u/a/br/span; `style` may carry `color` and
|
||||
* nothing else (hex, rgb()/rgba() or a `var(--wsb-*)` token); `a href` must
|
||||
* pass `safeUrl` or the link is unwrapped to its text; `target` is only ever
|
||||
* `_blank` and then always gets `rel="noopener"`.
|
||||
*/
|
||||
|
||||
export const INLINE_ALLOWED_TAGS = ['b', 'strong', 'i', 'em', 'u', 'a', 'br', 'span'];
|
||||
export const INLINE_ALLOWED_ATTR = ['href', 'target', 'rel', 'style'];
|
||||
|
||||
// A private DOMPurify instance: HtmlBlock's `purifyHtml` adds/removes a hook
|
||||
// on the shared default instance, and nothing here may ever inherit it (or
|
||||
// leak into it).
|
||||
let purifier: ReturnType<typeof createDOMPurify> | null = null;
|
||||
function getPurifier() {
|
||||
if (!purifier) purifier = createDOMPurify(window);
|
||||
return purifier;
|
||||
}
|
||||
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: INLINE_ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: INLINE_ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM_FRAGMENT: true,
|
||||
};
|
||||
|
||||
const SAFE_COLOR_RE = new RegExp(
|
||||
'^(?:' +
|
||||
'#[0-9a-f]{3,8}' +
|
||||
'|var\\(--wsb-[a-z0-9-]+\\)' +
|
||||
'|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)' +
|
||||
')$',
|
||||
'i',
|
||||
);
|
||||
|
||||
/** The `color` value of an inline style string if it is a safe colour, else ''. */
|
||||
function safeColorFromStyle(style: string): string {
|
||||
let color = '';
|
||||
for (const decl of style.split(';')) {
|
||||
const idx = decl.indexOf(':');
|
||||
if (idx < 0) continue;
|
||||
if (decl.slice(0, idx).trim().toLowerCase() !== 'color') continue;
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
color = SAFE_COLOR_RE.test(value) ? value : '';
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function unwrap(el: Element): void {
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return;
|
||||
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
||||
parent.removeChild(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn block boundaries that contentEditable inserts on Enter
|
||||
* (`a<div>b</div>`, `<p>…</p>`) into `<br>` so a multi-line edit keeps its
|
||||
* line breaks once the block tags are unwrapped by the allowlist. Runs on an
|
||||
* inert <template> (no script, no resource loads) before DOMPurify.
|
||||
*/
|
||||
function blocksToBreaks(html: string): string {
|
||||
if (!/<(div|p)\b/i.test(html)) return html;
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const blocks = Array.from(tpl.content.querySelectorAll('div, p')).reverse();
|
||||
for (const el of blocks) {
|
||||
if (el.previousSibling) el.parentNode?.insertBefore(document.createElement('br'), el);
|
||||
}
|
||||
return tpl.innerHTML;
|
||||
}
|
||||
|
||||
/** Sanitise inline rich text to the allowlist above. '' for empty/non-string. */
|
||||
export function sanitizeInlineHtml(html: string): string {
|
||||
if (typeof html !== 'string' || html.trim() === '') return '';
|
||||
const frag = getPurifier().sanitize(blocksToBreaks(html), PURIFY_CONFIG as any) as unknown as DocumentFragment;
|
||||
|
||||
frag.querySelectorAll('[style]').forEach((el) => {
|
||||
const color = safeColorFromStyle(el.getAttribute('style') || '');
|
||||
if (color) el.setAttribute('style', `color:${color}`);
|
||||
else el.removeAttribute('style');
|
||||
});
|
||||
|
||||
// Only <a> may carry href/target/rel.
|
||||
frag.querySelectorAll('[href],[target],[rel]').forEach((el) => {
|
||||
if (el.tagName !== 'A') {
|
||||
el.removeAttribute('href');
|
||||
el.removeAttribute('target');
|
||||
el.removeAttribute('rel');
|
||||
}
|
||||
});
|
||||
|
||||
frag.querySelectorAll('a').forEach((a) => {
|
||||
const href = safeUrl(a.getAttribute('href') || '');
|
||||
if (!href) { unwrap(a); return; }
|
||||
a.setAttribute('href', href);
|
||||
const blank = a.getAttribute('target') === '_blank';
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
if (blank) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener');
|
||||
}
|
||||
});
|
||||
|
||||
// A span without a (surviving) colour carries nothing -- drop the wrapper.
|
||||
frag.querySelectorAll('span:not([style])').forEach(unwrap);
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.appendChild(frag);
|
||||
return box.innerHTML;
|
||||
}
|
||||
|
||||
/** True when sanitised inline html carries no marks (only text and <br>). */
|
||||
export function isPlainInlineHtml(html: string): boolean {
|
||||
return !/<(?!br\b)[a-z]/i.test(html);
|
||||
}
|
||||
|
||||
/** Plain-text mirror of inline html: tags stripped, entities decoded, <br> -> newline. */
|
||||
export function inlineHtmlToText(html: string): string {
|
||||
if (typeof html !== 'string' || html === '') return '';
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
tpl.content.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
return tpl.content.textContent || '';
|
||||
}
|
||||
|
||||
/** Legacy plain `text` rendered as inline html: escaped, newlines -> <br>. */
|
||||
export function textToInlineHtml(text: string): string {
|
||||
return escapeHtml(text ?? '').replace(/\r\n?|\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* The sanitised rich html to show for a Heading/Text node, or null when the
|
||||
* plain `text` should be used instead. `text` is the plain mirror of `html`;
|
||||
* when a writer that only knows `text` (Sitesmith `update_props`, an older
|
||||
* saved blob) changes it, the mirror no longer matches and the stale `html`
|
||||
* is ignored -- `text` wins. A missing/empty `text` never overrides `html`.
|
||||
*/
|
||||
export function resolveInlineHtml(text: unknown, html: unknown): string | null {
|
||||
if (typeof html !== 'string' || html === '') return null;
|
||||
const clean = sanitizeInlineHtml(html);
|
||||
if (clean === '') return null;
|
||||
if (typeof text === 'string' && text !== '' && inlineHtmlToText(clean) !== text) return null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Give absolute http(s) links to another host `target="_blank"` (used on commit). */
|
||||
export function markExternalLinks(root: ParentNode): void {
|
||||
root.querySelectorAll('a[href]').forEach((a) => {
|
||||
if (a.hasAttribute('target')) return;
|
||||
try {
|
||||
const url = new URL(a.getAttribute('href') || '', window.location.href);
|
||||
if (/^https?:$/.test(url.protocol) && url.host !== window.location.host) {
|
||||
a.setAttribute('target', '_blank');
|
||||
}
|
||||
} catch { /* unparseable href: leave as-is, the sanitiser decides */ }
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user