Skip to content

sitetile: escape link and image destinations before restoring them, and only emit safe schemes - #29

Merged
cverorg merged 6 commits into
mainfrom
fix/escape-link-destinations
Sep 8, 2026
Merged

sitetile: escape link and image destinations before restoring them, and only emit safe schemes#29
cverorg merged 6 commits into
mainfrom
fix/escape-link-destinations

Conversation

@cverorg

@cverorg cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member

Problem

inlineHtml() stashed link and image destinations before escaping the surrounding text and restored them verbatim afterwards. The stash matcher only looked for a ](…) shape, so bracketed fragments in ordinary prose could be restored unescaped into the output. Reported privately; no payload in this PR by design.

Fix

  • Stashed destinations are escaped with the same escaper as everything else before they are restored.
  • New isSafeHref() parses the destination with new URL(dest, base) and allows only http:, https:, mailto:, and scheme-less / relative / fragment destinations; anything else renders as plain text instead of a link or image.
  • Applied to both link href and image src. No other stash/restore path exists in the file.

Tests

node packages/sitetile/site-core.test.js: 93/93 (5 new). A 20,000-input differential fuzz over [ ] ( ) < > " ' / \ scriptfound no output containing<scriptor anon*=` attribute, and every changed output is strictly more escaped than before with no content lost.

Downstream

reef vendors this file byte-for-byte; the vendor copy will be updated after this merges.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-1 review: fix first — P1-1, P1-2, P2-1. The escaping change itself is sound (60k grammar-directed differential fuzz: 2,536 unsafe outputs closed, 0 introduced; it also closes an entity-encoded scheme case the URL check alone would miss).

  • P1-1 (pre-existing, same class): the CTA / link-button emitters (ctaHtml, ctaButtonsHtml, linkButtonsHtml) still put destinations into href with attribute escaping only and no scheme check; Cta.astro and Hero.astro call the same exports. Route them through the same safe-href check.
  • P1-2 (regression): every scheme outside http/https/mailto is now silently dropped — tel:, sms:, ftp: links and data: images render as bare text with no warning anywhere. The fixtures contain none of these, which is why 93/93 stayed green.
  • P2-1 (regression): destinations that already carry entities are double-escaped (&amp;&amp;amp;), so the browser requests the wrong URL; affects recast (HTML→Markdown) content.
  • P2-2: ![[wikilink]] embeds bypass the check entirely. P3: quote handling now rests on an untested convention; cross-origin destinations classified as internal.

Fix round in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-2 fix pushed (1103cc3): every href=/src= emitter in the file now goes through the shared safe-destination check (CTA/link buttons, grid/gallery/carousel cells, people, collection, tagcloud, sidebar nav/logo, and imgTag() itself); allowlist widened to http/https/mailto/tel/sms/ftp/ftps plus relative/fragment, images additionally data:image/{png,jpeg,jpg,gif,webp,avif};base64 only; destinations are entity-decoded once before validation and escaped once on restore (no double escaping); ![[wikilink]] embeds go through the same check; an opt-in diagnostics queue (takeDropWarnings()) records dropped destinations by page and scheme. 109/109. Round-1 fuzz harness re-run unmodified: 0 unsafe introduced, all 2,597 pre-existing unsafe outputs closed, all 7,399 double-escape cases resolved. Round 2 review in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-2 review (opus): fix first — R2-P1-1, R2-P2-1. The round-1 fix holds (extended sweep over renderSiteToHtml: 19 coral shapes × 2 layouts × 26² destination pairs = 25,688 renders, 18,616 fixed, 0 introduced) and the residual is one line.

  • R2-P1-1 site-core.js:1531 gates it.href on learnOk, which validates it.learn — a different field — so it.href is never checked, and takeDropWarnings() reports the page clean. Only triggers when the other field is safe, so a spot-check with bad values in both slots passes.
  • R2-P2-1 (regression) decodeEntitiesOnce calls String.fromCodePoint unguarded; an out-of-range numeric entity throws out of renderSiteToHtml and kills the whole page render. main renders it fine.
  • P3: //evil classifies as internal while \\evil classifies as external — two spellings of one destination, two answers; no Astro component imports the safe-href helper (production components bind href={c.href} ungated — pre-existing, out of this diff, but the same policy should apply there).
  • Design note for the record: the embed coral passes raw HTML verbatim by design (the documented lane for raw HTML), so this PR is defence-in-depth for owner-authored Markdown, not a trust boundary — that is fine, and it is why the fix must be cheap and complete rather than clever.

Fix round in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-3 fix pushed (1a779f4): the collection GitHub anchor is gated on its own destination (only mis-bound gate of five); malformed numeric entities degrade to the original text instead of throwing; \\evil, /\evil, //evil all classify as external; safeHref/safeSrc exported and wired into every Astro component that binds a destination from page data (coral sections, NavNode, SiteLayout header/footer/sidebar/age-gate/breadcrumb/language links, blog views) — Embed.astro untouched by design; astro build + smoke 121/121 before and after, and a local fixture with bad-scheme destinations across every coral yields 0 occurrences in the built HTML. 116/116; round-2 harnesses unchanged: residual unsafe 0 (was 180), 0 introduced. Round 3 review in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-3 review (opus): fix first — R3-P2-1, R3-P2-2. Round-2 items are closed on rounds 1–2's own harnesses (0 introduced; residual 0 after classifying embed by design and allowed raster data:; 27 hostile entity inputs, 0 throws; 116/116 and all 31 sitetile test files green; linkKind's backslash change moves 0 of 1,098 real destinations harvested from the repo; null degrades to <span> with 0 unbalanced tags across 26 built pages).

  • R3-P2-1 Two author-controlled destinations still reach live sinks in the Astro layer: the form coral's action (%% sitetile: form action=… %% builds to <form action="…"> unchecked — no sweep ever rendered a form because site-core has no <form> emitter) and header-actions-cart-href, which flows into window.location.href = href in header-actions-cart.js. Both were missed because the sweep grepped href=|src= literally; gate at the declaration point.
  • R3-P2-2 The whole Astro half is invisible to every gate: replacing safeHref/safeSrc with the identity in all 13 edited .astro files keeps astro smoke 121/121 and all test files green. The lane built the fixture that would catch this, then deleted it — it belongs in the smoke build as an assertion.
  • P3: <link rel=icon>/apple-touch-icon/fonts: stylesheet hrefs ungated (iconHrefs(meta) reads the source, not the gated variable); drop-warning queue amplified 2–4× by repeated safeHref() calls; site-core↔Astro divergence on entity-bearing destinations; &#0; still emits a raw NUL.

Fix round in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-4 fix pushed (e17cbff): form action, header cart href, favicon/apple-touch-icon and fonts stylesheet hrefs, and a previously unaudited hero bg= reaching a live CSS url() are gated at the declaration point; a scheme-check fixture site exercises every coral, and the smoke build scans the built HTML/JS for disallowed schemes in href/src/action/srcset and inline assignments with 19 assertions that fail in both directions (identity-swap scratch build: FAIL 16; baseline 138/138). Repeated calls deduplicated; C0 controls in numeric entities rejected; site-core emitters decode once like the Astro side. 117/117, all 31 test files green, harnesses unchanged: 0 introduced, residual 0. Round 4 review in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-4 review (opus): fix first — R4-P1-1, R4-P2-1. Round 3's items are closed and reproduced (identity swap FAIL 16; form-gate-only removal → exactly 3 red; hero bg= gate-only removal → exactly 1 red — which also shows the global scan does not cover style="…url(…)"); 31/31 suites, harnesses unchanged, 0 introduced, residual 0.

  • R4-P1-1 Three ordinary author fields reach a live href through helpers: a post's permalink:, a site's blog-url-pattern: and blog-path: (blogBase) — via postUrl() on the blog index, term archives, sibling prev/next, "Keep reading", "Recent posts", rss.xml, sitemap.xml, and the archive island's a.href. Same shape as R2-P1-1; missed because the sweep enumerated sink classes, not bindings whose value comes from a helper (href={postUrl(p, meta)} reads as an internal route until you open the helper).
  • R4-P2-1 disallowedSchemeHits() runs on one built page and the hostile fixture is one page, so the whole blog family is invisible to the gate.
  • P3: share-image:og:image ungated; a blog card's featured image accepts the SVG data: URI the body renderer refuses; imgTag missed by the decode-once parity; the scan sees 13 of 34 sink shapes; fixture covers 8 of 14 corals; bg= still injects arbitrary CSS declarations.

Fix round in progress.

@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round-5 fix pushed (5b43135): postUrl()/blogBase() and the same-shape categoryBase()/tagBase()/authorBase() validate their final value and degrade to the historic default; 12 route files read the gated helpers; sitemap.mjs has its own minimal scheme check; post featured image and site share-image/og-image gated via safeSrc; the scan now covers every built page plus rss/sitemap/search-index/reef-posts and handles srcset candidates, style=url(), <meta content> and obfuscated schemes (22/34 shapes, 0 false positives); a second isolated build carries a hostile blog fixture; hero bg= emits a quoted, CSS-escaped url(); fixtures 14/14 corals. Two-arm proof: identity swap → 28 red; four single-gate removals → exactly their own assertions red. 117/117, 32 suites, astro smoke 160/160, harnesses 0 introduced / 0 residual. Round 5 review in progress.

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.
…ations

Round 2 of the destination-escaping fix (round-1 review findings P1-1, P1-2,
P2-1, P2-2):

- P1-1: route every href/src emitter through the shared safe-destination
  check, not just inlineHtml's own link/image regexes. Covers ctaHtml,
  ctaButtonsHtml, linkButtonsHtml (author cta=/button= params and body
  link-buttons — reachable from live Cta.astro/Hero.astro), plus every other
  href/src emitter found by grepping the file: grid/gallery/carousel cells,
  the grid overlay/action pattern, people name+link row, collection head
  link and item GitHub/learn links, tagcloud tags, sidebar nav items and the
  sidebar logo image, and imgTag itself (heroParts' multi-image extraction
  and firstImage-derived figures called it directly with no check at all).
  A disallowed destination now degrades to plain text/a non-live element
  everywhere, never a live link or image.

- P1-2: widen the href allowlist to http(s), mailto, tel, sms, ftp(s), and
  scheme-less/relative/fragment destinations. Images additionally allow
  data: for a fixed set of raster MIME types only (png/jpeg/jpg/gif/webp/
  avif) — never svg+xml, and never data: on a plain href. A disallowed
  destination no longer disappears silently: it renders as plain text, and
  a new opt-in diagnostics queue (takeDropWarnings) records the page and
  scheme for whoever calls it — additive, no existing export's shape or
  behavior changes for callers that don't use it.

- P2-1: decode HTML entities in a destination exactly once before either
  validating its scheme or escaping it, instead of escaping an already-
  escaped destination a second time. Fixes both the double-escape on
  ordinary entity-bearing destinations (e.g. an imported `&amp;` in a query
  string) and strengthens the scheme check itself against an
  entity-obfuscated scheme.

- P2-2: `![[wikilink]]` embeds now go through the same scheme check as the
  sibling markdown-image path.

Tests: full-string assertions for each of the above, the widened allowlist,
the entity-decode-once behavior, the wikilink guard, and the drop-warning
queue. 109/109 pass (was 93/93 before this commit).

Differential fuzz (round-1's own grammar-directed harness and ruler,
60,000 inputs, same seed): 0 unsafe introduced, all 2,597 pre-existing
unsafe outputs still closed (including the 61 that were only closed on
the non-wikilink paths before this commit), all 7,399 double-escape
regressions from round 1 resolved, and the widened allowlist recovers
destinations round 1 had dropped where the fuzz grammar happens to
construct one of the newly-allowed schemes.
…, and cover Astro consumers

Round 2 review follow-up:
- collection's GitHub item anchor validated the wrong field (it.learn instead
  of it.href), letting a disallowed href go live whenever the other field was
  safe; it now gates on isSafeHref(it.href) and records the drop.
- decodeEntitiesOnce no longer throws RangeError on an out-of-range or
  malformed numeric entity; it degrades to the raw text and the whole
  decoder falls back to the un-decoded string on any exception.
- linkKind normalises backslashes to slashes before classifying a
  destination, so a protocol-relative reference spelled with backslashes
  classifies the same way regardless of which slash/backslash mix is used.
- exported safeHref/safeSrc so the Astro rendering layer can share the same
  allowlist policy; wired them into every coral section component and layout
  chrome binding that builds href=/src= from author-controlled destinations
  (frontmatter and page content), which previously had no scheme check of
  their own.

Verified with the existing site-core.test.js suite (7 new cases), the round
2 exhaustive sweep and grammar-directed fuzz harnesses re-run against this
change (0 introduced, 0 residual unsafe emissions outside policy/by-design),
and a full astro build + smoke suite (121/121).
…sert it in the smoke build

Form action, header cart href, favicon/apple-touch-icon and fonts stylesheet hrefs, and the hero background url() are now validated where the value is read from page or site data, so a template cannot forget the check. A scheme-check fixture site exercises every coral with a disallowed/safe pair and the smoke build scans the built HTML and JS for disallowed schemes in href, src, action, srcset and inline assignments, failing in both directions (a scratch build with the check replaced by the identity fails 16 assertions). Repeated safe-destination calls in templates are deduplicated so the drop diagnostics are not amplified; control characters in numeric entities are rejected; site-core's own emitters decode a destination once before escaping, matching the Astro side.
…e scan's blind spot, and quote the hero bg() CSS string

Round 4 review found that postUrl()/blogBase() returned an author-controlled
permalink/blog-url-pattern/blog-path verbatim, reaching a live href on the blog
index, both term archives, every sibling post's prev/next nav, "Keep reading"/
"Recent posts", rss.xml, sitemap.xml, and the archive island's client corpus --
and that the smoke's disallowedSchemeHits() scan was scoped to one page, so it
could not see any of it.

- gate postUrl()/blogBase() once, at their return, on the same allowlist every
  other destination in this renderer already uses; a disallowed value degrades
  to the historic default rather than the raw scheme
- extend the same gate to categoryBase()/tagBase() (blog-category-base/
  blog-tag-base) and authorBase() (blog-author-base, the same shape, found
  while auditing every pageHref() in the term-archive family) -- every route
  file that read one of these fields directly now goes through the gated
  helper instead
- gate a post's featured image (parsePost's own `image` field) and the
  site-wide share-image/og-image meta tags the same way
- run disallowedSchemeHits() over every built page plus rss.xml/sitemap.xml/
  search-index.json/reef-posts.json, not just one fixture page; widen the scan
  itself to catch a second matching attribute per tag, srcset candidates,
  style="...url(...)", <meta content=...>, and entity/whitespace-obfuscated
  schemes
- add an isolated second astro build (a throwaway sibling of astro/, never
  committed) carrying a hostile permalink/blog-url-pattern/blog-path/category-
  base/tag-base fixture, since these are site-level fields a single-page
  fixture can't exercise without poisoning ~150 unrelated assertions
- extend the scheme-check fixture to the remaining coral types (cta, social,
  timeline, faq, prose, embed), reaching coverage of all known section types
- quote and CSS-string-escape the hero bg= destination before it reaches
  url(...); an otherwise-allowed relative path could previously close the CSS
  declaration via a stray ) or ; and open new ones in the same inline style
- bring imgTag's src emission in line with every other already-gated emitter
  (decode once, then escape) so the reference renderer and the Astro layer
  agree on an entity-bearing image destination

Two-arm proof (throwaway scratch copies, never written back): a full identity
swap of safeHref/safeSrc turns 28 assertions red; each single-gate removal
(postUrl's permalink gate, the hero bg quoting, categoryBase/tagBase, and
Form.astro's action gate) turns red only the assertions that trace to it.
site-core.test.js (117), all sitetile unit suites (32/32) and the astro smoke
(160/160) are green; the round 1-3 fuzz/sweep harnesses rerun unchanged with
0 introduced and 0 residual.
@cverorg
cverorg force-pushed the fix/escape-link-destinations branch from 5b43135 to 9ade1d1 Compare September 8, 2026 22:26
@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main after #28 merged (head 9ade1d1): three conflicts in inlineHtml() resolved keeping both the comment scan (#28) and the destination handling (#29) composed; two interaction tests added (a comment-shaped run inside a destination is never stripped and never a live scheme; a destination inside a real comment is erased with it, no drop warning). 179/179, 32 suites, astro smoke 160/160, round-1/2 harnesses 0 introduced / 0 residual, #28's 400k fuzz 0 offenders on the rebased build. Round 5 review in progress.

@cverorg
cverorg merged commit 64f542c into main Sep 8, 2026
2 checks passed
@cverorg
cverorg deleted the fix/escape-link-destinations branch September 8, 2026 22:51
@cverorg

cverorg commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Merged after round 5 (verdict: mergeable). Remaining follow-ups, all P3 and all about the smoke ruler rather than the gates themselves — every sink is gated and unit-tested:

  1. The rss.xml/sitemap.xml arm of siteWideSchemeHits() (smoke-build.mjs ~361) is ^-anchored, but feed text nodes are always origin + path, so the arm structurally never fires.
  2. The style="…url(…)" arm matches a literal quote, while cssUrlString now emits an entity-escaped quote. With the hero gate removed the disallowed scheme lands on a built page and the scan reports 0.
  3. A raw > inside an earlier attribute value truncates the tag before the scan reads its href. Reachable from ordinary page markdown via the <small>/<br> re-emit path, so that href is never tested.
  4. video poster= is emitted (site-core.js ~765, from the alt slot) via attrq only; the prefix test keeps it safe today but nothing in the scan or tests watches it.
  5. blog-author-base has gate coverage but no build coverage: removing the authorBase gate leaves the smoke at 160/160 and only the unit test goes red.
  6. takeDropWarnings() has no consumer outside tests, so a silently rewritten permalink: is never surfaced to the site owner at build time.

Separate correctness issue filed: a safe absolute permalink: fails the build with NoMatchingStaticPathFound and the sitemap concatenates origin + absolute URL. Pre-existing, unchanged by this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant