From 91c060974e81628e289463fe1717d971e59cdb6a Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 05:49:18 +0000 Subject: [PATCH] Map popups show data as text: a people-group name or country field can no longer run script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library's pin and region popups (useMapEvents) interpolated data fields raw into the HTML handed to setHTML(); a name like 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. --- internal/check-popup-escaping.mjs | 46 +++++++++++++++++++++++++++++ library/composables/useMapEvents.js | 35 +++++++++++----------- library/utils/html.js | 23 +++++++++++++++ package.json | 8 ++++- 4 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 internal/check-popup-escaping.mjs create mode 100644 library/utils/html.js diff --git a/internal/check-popup-escaping.mjs b/internal/check-popup-escaping.mjs new file mode 100644 index 000000000..5593db580 --- /dev/null +++ b/internal/check-popup-escaping.mjs @@ -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 `` 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 = '' +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 (//i.test(html)) probs.push("a raw /