285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* blurt-image-link-safety smoke — cp388.
|
|
*
|
|
* HIGH-SECURITY regression net for the order-`terms` Blurt-image
|
|
* link feature. Order terms are public, on-chain, attacker-controlled
|
|
* free text; they render as PLAIN TEXT except that an https link to an
|
|
* image on Blurt's own image server (`img.blurt.blog`) becomes a
|
|
* clickable external link (opens a fresh tab; never an inline <img>,
|
|
* so the viewer's IP never leaks on render).
|
|
*
|
|
* This smoke pins the security model so a later checkpoint cannot
|
|
* loosen it without a red CI:
|
|
* - the validator accepts ONLY https + exact host img.blurt.blog +
|
|
* image extension + no userinfo + default port;
|
|
* - the validator REJECTS every host-spoof / scheme / port / ext /
|
|
* userinfo trick;
|
|
* - the linkifier flags ONLY validator-approved URLs (a non-Blurt or
|
|
* non-image URL stays inert plain text — no arbitrary external
|
|
* links in public terms);
|
|
* - an XSS-shaped run is never turned into a link;
|
|
* - the render path is wired into all four terms-display views via
|
|
* `<TermsText>`, binds the href through the safe builder, never
|
|
* uses `{@html}`, and carries the privacy anchor attributes;
|
|
* - href-xss-smoke recognises `safeBlurtImageUrl` as a safe builder.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { safeBlurtImageUrl, linkifyBlurtImageSegments } from '../src/lib/utils/blurtImageLink';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const WEB = join(__dirname, '..');
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
function ok(msg: string) {
|
|
pass++;
|
|
console.log(` \u2713 ${msg}`);
|
|
}
|
|
function bad(msg: string) {
|
|
fail++;
|
|
console.error(` \u2717 ${msg}`);
|
|
}
|
|
function expect(cond: boolean, msg: string) {
|
|
if (cond) ok(msg);
|
|
else bad(msg);
|
|
}
|
|
|
|
// ── 1. Validator ACCEPTS legitimate Blurt-image URLs ──
|
|
const ACCEPT = [
|
|
'https://img.blurt.blog/blurtimage/saboin/c034057d522b2a4cd356e1e1a01b2bd6c33774c5.png',
|
|
'https://img.blurt.blog/blurtimage/alice/abc.jpg',
|
|
'https://img.blurt.blog/blurtimage/alice/abc.jpeg',
|
|
'https://img.blurt.blog/blurtimage/alice/abc.gif',
|
|
'https://img.blurt.blog/blurtimage/alice/abc.webp',
|
|
'https://img.blurt.blog/blurtimage/alice/ABC.PNG', // uppercase ext
|
|
'https://IMG.BLURT.BLOG/blurtimage/alice/abc.png', // host case-normalized by URL()
|
|
'https://img.blurt.blog/640x480/https://other.example/x.jpg', // proxy form, ends in image ext
|
|
'https://img.blurt.blog/blurtimage/alice/abc.png?cache=1', // query preserved, path ends in ext
|
|
'https://imgp.blurt.blog/768x0/https://img.blurt.blog/blurtimage/alice/abc.png', // proxy host, condenser resize form
|
|
'https://imgp.blurt.blog/blurtimage/alice/abc.webp', // proxy host, direct
|
|
'https://IMGP.BLURT.BLOG/768x0/x.jpg' // proxy host case-normalized by URL()
|
|
];
|
|
for (const u of ACCEPT) expect(safeBlurtImageUrl(u) !== null, `accepts ${u}`);
|
|
|
|
// ── 2. Validator REJECTS every spoof / scheme / port / ext / userinfo trick ──
|
|
const REJECT: Array<[string, string]> = [
|
|
['http://img.blurt.blog/blurtimage/a/x.png', 'http (not https)'],
|
|
['https://img.blurt.blog.evil.com/x.png', 'host suffix attack'],
|
|
['https://imgp.blurt.blog.evil.com/x.png', 'proxy host suffix attack'],
|
|
['https://evil.img.blurt.blog/x.png', 'left-extended host (not exact)'],
|
|
['https://imgpXblurt.blog/x.png', 'proxy lookalike host'],
|
|
['https://imgXblurt.blog/x.png', 'lookalike host'],
|
|
['https://evil.example/x.png', 'unrelated host'],
|
|
['https://blurt.blog/images/logo.png', 'apex blurt.blog (site assets, not user images)'],
|
|
['https://img.blurt.blog/x.txt', 'non-image extension'],
|
|
['https://img.blurt.blog/x.svg', 'svg excluded (can carry script)'],
|
|
['https://img.blurt.blog/page', 'no extension'],
|
|
['https://img.blurt.blog/blurtimage/a/x', 'no extension (blurtimage path)'],
|
|
['https://evil.example@img.blurt.blog/x.png', 'userinfo present'],
|
|
['https://img.blurt.blog:8080/x.png', 'non-default port'],
|
|
['javascript:alert(1)//img.blurt.blog/x.png', 'javascript: scheme'],
|
|
['data:image/png;base64,AAAA', 'data: scheme'],
|
|
['', 'empty string'],
|
|
[' ', 'whitespace only'],
|
|
['not a url', 'unparseable']
|
|
];
|
|
for (const [u, why] of REJECT)
|
|
expect(safeBlurtImageUrl(u) === null, `rejects (${why}): ${u || '∅'}`);
|
|
expect(safeBlurtImageUrl(null) === null, 'rejects null');
|
|
expect(safeBlurtImageUrl(undefined) === null, 'rejects undefined');
|
|
|
|
// ── 3. Linkifier flags ONLY validator-approved URLs ──
|
|
{
|
|
const segs = linkifyBlurtImageSegments(
|
|
'photo of the bike: https://img.blurt.blog/blurtimage/me/bike.jpg — thanks'
|
|
);
|
|
const links = segs.filter((s) => s.link);
|
|
expect(links.length === 1, 'one link segment for a single Blurt image URL');
|
|
expect(
|
|
links[0]?.value === 'https://img.blurt.blog/blurtimage/me/bike.jpg',
|
|
'link segment carries the exact URL'
|
|
);
|
|
expect(
|
|
segs.map((s) => s.value).join('') ===
|
|
'photo of the bike: https://img.blurt.blog/blurtimage/me/bike.jpg — thanks',
|
|
'segments reassemble to the original text (no data loss)'
|
|
);
|
|
}
|
|
{
|
|
const segs = linkifyBlurtImageSegments('contact me at https://evil.example/track.png please');
|
|
expect(
|
|
segs.every((s) => !s.link),
|
|
'non-Blurt URL is NOT linkified (stays plain text)'
|
|
);
|
|
}
|
|
{
|
|
const segs = linkifyBlurtImageSegments(
|
|
'good https://img.blurt.blog/blurtimage/a/x.png bad https://evil.example/y.png'
|
|
);
|
|
const links = segs.filter((s) => s.link);
|
|
expect(
|
|
links.length === 1 && links[0].value.includes('img.blurt.blog'),
|
|
'mixed: only the Blurt URL links'
|
|
);
|
|
}
|
|
{
|
|
// trailing punctuation peeled off the link
|
|
const segs = linkifyBlurtImageSegments('see https://img.blurt.blog/blurtimage/a/x.png.');
|
|
const link = segs.find((s) => s.link);
|
|
expect(
|
|
link?.value === 'https://img.blurt.blog/blurtimage/a/x.png',
|
|
'trailing period peeled from the link value'
|
|
);
|
|
expect(
|
|
segs.some((s) => !s.link && s.value === '.'),
|
|
'peeled period kept as plain text'
|
|
);
|
|
}
|
|
expect(linkifyBlurtImageSegments('').length === 0, 'empty text → no segments');
|
|
expect(
|
|
linkifyBlurtImageSegments('no links here at all').every((s) => !s.link),
|
|
'plain text with no URL → no link segments'
|
|
);
|
|
|
|
// ── 4. XSS-shaped run is never turned into a link ──
|
|
{
|
|
const payload = 'https://img.blurt.blog/x.png"><script>alert(1)</script>';
|
|
expect(safeBlurtImageUrl(payload) === null, 'validator rejects an XSS-tail URL');
|
|
const segs = linkifyBlurtImageSegments(`look ${payload}`);
|
|
expect(
|
|
segs.every((s) => !s.link),
|
|
'linkifier does not link an XSS-tail run'
|
|
);
|
|
}
|
|
|
|
// ── 5. Render wiring: TermsText is the single render path, no {@html}, privacy attrs present ──
|
|
// cp406 — TermsText renders a structured tree from parseTermsMarkdown()
|
|
// (restricted markdown: headings/bold/italics/lists/hr; cp413 blockquotes;
|
|
// cp414 `[text](url)` hyperlinks). Safe hrefs originate from TWO validated
|
|
// builders baked into the parse tree in termsMarkdown.ts — safeBlurtImageUrl
|
|
// (auto-linked Blurt images) and safeContactUrl (explicit `[text](url)` links,
|
|
// which REFUSES javascript:/data:/etc, leaving an unsafe scheme as inert text) —
|
|
// and TermsText binds either via href={r.href}. Still NO {@html}: every leaf
|
|
// goes through Svelte escaping.
|
|
const termsText = readFileSync(join(WEB, 'src/lib/components/TermsText.svelte'), 'utf8');
|
|
const termsMarkdown = readFileSync(join(WEB, 'src/lib/utils/termsMarkdown.ts'), 'utf8');
|
|
expect(!/\{@html\s/.test(termsText), 'TermsText never uses the {@html} directive');
|
|
expect(
|
|
termsMarkdown.includes('safeBlurtImageUrl(seg.value)'),
|
|
'termsMarkdown builds Blurt-image link href via the safe builder'
|
|
);
|
|
expect(
|
|
termsMarkdown.includes('safeContactUrl(m[2]'),
|
|
'termsMarkdown validates `[text](url)` hyperlink URLs via safeContactUrl (scheme allowlist)'
|
|
);
|
|
expect(
|
|
termsText.includes('href={r.href}'),
|
|
'TermsText binds href from the safe parse tree'
|
|
);
|
|
expect(termsText.includes('target="_blank"'), 'TermsText opens links in a new tab');
|
|
expect(
|
|
termsText.includes('rel="noopener noreferrer nofollow"'),
|
|
'TermsText link carries noopener noreferrer nofollow'
|
|
);
|
|
expect(
|
|
termsText.includes('referrerpolicy="no-referrer"'),
|
|
'TermsText link sets referrerpolicy="no-referrer"'
|
|
);
|
|
// cp415 — terms links render in the brand emerald (visible on the dark terms
|
|
// panel) and, on click/tap, open a "Leaving Morphit" interstitial before
|
|
// navigating; the actual navigation is an anchor-click with noopener/noreferrer.
|
|
expect(
|
|
termsText.includes('text-morphit-emerald'),
|
|
'TermsText renders links in the brand emerald'
|
|
);
|
|
expect(
|
|
/onclick=\{\(e\) => onLinkClick\(e, r\.href\)\}/.test(termsText),
|
|
'TermsText intercepts link clicks (interstitial trigger)'
|
|
);
|
|
expect(
|
|
termsText.includes('<ConfirmModal') && termsText.includes("terms.leave_site.title"),
|
|
'TermsText shows the Leaving-Morphit ConfirmModal on a link click'
|
|
);
|
|
expect(
|
|
/destinationHost\(/.test(termsText) &&
|
|
/terms\.leave_site\.body[\s\S]*?values:\s*\{\s*site:/.test(termsText),
|
|
'TermsText names the destination host in the interstitial body (anti-phishing)'
|
|
);
|
|
expect(
|
|
/a\.rel = 'noopener noreferrer'/.test(termsText) && /a\.click\(\)/.test(termsText),
|
|
'TermsText navigates via a noopener/noreferrer anchor-click after confirm'
|
|
);
|
|
|
|
// ── 6. Full-terms views render clickable terms through TermsText ──
|
|
// cp404 — the orderbook + account LIST cards were consolidated into the
|
|
// shared OrderCard, which shows terms as a TRUNCATED, plain-escaped preview
|
|
// (no clickable links, no {@html} — see section 6b). The views below render
|
|
// the FULL terms with clickable links, so they MUST route through TermsText.
|
|
const SITES = [
|
|
'src/routes/[lang]/[x+40][account=account]/[permlink=permlink]/+page.svelte',
|
|
'src/routes/[lang]/my/orders/+page.svelte'
|
|
];
|
|
for (const rel of SITES) {
|
|
const src = readFileSync(join(WEB, rel), 'utf8');
|
|
const wired =
|
|
src.includes("import TermsText from '$components/TermsText.svelte'") &&
|
|
src.includes('<TermsText');
|
|
expect(wired, `terms rendered via <TermsText> in ${rel.split('/').slice(-2).join('/')}`);
|
|
// belt-and-suspenders: no raw {order.terms}/{o.terms} render left behind
|
|
expect(
|
|
!/[>\s]\{(?:order|o)\.terms\}/.test(src),
|
|
`no raw terms interpolation left in ${rel.split('/').slice(-2).join('/')}`
|
|
);
|
|
}
|
|
|
|
// ── 6b. OrderCard list-preview terms are SAFE escaped text ──
|
|
// The browse cards intentionally show a one-line truncated preview (full
|
|
// terms + clickable safe links live on the order detail page). cp406 — the
|
|
// preview is stripMarkdown(order.terms) rendered inside a `.truncate` span, so
|
|
// hostile markup/URLs in terms are neutralised (inert, markdown-stripped
|
|
// escaped text), not executed or turned into links. This preserves the
|
|
// link-safety model without the invalid nested-<a> a TermsText link would
|
|
// create inside the card's stretched detail-page link.
|
|
//
|
|
// cp411 — when the orderbook "Order details" search is active, the matched
|
|
// word(s) are highlighted via a SINGLE `{@html termsPreviewHtml}`. That is
|
|
// SAFE: termsPreviewHtml is produced by highlightMatches(), which HTML-escapes
|
|
// every character of the (attacker-controllable) terms and only ever injects a
|
|
// static-class <mark> — its dedicated orderbook-terms-highlight-safety-smoke
|
|
// pins that guarantee. What we lock here is that this highlight builder is the
|
|
// ONLY {@html} in OrderCard and that its source is that safe function, so raw
|
|
// terms can never reach {@html} directly.
|
|
{
|
|
const orderCard = readFileSync(join(WEB, 'src/lib/components/OrderCard.svelte'), 'utf8');
|
|
const orderCardHtmlBindings = orderCard.match(/\{@html\s+[^}]*\}/g) ?? [];
|
|
expect(
|
|
orderCardHtmlBindings.length === 1 && /\{@html\s+termsPreviewHtml\s*\}/.test(orderCard),
|
|
'OrderCard uses exactly one {@html}, bound to termsPreviewHtml (the highlight preview)'
|
|
);
|
|
expect(
|
|
/const\s+termsPreviewHtml\s*=[\s\S]*?highlightMatches\(/.test(orderCard),
|
|
"OrderCard's {@html} source (termsPreviewHtml) is produced by the safe highlightMatches() builder"
|
|
);
|
|
expect(
|
|
/stripMarkdown\(order\.terms\)/.test(orderCard),
|
|
'OrderCard strips markdown from the terms preview'
|
|
);
|
|
expect(
|
|
/class="truncate"[^>]*>\{termsPreview\}/.test(orderCard),
|
|
'OrderCard non-highlighted terms preview is a single truncated, escaped line'
|
|
);
|
|
}
|
|
|
|
// ── 7. href-xss-smoke recognises safeBlurtImageUrl as a safe builder ──
|
|
const hrefXss = readFileSync(join(WEB, 'scripts/href-xss-smoke.ts'), 'utf8');
|
|
expect(
|
|
/SAFE_BUILDER_NAMES[\s\S]*'safeBlurtImageUrl'/.test(hrefXss),
|
|
'safeBlurtImageUrl is registered in href-xss SAFE_BUILDER_NAMES'
|
|
);
|
|
|
|
console.log(`\n${pass} ok, ${fail} failing`);
|
|
if (fail > 0) process.exit(1);
|
|
console.log(`\u2713 all ${pass} blurt-image-link-safety scenarios passed`);
|