PPLR data maps: escape every value that goes into popup and search-suggestion HTML

The critic fact-check (CRITIC-3) found PPLR's popups interpolating data raw into setHTML():
popupForPG (pins, coverage, rosetta), the country popup in coverage, the local popup builders in
religion, rop-tree and language-families, and the search box's suggestion renderer. That last one
renders place names that come from the Mapbox geocoding API, so it is external data. All of them now
go through escHtml(); the dash placeholder for empty values is unchanged.
Headless proof: a hostile-input test (an <img onerror> name, a <script> country, quotes and
ampersands) 5/5: no live tag or handler survives and the text shows as text. BUNDLE=pplr-data-maps
builds. Plugin-only.
This commit is contained in:
Doxa Maps 2026-09-26 01:54:02 +00:00
parent 8b6f37f663
commit 05346b1e53
6 changed files with 23 additions and 17 deletions

View file

@ -20,6 +20,7 @@
*/
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { escHtml } from '../utils/pplrMap.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { useInstance } from '../composables/usePplrInstance.js'
@ -108,13 +109,13 @@ function renderSuggestion(item) {
const local = item?.properties?.__pplr_local
const dotColor = local?.color || ''
const dot = dotColor
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${dotColor};margin-right:8px;vertical-align:middle"></span>`
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${escHtml(dotColor)};margin-right:8px;vertical-align:middle"></span>`
: ''
const tag = kind
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${kind}</span>`
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${escHtml(kind)}</span>`
: ''
return `<div class="mapboxgl-ctrl-geocoder--suggestion-title" style="display:flex;align-items:center;gap:4px;width:100%">
${dot}<span style="flex:1">${item.place_name}</span>${tag}
${dot}<span style="flex:1">${escHtml(item.place_name)}</span>${tag}
</div>`
}

View file

@ -325,10 +325,15 @@ export async function addPGPoints(map, {
}
/** Standard popup HTML for a PG feature (PG-in-country grain). */
// Every value interpolated into popup / suggestion HTML goes through this (CRITIC-3, the critic
// fact-check). The data is PPLR's baked JSON and, for place search, the Mapbox geocoding API, and
// setHTML() parses whatever it is handed; escaping costs nothing and closes the door.
export const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
export function popupForPG(p, extraRows = '') {
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : v)
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : escHtml(v))
return `<div class="pplr-popup">
<h3>${p.name || '(no name)'}</h3>
<h3>${p.name ? escHtml(p.name) : '(no name)'}</h3>
<table>
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>

View file

@ -12,7 +12,7 @@ import { usePplrT } from '../composables/usePplrT.js'
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, loadOnce, LOCALES, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import { addCountryPolygons, addPGPoints, loadOnce, LOCALES, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
@ -101,9 +101,9 @@ onMounted(async () => {
map.value.on('click', 'choropleth', e => {
const p = e.features[0].properties
const html = `<div class="pplr-popup">
<h3>${p.CtryNm || p.ctryNmDisp || '—'}<small>${p.GENCalpha3 || p.ISOalpha2 || '?'}</small></h3>
<h3>${escHtml(p.CtryNm || p.ctryNmDisp || '—')}<small>${escHtml(p.GENCalpha3 || p.ISOalpha2 || '?')}</small></h3>
<table>
<tr><td>PG count</td><td><b>${p.pgCount || 0}</b></td></tr>
<tr><td>PG count</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>Avg locales filled</td><td>
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>

View file

@ -16,7 +16,7 @@ import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
@ -410,10 +410,10 @@ function setupLayers() {
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
const color = familyColor(p.fam || 'Other')
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>

View file

@ -12,7 +12,7 @@ import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import FlatLegend from '../components/FlatLegend.vue'
@ -231,10 +231,10 @@ const detailColor = computed(() => {
})
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
const grp = RELIGION_GROUPS.find(g => g.key === p.rg)
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>

View file

@ -12,7 +12,7 @@ import { inject, onMounted, onBeforeUnmount, ref, computed, shallowRef, watch }
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
@ -376,9 +376,9 @@ function setupLayers() {
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>