From 0dc7f19a84560ef5b3a2eac7de3530c06a93d093 Mon Sep 17 00:00:00 2001 From: cverorg <292680828+cverorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:14:23 +0900 Subject: [PATCH 1/6] fix(sitetile): escape link destinations before restoring the stash inlineHtml() stashed a link/image destination BEFORE escaping the surrounding text, then restored it RAW after the escaping pass. The stash regex only requires the `](...)` shape, not a matching `[`, so two unrelated bracket-fragments elsewhere in ordinary Markdown prose could each stash their own bracketed content as a "destination" and, once restored raw, splice a live, syntactically complete element into the rendered page -- with no real link ever forming. Reachable by anyone who can write page Markdown. Fix: restore each destination through the same escaper (escHtml) that already runs over the rest of the string, regardless of whether it ends up inside a tag or bare in text. A destination that does form a link or image is additionally validated against a scheme allowlist (http, https, mailto, and scheme-less/relative/fragment) before it is allowed into an href or src at all -- escaping alone stops a raw element from forming but does nothing about a javascript: or data: scheme. A disallowed destination renders as plain text instead. Covers both consumers of the same stash (link href and image src/alt share one mechanism in this function); no other stash/restore of raw destination text exists elsewhere in the file. Adds targeted tests plus keeps all existing sitetile tests green. --- packages/sitetile/site-core.js | 42 ++++++++++++++++++++++++++--- packages/sitetile/site-core.test.js | 34 ++++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/sitetile/site-core.js b/packages/sitetile/site-core.js index 74570d2..9e656cd 100644 --- a/packages/sitetile/site-core.js +++ b/packages/sitetile/site-core.js @@ -586,6 +586,23 @@ function escAttr(s) { return escHtml(s).replace(/"/g, '"'); } // Attribute-safe a value that cssmd ALREADY entity-escaped (&<> done) — only quotes remain. function attrq(s) { return String(s == null ? '' : s).replace(/"/g, '"'); } +// isSafeHref: true if `dest` may become a live `href`/`src`. Asks the URL parser, never a prefix +// test — resolving against a fixed base is what catches "java\nscript:" and a leading space, both +// of which defeat a naive startsWith even lowercased and both still parse to javascript:. `dest` +// may already be entity-escaped (&<> as `&`/`<`/`>`) by the time this runs; that never +// changes the parsed scheme, since a scheme is letters/digits/+/-/. only. No allowlist helper +// exists elsewhere in this file, so this one keeps the renderer's actual normal cases: http(s), +// mailto, and scheme-less destinations (relative paths, `#fragment`) — everything else (notably +// `javascript:`, `data:`) is unsafe and the caller renders the destination as plain text instead. +const SAFE_HREF_BASE = 'http://sitetile.invalid/'; +function isSafeHref(dest) { + const raw = String(dest == null ? '' : dest); + if (raw === '') return true; + let u; + try { u = new URL(raw, SAFE_HREF_BASE); } catch { return false; } + return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:'; +} + // A markdown image whose src is a VIDEO file (`![](…/clip.mp4)`) renders a real