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:
parent
8b6f37f663
commit
05346b1e53
6 changed files with 23 additions and 17 deletions
|
|
@ -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>`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue