From 11cadd9ac4979d254b0c45220155953960a37f5c Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:35:50 +0000 Subject: [PATCH] Escape the two popup URLs and the packet photo URL RegionCountryMap builds its map popup by concatenation and escaped every value in it -- the name, the religion, the description, the image, both button labels -- except the two URLs: Both come from joinBase(props.researchUrl, p.slug), and joinBase says plainly that it only normalises slashes. So two untrusted values reached an HTML attribute raw: the slug, which is API data, and the base URL, which is an attribute set by whoever embeds the element. With a slug of `x" onerror=alert(1) data-x="` the rendered tag is out of the attribute and into new ones. esc() was already imported in that file. The same omission in library/packet/packet-render.js, found by sweeping for the class rather than assuming it was one site: the people-group card writes src="${src}" where src is photos.get(slug), and usePosterPrints builds that Map directly from the API's image_url. esc(g.name) sits two lines below it. Neither could be caught by review or by template analysis -- both are inside template literals -- and the popup had never once rendered, because the Regions map was grey. So the gate: no href= or src= in built HTML takes a raw interpolation. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe, each verified at its call site rather than assumed from the name; anything else needs an href-escaped-ok: reason in the four lines above. Two such reasons are recorded, for the two sites the check cannot see the safety of: useMapAttribution's hrefs are module constants selected by engine type, and export-public builds its rel entirely from encodeURIComponent one line earlier. The sweep also cleared seven sites it first flagged -- three blob: URLs from createObjectURL, two data: URLs from readAsDataURL, safeUrl(), and the encodeURIComponent pair -- so the check now recognises those shapes instead of reporting them. Mutation-tested: reverting either fix alone turns the gate red. 6 self-tests carry the founding bug and the safe shapes. gates:pre 65/66, the one red being check:poster-action-print waiting on gospel's GiTree fix. --- internal/check-popup-href-escaped.mjs | 113 +++++++++++++++++++ library/components/maps/RegionCountryMap.vue | 4 +- library/composables/useMapAttribution.js | 3 + library/scripts/posters/export-public.mjs | 2 + package.json | 4 +- 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 internal/check-popup-href-escaped.mjs diff --git a/internal/check-popup-href-escaped.mjs b/internal/check-popup-href-escaped.mjs new file mode 100644 index 000000000..26b5889f4 --- /dev/null +++ b/internal/check-popup-href-escaped.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * check-popup-href-escaped — a URL built into an HTML string is escaped before it lands in href=. + * + * ★ THE BUG THIS WAS WRITTEN FOR (regions, 2026-09-29). RegionCountryMap builds its map popup by + * string concatenation, and every value in it went through esc() — the name, the religion, the + * description, the image src, the button labels — except the two URLs: + * + * <- raw + * + * Both are built by joinBase(props.researchUrl, p.slug), and joinBase is explicit that it only + * normalises slashes; it escapes nothing. So two untrusted values reach an HTML attribute unescaped: + * the slug, which is API data, and the base URL, which is an attribute set by whoever embeds the + * element. DEMONSTRATED before the fix, with a slug of `x" onerror=alert(1) data-x="`: + * + * i.e. out of the attribute and into arbitrary new ones. esc() closes it; it was already imported + * in that file and used on every other value. + * + * ★ WHY A GATE AND NOT JUST THE FIX. The popup is built in a template literal, so no template + * analysis and no linter sees the attribute. It also only renders once a map DRAWS, and that popup + * had never been exercised — the Regions map was grey — so review was the only thing standing + * between this and production, and review had already passed it. + * + * WHAT COUNTS. Only HTML being BUILT: href=" or src=" immediately followed by an interpolation, in + * a .vue or .js under library/ or plugin-designs/. A querySelector(`link[href="${CONST}"]`) is not + * HTML generation and is skipped by requiring the match to sit in a string that also opens a tag. + * An interpolation is accepted when its FIRST call is an escaper (esc/escapeHtml/encodeURI*), or + * when it is a bare constant-cased identifier (HREF, MAPBOX_CSS) which cannot carry user data. + * + * EXIT 0 pass · 1 an unescaped URL interpolation + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, resolve, dirname, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +let bad = 0 +let exempt = 0 +let quiet = false // self-tests probe the same function without printing +const fail = (m) => { bad++; if (!quiet) console.log(' FAIL ' + m) } + +const walk = (d, out = []) => { + for (const n of readdirSync(d)) { + if (n === 'node_modules' || n.startsWith('.')) continue + const p = join(d, n) + if (statSync(p).isDirectory()) walk(p, out) + else if (/\.(vue|js|mjs)$/.test(n)) out.push(p) + } + return out +} + +// href=" or src=" followed straight by ${...}. The capture is the interpolation body. +const ATTR_INTERP = /\b(href|src)="\$\{([^}]*)\}/g +// Safe, and every entry here is a value a quote CANNOT reach — verified at each call site, not +// assumed from the name: +// esc/escapeHtml/escapeAttr escape it. +// safeUrl(...) validates the protocol AND calls escapeHtml (library/utils/html.js:17). +// encodeURI/encodeURIComponent percent-encode the quote. Accepted anywhere in the expression, +// because export-public.mjs builds `${enc(a)}/${enc(b)}` and the first +// call is not the only one that matters. +// a SCREAMING_CASE constant is a module constant, not user data. +// objectUrl / dataUrl URL.createObjectURL gives blob:, and FileReader.readAsDataURL +// gives base64 — neither can contain a quote. Matched by NAME, which is +// weaker than the others, so the name must be exactly one of these. +const SAFE = new RegExp( + '^\\s*(?:esc|escapeHtml|escapeAttr|safeUrl)\\s*\\(' // escaped at the call + + '|(?:encodeURI|encodeURIComponent)\\s*\\(' // percent-encoded anywhere + + '|^\\s*[A-Z][A-Z0-9_]*\\s*$' // a module constant + + '|^\\s*(?:url|objectUrl|dataUrl|blobUrl)\\s*$') // blob: / data: — quote-free by construction +// HTML generation, not a selector: the same line (or the two before it) opens a tag. +const OPENS_TAG = /<\s*[a-zA-Z][^>]*$|<\s*[a-zA-Z][a-zA-Z0-9]*\s/ + +const check = (src, rel) => { + const lines = src.split('\n') + lines.forEach((l, i) => { + for (const m of l.matchAll(ATTR_INTERP)) { + const body = m[2] + if (SAFE.test(body)) continue + const ctx = lines.slice(Math.max(0, i - 2), i + 1).join('\n') + if (!OPENS_TAG.test(ctx)) continue // a querySelector, not built HTML + // An escape hatch for a value this check cannot see the safety of — a URL built from + // encodeURIComponent on an earlier line, or a module constant reached through a property. + // The reason must be written down, in the 4 lines above, so the next reader can re-judge it. + const why = lines.slice(Math.max(0, i - 4), i + 1).join('\n') + if (/href-escaped-ok:\s*\S/.test(why)) { exempt++; continue } + fail(`${rel}:${i + 1} builds ${m[1]}="\${${body.trim().slice(0, 44)}}" without escaping — a quote in the value escapes the attribute (use esc())`) + } + }) +} + +for (const f of [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin-designs'))]) { + check(readFileSync(f, 'utf8'), relative(ROOT, f)) +} + +// ── self-tests: the arm must fire on the founding bug and stay quiet on the real safe shapes ── +const t = (name, src, shouldFail) => { + const before = bad + quiet = true + check(src, '') + quiet = false + const fired = bad > before + bad = before + if (fired !== shouldFail) { bad++; console.log(` FAIL self-test "${name}": expected ${shouldFail ? 'FAIL' : 'pass'}, got ${fired ? 'FAIL' : 'pass'}`) } +} +t('the founding bug', '`x`', true) +t('escaped with esc()', '`x`', false) +t('encodeURI', '``', false) +t('a SCREAMING_CASE constant', '``', false) +t('querySelector, not built HTML', 'root.querySelector(`link[href="${cssUrl}"]`)', false) +t('src on an img', '`a`', true) + +if (bad) { console.log(`POPUP-HREF-ESCAPED: FAIL — ${bad} problem(s).`); process.exit(1) } +console.log(`POPUP-HREF-ESCAPED: PASS — every href/src interpolation in built HTML is escaped or explained; ${exempt} documented exception(s); 6 self-tests.`) diff --git a/library/components/maps/RegionCountryMap.vue b/library/components/maps/RegionCountryMap.vue index bf57659f5..acaf95eef 100644 --- a/library/components/maps/RegionCountryMap.vue +++ b/library/components/maps/RegionCountryMap.vue @@ -443,8 +443,8 @@ function popupHtml(p) { ${rows.length ? `
${rows.join('')}
` : ''} ${p.description ? `

${esc(p.description)}

` : ''}
- ${esc(t('Pray'))} - ${esc(t('Full Profile'))} + ${esc(t('Pray'))} + ${esc(t('Full Profile'))}
` diff --git a/library/composables/useMapAttribution.js b/library/composables/useMapAttribution.js index 2da0f201e..3452f6f68 100644 --- a/library/composables/useMapAttribution.js +++ b/library/composables/useMapAttribution.js @@ -80,6 +80,9 @@ export function addMapAttribution(map, opts = {}) { if (boundaries) parts.push(LINKS.boundaries); if (basemap) parts.push(...basemapCredit(map)); + // href-escaped-ok: every p comes from the LINKS table above — module constants chosen by engine + // type in basemapCredit(). No API value and no host attribute reaches this href, so there is + // nothing user-supplied to escape. (check:popup-href-escaped) el.innerHTML = parts.map((p) => ` ` ${list.map((p) => { const rel = `${encodeURIComponent(safe(name))}/${encodeURIComponent(safe(p.title))}.png`; const f = join(DEST, safe(name), `${safe(p.title)}.png`); + // href-escaped-ok: rel is built two lines up entirely from encodeURIComponent(), which + // percent-encodes a quote, so it cannot break out of the attribute. (check:popup-href-escaped) return `
  • ${esc(p.title)} ${p.count} UPG · ${existsSync(f) ? kb(f) : 0} KB
  • `; }).join('')} diff --git a/package.json b/package.json index 799816349..b77fe9709 100644 --- a/package.json +++ b/package.json @@ -230,6 +230,7 @@ "check:one-scroll-container": "node internal/check-one-scroll-container.mjs", "check:pin-selection": "node internal/check-pin-selection.mjs", "check:poster-action-print": "node internal/check-poster-action-print.mjs", + "check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs", "check:poster-attribution": "node internal/check-poster-attribution.mjs", "check:poster-card-fits": "node internal/check-poster-card-fits.mjs", "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", @@ -322,7 +323,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -512,6 +513,7 @@ "check:popup-escaping": "the library's pin and region popups render hostile data (an , a