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:
<a class="cm-popup__btn" href="${profileUrl}">
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
<a href="https://doxa.life/research/x" onerror=alert(1) data-x="">
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.
This commit is contained in:
parent
496cd88091
commit
11cadd9ac4
5 changed files with 123 additions and 3 deletions
113
internal/check-popup-href-escaped.mjs
Normal file
113
internal/check-popup-href-escaped.mjs
Normal file
|
|
@ -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:
|
||||
*
|
||||
* <a class="cm-popup__btn" href="${profileUrl}"> <- 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="`:
|
||||
* <a href="https://doxa.life/research/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:<uuid>, 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, '<self-test>')
|
||||
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', '`<a class="b" href="${profileUrl}">x</a>`', true)
|
||||
t('escaped with esc()', '`<a class="b" href="${esc(profileUrl)}">x</a>`', false)
|
||||
t('encodeURI', '`<img src="${encodeURIComponent(u)}">`', false)
|
||||
t('a SCREAMING_CASE constant', '`<link rel="stylesheet" href="${MAPBOX_CSS}">`', false)
|
||||
t('querySelector, not built HTML', 'root.querySelector(`link[href="${cssUrl}"]`)', false)
|
||||
t('src on an img', '`<img class="p" src="${p.imageUrl}" alt="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.`)
|
||||
|
|
@ -443,8 +443,8 @@ function popupHtml(p) {
|
|||
${rows.length ? `<dl class="cm-popup__stats">${rows.join('')}</dl>` : ''}
|
||||
${p.description ? `<p class="cm-popup__desc">${esc(p.description)}</p>` : ''}
|
||||
<div class="cm-popup__actions">
|
||||
<a class="cm-popup__btn cm-popup__btn--primary" href="${prayUrl}">${esc(t('Pray'))}</a>
|
||||
<a class="cm-popup__btn" href="${profileUrl}">${esc(t('Full Profile'))}</a>
|
||||
<a class="cm-popup__btn cm-popup__btn--primary" href="${esc(prayUrl)}">${esc(t('Pray'))}</a>
|
||||
<a class="cm-popup__btn" href="${esc(profileUrl)}">${esc(t('Full Profile'))}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<a href="${p.href}" target="_blank" rel="noopener noreferrer"`
|
||||
+ (p.title ? ` title="${p.title}"` : '')
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ const body = sections.map(({ name, list, total }) => `
|
|||
${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 `<li><a href="${rel}">${esc(p.title)}</a>
|
||||
<span class="m">${p.count} UPG · ${existsSync(f) ? kb(f) : 0} KB</span></li>`;
|
||||
}).join('')}
|
||||
|
|
|
|||
|
|
@ -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 <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
|
||||
"check:ported-globals-identical": "the DX Life base/_globals.scss rules that shadow-root components used to copy now come from one module (library/styles/portedGlobals.js), and the CSS each component actually INJECTS must be byte-identical to the pre-refactor baseline: it runs each component's setup with stubbed imports and hashes the real useShadowStyles argument, not the source text. Deduplication with a proof that no pixel can change. Node only, no browser.",
|
||||
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
|
||||
"check:popup-href-escaped": "a URL interpolated into an HTML string is escaped before it lands in href= or src=. Written for RegionCountryMap's popup, which escaped every value except its two link URLs -- both built from joinBase(hostAttribute, apiSlug), so a quote in either escaped the attribute. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe; anything else needs an href-escaped-ok: reason. 6 self-tests. Source-only, headless.",
|
||||
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",
|
||||
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue