Select text inside a selected Heading or Text block and a floating
toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is
prevented so the selection survives; mod+b/i/u handled on the editable).
Storage: new `html` prop holding sanitised inline HTML, with `text` kept
as the plain mirror for AI summaries, search and legacy content.
Unmarked edits store html '' so legacy nodes keep their shape. The
allowlist (b strong i em u a br span; style=color only, hex/rgb/
var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links
unwrapped; target only _blank with forced rel=noopener) runs on commit,
on canvas render and again at export. If `text` was changed without
`html` (Sitesmith update_props), the stale html is ignored.
Each edit commits once (blur, or deselect if no blur came first) as one
setProp = one undo step; the old blur+deselect double commit is gone.
Heading colour default now tokenRef('text'). Sitesmith canvas summary
skips `html`.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|