morphit/apps/web/scripts/terms-markdown-smoke.ts

297 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Terms restricted-markdown smoke (cp406).
*
* Locks two things:
* 1. parseTermsMarkdown() — the pure parser behind TermsText.svelte. Covers
* the allowed subset (headings 13, bold, italics, ul/ol lists,
* blockquotes, hr, line feeds) and — critically — that NO raw HTML ever
* survives as markup: every leaf is plain text (rendered through Svelte
* escaping), so an attacker-authored `terms` string can never become live
* DOM. This is the highest-risk free-text input in the app, so the XSS
* invariant is regression-locked here.
* 2. stripMarkdown() — the compact-card path (single plain line) now also
* strips headings, horizontal rules, and blockquote markers.
*
* cp413 — added blockquotes.
*
* Usage: tsx apps/web/scripts/terms-markdown-smoke.ts
*/
import { parseTermsMarkdown, type TermsBlock, type TermsInline } from '../src/lib/utils/termsMarkdown.ts';
import { stripMarkdown } from '../src/lib/seo/stripMarkdown.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function assert(cond: boolean, label: string): void {
if (!cond) throw new Error(label);
}
/** Concatenate every leaf's text across a block tree — used to prove no HTML
* markup ever appears as a tag (it's all inert text). */
function flatText(blocks: TermsBlock[]): string {
const parts: string[] = [];
const pushRuns = (runs: TermsInline[]) => {
for (const r of runs) parts.push(r.v);
};
for (const b of blocks) {
if (b.type === 'heading' || b.type === 'paragraph' || b.type === 'blockquote') pushRuns(b.runs);
else if (b.type === 'ul' || b.type === 'ol') b.items.forEach(pushRuns);
}
return parts.join('\u0001');
}
console.log('terms-markdown-smoke: parseTermsMarkdown + stripMarkdown');
scenario('empty / null input → no blocks', () => {
assert(parseTermsMarkdown('').length === 0, 'empty');
assert(parseTermsMarkdown(null).length === 0, 'null');
assert(parseTermsMarkdown(undefined).length === 0, 'undefined');
});
scenario('headings levels 13 (deeper capped at 3)', () => {
const b = parseTermsMarkdown('# One\n\n## Two\n\n### Three\n\n###### Six');
assert(b.length === 4, `count ${b.length}`);
assert(b[0].type === 'heading' && b[0].level === 1, 'h1');
assert(b[1].type === 'heading' && b[1].level === 2, 'h2');
assert(b[2].type === 'heading' && b[2].level === 3, 'h3');
assert(b[3].type === 'heading' && b[3].level === 3, 'h6 capped to 3');
});
scenario('bold + italic inline runs', () => {
const b = parseTermsMarkdown('a **bold** and *italic* end');
assert(b.length === 1 && b[0].type === 'paragraph', 'one paragraph');
if (b[0].type !== 'paragraph') throw new Error('narrow');
const kinds = b[0].runs.map((r) => r.t).join(',');
assert(kinds.includes('bold'), 'has bold');
assert(kinds.includes('italic'), 'has italic');
const bold = b[0].runs.find((r) => r.t === 'bold');
assert(bold?.v === 'bold', 'bold text');
});
scenario('unordered list gathers consecutive items', () => {
const b = parseTermsMarkdown('intro\n\n- one\n- two\n- three');
assert(b.length === 2, `count ${b.length}`);
assert(b[1].type === 'ul', 'ul type');
if (b[1].type !== 'ul') throw new Error('narrow');
assert(b[1].items.length === 3, `items ${b[1].items.length}`);
});
scenario('ordered list gathers consecutive items', () => {
const b = parseTermsMarkdown('1. first\n2. second');
assert(b.length === 1 && b[0].type === 'ol', 'ol');
if (b[0].type !== 'ol') throw new Error('narrow');
assert(b[0].items.length === 2, `items ${b[0].items.length}`);
});
scenario('horizontal rule as its own block', () => {
const b = parseTermsMarkdown('above\n\n---\n\nbelow');
assert(b.some((x) => x.type === 'hr'), 'has hr');
});
scenario('blockquote: single line becomes a blockquote block', () => {
const b = parseTermsMarkdown('> quoted text');
assert(b.length === 1 && b[0].type === 'blockquote', `got ${b[0]?.type}`);
if (b[0].type !== 'blockquote') throw new Error('narrow');
assert(b[0].runs.map((r) => r.v).join('') === 'quoted text', 'marker stripped');
});
scenario('blockquote: consecutive > lines gather into one block, newlines kept', () => {
const b = parseTermsMarkdown('> line one\n> line two');
assert(b.length === 1 && b[0].type === 'blockquote', `count ${b.length}`);
if (b[0].type !== 'blockquote') throw new Error('narrow');
const text = b[0].runs.map((r) => r.v).join('');
assert(text.includes('line one') && text.includes('line two'), 'both lines');
assert(text.includes('\n'), 'internal newline preserved');
});
scenario('blockquote: a non-> line ends the quote (paragraph follows)', () => {
const b = parseTermsMarkdown('> quoted\nafter');
assert(b.length === 2, `count ${b.length}`);
assert(b[0].type === 'blockquote', 'first is blockquote');
assert(b[1].type === 'paragraph', 'second is paragraph');
});
scenario('blockquote: inline emphasis + Blurt image link work inside a quote', () => {
const b = parseTermsMarkdown('> see **bold** and https://img.blurt.blog/x.png');
if (b[0].type !== 'blockquote') throw new Error('narrow');
const kinds = b[0].runs.map((r) => r.t);
assert(kinds.includes('bold'), 'bold inside quote');
const link = b[0].runs.find((r) => r.t === 'link');
assert(!!link && link.href.startsWith('https://img.blurt.blog/'), 'safe blurt link inside quote');
});
scenario('blockquote: a `>` mid-line does NOT start a quote', () => {
const b = parseTermsMarkdown('price a > price b');
assert(b.length === 1 && b[0].type === 'paragraph', `got ${b[0]?.type}`);
});
scenario('blockquote XSS: raw HTML inside a quote stays inert text', () => {
const b = parseTermsMarkdown('> <script>alert(1)</script> <img src=x onerror=alert(2)>');
assert(b[0]?.type === 'blockquote', 'is a blockquote');
const flat = flatText(b);
assert(flat.includes('<script>'), 'script preserved as text');
assert(flat.includes('onerror='), 'onerror preserved as text');
// Only inline run types the renderer knows how to escape may appear.
if (b[0].type === 'blockquote') {
for (const r of b[0].runs) assert(['text', 'bold', 'italic', 'link'].includes(r.t), `run ${r.t}`);
}
});
scenario('line feeds preserved inside a paragraph', () => {
const b = parseTermsMarkdown('line one\nline two');
assert(b.length === 1 && b[0].type === 'paragraph', 'single paragraph');
if (b[0].type !== 'paragraph') throw new Error('narrow');
const text = b[0].runs.map((r) => r.v).join('');
assert(text.includes('\n'), 'newline kept');
});
scenario('XSS: raw HTML stays inert text, never a markup node', () => {
const evil = '<script>alert(1)</script> <img src=x onerror=alert(2)> **b**';
const b = parseTermsMarkdown(evil);
const flat = flatText(b);
// The literal angle-bracket text must survive verbatim as TEXT (Svelte will
// entity-escape it at render). There is no block/run type that emits tags.
assert(flat.includes('<script>'), 'script tag preserved as text');
assert(flat.includes('onerror='), 'onerror preserved as text');
// No run type other than text/bold/italic/link exists — assert the union.
for (const block of b) {
if (block.type === 'heading' || block.type === 'paragraph' || block.type === 'blockquote') {
for (const r of block.runs) assert(['text', 'bold', 'italic', 'link'].includes(r.t), `run ${r.t}`);
}
}
});
scenario('Blurt image link becomes a link run; other urls stay text', () => {
const b = parseTermsMarkdown('see https://img.blurt.blog/x.png here and https://evil.example/y');
if (b[0].type !== 'paragraph') throw new Error('narrow');
const link = b[0].runs.find((r) => r.t === 'link');
assert(!!link, 'blurt image is a link run');
assert(!!link && link.href.startsWith('https://img.blurt.blog/'), 'safe href');
// The non-blurt url must NOT become a link.
const other = b[0].runs.filter((r) => r.t === 'link');
assert(other.length === 1, 'only the blurt url links');
});
scenario('hyperlink: [text](https url) becomes a link run with custom text', () => {
const b = parseTermsMarkdown('Read [our FAQ](https://morphit.io/faq) first');
if (b[0].type !== 'paragraph') throw new Error('narrow');
const link = b[0].runs.find((r) => r.t === 'link');
assert(!!link, 'has a link run');
assert(link?.v === 'our FAQ', `label "${link?.v}"`);
assert(link?.href === 'https://morphit.io/faq', `href "${link?.href}"`);
// surrounding text preserved
const text = b[0].runs.map((r) => r.v).join('');
assert(text.includes('Read ') && text.includes(' first'), 'surrounding text kept');
});
scenario('hyperlink: safe schemes (http/mailto) link, others do not', () => {
for (const [url, isLink] of [
['https://a.example', true],
['http://onion.example', true],
['mailto:seller@example.com', true],
['matrix:r/room:server', true]
] as const) {
const b = parseTermsMarkdown(`x [label](${url}) y`);
const has = b[0].type === 'paragraph' && b[0].runs.some((r) => r.t === 'link');
assert(has === isLink, `${url} → link=${has}`);
}
});
scenario('hyperlink XSS: javascript:/data:/vbscript: schemes are NEVER links (stay inert text)', () => {
for (const url of [
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd'
]) {
const b = parseTermsMarkdown(`click [here](${url})`);
assert(b[0]?.type === 'paragraph', 'paragraph');
if (b[0].type !== 'paragraph') throw new Error('narrow');
// NO link run — the dangerous scheme must not produce an href.
assert(!b[0].runs.some((r) => r.t === 'link'), `${url} must not link`);
// It survives as inert text (Svelte will entity-escape it at render).
const flat = flatText(b);
assert(flat.includes('[here]'), `${url} left as literal text`);
}
});
scenario('hyperlink: works inside a blockquote; a bad scheme there is also inert', () => {
const ok = parseTermsMarkdown('> see [site](https://example.com)');
assert(ok[0]?.type === 'blockquote' && ok[0].runs.some((r) => r.t === 'link'), 'safe link in quote');
const bad = parseTermsMarkdown('> see [x](javascript:alert(1))');
assert(
bad[0]?.type === 'blockquote' && !bad[0].runs.some((r) => r.t === 'link'),
'bad scheme in quote is inert'
);
});
scenario('parser is total on odd markers (never throws)', () => {
for (const s of ['*', '**', '***', '# ', '- ', '1.', ' ', '*a', 'a*', '__x']) {
parseTermsMarkdown(s); // must not throw
}
});
// ── stripMarkdown extension: headings + hr (cp406) ──────────────────────────
scenario('stripMarkdown drops heading markers', () => {
assert(stripMarkdown('# Title') === 'Title', `got "${stripMarkdown('# Title')}"`);
assert(stripMarkdown('### Sub head') === 'Sub head', `got "${stripMarkdown('### Sub head')}"`);
});
scenario('stripMarkdown drops horizontal rules', () => {
const out = stripMarkdown('a\n\n---\n\nb');
assert(!out.includes('---'), `hr leaked: "${out}"`);
assert(out.includes('a') && out.includes('b'), 'text kept');
});
scenario('stripMarkdown drops blockquote markers', () => {
const out = stripMarkdown('> quoted line');
assert(!out.includes('>'), `blockquote marker leaked: "${out}"`);
assert(out.includes('quoted line'), 'quoted text kept');
});
scenario('stripMarkdown drops ordered-list markers (N.)', () => {
const out = stripMarkdown('1. first\n2. second\n3. third');
assert(!/\d+\.\s/.test(out), `ordered marker leaked: "${out}"`);
assert(out.includes('first') && out.includes('third'), 'items kept');
});
scenario('stripMarkdown drops star + dash bullets (incl. first line)', () => {
const star = stripMarkdown('* one\n* two');
assert(!/[*]/.test(star), `star bullet leaked: "${star}"`);
const dash = stripMarkdown('- a\n- b');
assert(!/(^|\s)-\s/.test(dash), `dash bullet leaked: "${dash}"`);
});
scenario('stripMarkdown: NO markdown marker survives a kitchen-sink (OrderCard slice is always clean)', () => {
const out = stripMarkdown(
'# H1\n\n## H2\n\n**bold** and *italic* and `code`\n\n1. num one\n2. num two\n* star\n- dash\n\n> a quote\n\n---\n\nsee [our FAQ](https://morphit.io/faq) and https://img.blurt.blog/x.png'
);
// single line
assert(!out.includes('\n'), 'collapsed to one line');
// no surviving markdown syntax markers
assert(!/[#*`]/.test(out), `emphasis/heading/code marker leaked: "${out}"`);
assert(!/\]\(/.test(out), `raw link syntax leaked: "${out}"`);
assert(!/(^|\s)-\s|\d+\.\s|(^|\s)>\s/.test(out), `list/quote marker leaked: "${out}"`);
// content survives as plain text
assert(out.includes('bold') && out.includes('our FAQ'), 'text content kept');
});
scenario('stripMarkdown collapses to a single line', () => {
const out = stripMarkdown('# H\n\n- one\n- two\n\n**bold** *ital*');
assert(!out.includes('\n'), 'single line');
assert(!out.includes('#') && !out.includes('*'), `markers leaked: "${out}"`);
});
console.log(`\nterms-markdown-smoke: ${scenarios - failures}/${scenarios} passed`);
if (failures > 0) {
console.log(`terms-markdown-smoke: ${failures} FAILED`);
process.exit(1);
}
console.log(`✓ all ${scenarios} terms-markdown scenarios passed`);