Map popups show data as text: a people-group name or country field can no longer run script
The library's pin and region popups (useMapEvents) interpolated data fields raw into the HTML handed to setHTML(); a name like <img src=x onerror=…> from an API or a partner's list would run in the host page (CRITIC-3). library/utils/html.js adds escapeHtml and safeUrl (http(s) only); every field of both popups goes through them. check:popup-escaping (headless, gates:post) mounts the real composable, renders both popups from hostile data and requires no markup, handler or javascript: URL to survive: it FAILs on the previous code (both popups) and PASSes here. check:cdn-pins and check:no-eval join gates:post too.
This commit is contained in:
parent
f14c397758
commit
91c060974e
4 changed files with 94 additions and 18 deletions
46
internal/check-popup-escaping.mjs
Normal file
46
internal/check-popup-escaping.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-popup-escaping — the library's map popups cannot be made to run script by the DATA. Headless.
|
||||
*
|
||||
* CRITIC-3 (2026-09-26): generatePinPopupContent and generateRegionPopupContent (useMapEvents.js)
|
||||
* interpolated people-group and country fields raw into HTML handed to setHTML(). A name or description
|
||||
* like `<img src=x onerror=…>` from an API or a partner's list would run in the host page.
|
||||
* Mounts the real composable in a tiny Vue app and renders both popups from hostile data.
|
||||
* ASSERTS no raw tag, event-handler attribute or javascript: URL survives; ordinary text still shows.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { createApp, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis.__INTLIFY_JIT_COMPILATION__ = true
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const { useMapEvents } = await import('../library/composables/useMapEvents.js')
|
||||
const popup = JSON.parse(readFileSync(resolve(ROOT, 'library/i18n/locales/en/popup.json'), 'utf8'))
|
||||
const EVIL = '<img src=x onerror="alert(1)"><script>alert(2)</script>'
|
||||
const pin = { name: 'Afar ' + EVIL, country: EVIL, status: EVIL, language: EVIL, languageFamily: EVIL, population: EVIL,
|
||||
description: EVIL, imageUrl: 'javascript:alert(3)' }
|
||||
let out = {}
|
||||
const app = createApp({ setup() {
|
||||
const ev = useMapEvents({ getNormalizedPeopleGroups: () => [{ countryIso: 'ETH', wagfRegionLabel: EVIL, wagfBlockLabel: EVIL }] })
|
||||
out.pin = ev.generatePinPopupContent(pin)
|
||||
out.region = ev.generateRegionPopupContent({ iso_3166_1_alpha_3: 'ETH', name_en: 'Ethiopia ' + EVIL })
|
||||
return () => h('div')
|
||||
} })
|
||||
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: { popup } } }))
|
||||
app.provide('uiStore', {})
|
||||
const { renderToString } = await import('vue/server-renderer')
|
||||
await renderToString(app)
|
||||
let bad = 0
|
||||
for (const [k, html] of Object.entries(out)) {
|
||||
const probs = []
|
||||
if (/<img src=x|<script>/i.test(html)) probs.push("a raw <img>/<script> tag from the data")
|
||||
if (/\son\w+\s*=\s*"alert/i.test(html)) probs.push('an event-handler attribute from the data')
|
||||
if (/javascript:/i.test(html)) probs.push('a javascript: URL')
|
||||
if (!/Afar|Ethiopia/.test(html)) probs.push('the ordinary text is gone')
|
||||
if (probs.length) bad++
|
||||
console.log(` ${probs.length ? 'FAIL' : 'ok '} ${k} popup${probs.length ? ': ' + probs.join('; ') : ': hostile data shown as text, no markup or script'}`)
|
||||
}
|
||||
if (bad) { console.log(`POPUP-ESCAPING: FAIL — ${bad} popup(s) render data as HTML.`); process.exit(1) }
|
||||
console.log('POPUP-ESCAPING: PASS — both library popups escape every data field and refuse non-http URLs.')
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
// Import Vue functions from NPM package
|
||||
import { ref, inject } from 'vue';
|
||||
import { escapeHtml as esc, safeUrl } from '../utils/html.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { engineFor } from './useMapProvider.js';
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ export function useMapEvents(options = {}) {
|
|||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background-color: ${avatarColor};
|
||||
background-color: ${esc(avatarColor)};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -98,7 +99,7 @@ export function useMapEvents(options = {}) {
|
|||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
">
|
||||
${firstLetter}
|
||||
${esc(firstLetter)}
|
||||
</div>
|
||||
|
||||
<!-- Name and Status -->
|
||||
|
|
@ -111,12 +112,12 @@ export function useMapEvents(options = {}) {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
">${properties.name}</h3>
|
||||
">${esc(properties.name)}</h3>
|
||||
<p style="
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #8e8e8e;
|
||||
">${properties.country || 'Unknown'} • ${properties.status || 'Unknown Status'}</p>
|
||||
">${esc(properties.country || 'Unknown')} • ${esc(properties.status || 'Unknown Status')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -131,30 +132,30 @@ export function useMapEvents(options = {}) {
|
|||
overflow: hidden;
|
||||
">
|
||||
<img
|
||||
src="${imageUrl}"
|
||||
alt="${properties.name}"
|
||||
src="${safeUrl(imageUrl, placeholderImage)}"
|
||||
alt="${esc(properties.name)}"
|
||||
style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
"
|
||||
onerror="this.onerror=null; this.src='${placeholderImage}'"
|
||||
onerror="this.onerror=null; this.src='${esc(placeholderImage)}'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div style="padding: 12px;">
|
||||
<p style="margin: 0 0 6px 0; font-size: 13px;">
|
||||
<strong>Language:</strong> ${properties.language || 'Unknown'}
|
||||
<strong>Language:</strong> ${esc(properties.language || 'Unknown')}
|
||||
</p>
|
||||
<p style="margin: 0 0 6px 0; font-size: 13px;">
|
||||
<strong>Family:</strong>
|
||||
<span style="background: ${familyColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
|
||||
${properties.languageFamily || 'Unknown'}
|
||||
<span style="background: ${esc(familyColor)}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
|
||||
${esc(properties.languageFamily || 'Unknown')}
|
||||
</span>
|
||||
</p>
|
||||
${properties.population ? `<p style="margin: 0 0 6px 0; font-size: 13px;"><strong>Population:</strong> ${properties.population}</p>` : ''}
|
||||
${properties.description ? `<p style="margin: 8px 0; font-size: 13px; color: #262626; line-height: 1.4;">${properties.description}</p>` : ''}
|
||||
${properties.population ? `<p style="margin: 0 0 6px 0; font-size: 13px;"><strong>Population:</strong> ${esc(properties.population)}</p>` : ''}
|
||||
${properties.description ? `<p style="margin: 8px 0; font-size: 13px; color: #262626; line-height: 1.4;">${esc(properties.description)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -187,19 +188,19 @@ export function useMapEvents(options = {}) {
|
|||
return `
|
||||
<div style="padding: 8px; min-width: 220px;">
|
||||
<h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600; color: #333;">
|
||||
${countryName}
|
||||
${esc(countryName)}
|
||||
</h3>
|
||||
<p style="margin: 4px 0; font-size: 14px;">
|
||||
<strong>${t('popup.country.wagfRegion')}:</strong> ${wagfRegion}
|
||||
<strong>${esc(t('popup.country.wagfRegion'))}:</strong> ${esc(wagfRegion)}
|
||||
</p>
|
||||
<p style="margin: 4px 0; font-size: 14px;">
|
||||
<strong>${t('popup.country.wagfBlock')}:</strong> ${wagfBlock}
|
||||
<strong>${esc(t('popup.country.wagfBlock'))}:</strong> ${esc(wagfBlock)}
|
||||
</p>
|
||||
<p style="margin: 4px 0; font-size: 14px;">
|
||||
<strong>${t('popup.country.country')}:</strong> ${countryName} (${iso3})
|
||||
<strong>${esc(t('popup.country.country'))}:</strong> ${esc(countryName)} (${esc(iso3)})
|
||||
</p>
|
||||
<p style="margin: 4px 0; font-size: 14px;">
|
||||
<strong>${t('popup.country.uupgCount')}:</strong> ${uupgCount}
|
||||
<strong>${esc(t('popup.country.uupgCount'))}:</strong> ${esc(uupgCount)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
23
library/utils/html.js
Normal file
23
library/utils/html.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
|
||||
*
|
||||
* Popups are built as HTML strings and handed to setHTML(), which parses them. A people-group name,
|
||||
* a description or a country label comes from DATA (an API or a partner's list), so interpolating it
|
||||
* raw lets a `<img onerror>` in the data run in the host page. Escape every data value; pass every URL
|
||||
* through safeUrl so a `javascript:` link cannot ride in on a data field.
|
||||
*/
|
||||
const MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' }
|
||||
|
||||
/** Text or attribute value → safe to interpolate into HTML (null/undefined → ''). */
|
||||
export function escapeHtml(v) {
|
||||
return v == null ? '' : String(v).replace(/[&<>"'`]/g, (c) => MAP[c])
|
||||
}
|
||||
|
||||
/** An http(s) URL, escaped for an attribute; anything else (javascript:, data:, garbage) → fallback. */
|
||||
export function safeUrl(u, fallback = '') {
|
||||
try {
|
||||
const url = new URL(String(u), typeof location !== 'undefined' ? location.href : 'https://example.invalid/')
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') return escapeHtml(url.href)
|
||||
} catch { /* not a URL */ }
|
||||
return escapeHtml(fallback)
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
"check:card-deck": "node internal/check-card-deck.mjs",
|
||||
"check:catalog-currency": "node internal/check-catalog-currency.mjs",
|
||||
"check:catalog-identity": "node internal/check-catalog-identity.mjs",
|
||||
"check:cdn-pins": "node internal/check-cdn-pins.mjs",
|
||||
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
|
||||
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs",
|
||||
"check:composite": "node internal/check-composite-freshness.mjs",
|
||||
|
|
@ -125,6 +126,7 @@
|
|||
"check:no-codename": "node internal/check-no-internal-codename.mjs",
|
||||
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
|
||||
"check:no-eval": "node internal/check-no-eval.mjs",
|
||||
"check:no-free-identifiers": "node plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs",
|
||||
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
|
||||
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
|
||||
|
|
@ -135,6 +137,7 @@
|
|||
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs",
|
||||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"check:popup-escaping": "node internal/check-popup-escaping.mjs",
|
||||
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
|
||||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
|
|
@ -194,7 +197,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- 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: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",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- 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: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",
|
||||
"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",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"lists": "node library/scripts/people-groups/build-lists.mjs",
|
||||
|
|
@ -251,6 +254,7 @@
|
|||
"check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
|
||||
"check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
|
||||
"check:catalog-identity": "proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
|
||||
"check:cdn-pins": "every runtime third-party script/stylesheet in shipped code is an exact version listed in library/constants/cdn.js with its SRI hash (no floating ranges). --online re-fetches each and compares the hash.",
|
||||
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
|
||||
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first.",
|
||||
"check:composite": "a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
|
||||
|
|
@ -315,12 +319,14 @@
|
|||
"check:no-abs-paths": "no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
|
||||
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
|
||||
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
|
||||
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
|
||||
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
|
||||
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
|
||||
"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: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.",
|
||||
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue