Merge issue-46 (Refs #44 #46)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:47:54 -07:00
commit 98e32a68b9
14 files changed
+1091 -68

No files matched your search

+18
View File
@@ -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=');
});
});
+2 -1
View File
@@ -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;
+118
View File
@@ -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>'],
['&lt;script&gt;alert(1)&lt;/script&gt;', '&lt;script&gt;alert(1)&lt;/script&gt;'],
['<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="jav&#x61;script:alert(1)">x</a>',
'<a href="jav&#97;script&#58;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&amp;b=2">q</a>')).toBe('<a href="https://e.com/?a=1&amp;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/&quot;onmouseover=&quot;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', '&lt;already&gt;', '', '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 &amp; b<br>c &lt;d&gt;')).toBe('a & b\nc <d>');
});
test('textToInlineHtml escapes and turns newlines into <br>', () => {
expect(textToInlineHtml('a<b\nc')).toBe('a&lt;b<br>c');
});
test('isPlainInlineHtml: only text and <br> count as no marks', () => {
expect(isPlainInlineHtml('a &amp; b<br>c')).toBe(true);
expect(isPlainInlineHtml('a <b>b</b>')).toBe(false);
expect(isPlainInlineHtml('<br>')).toBe(true);
});
});
+171
View File
@@ -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 */ }
});
}