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.
110 lines
5.6 KiB
JavaScript
110 lines
5.6 KiB
JavaScript
// useMapAttribution.js — the data-credit line every map MUST carry.
|
|
//
|
|
// import { addMapAttribution } from '@map/composables/useMapAttribution'
|
|
// addMapAttribution(map, { boundaries: true })
|
|
//
|
|
// ═══ WHY THIS EXISTS — IT IS A LICENCE OBLIGATION, NOT A NICETY ═════════════
|
|
// Every map in this repo constructs Mapbox with `attributionControl: false`
|
|
// (useMapInstance.js and all three research profiles), and NOTHING replaced it. So
|
|
// the live maps were rendering NO attribution at all — not for geoBoundaries, and
|
|
// not for Mapbox/OpenStreetMap either, whose terms also require it.
|
|
//
|
|
// geoBoundaries' own request, verbatim: "we ask that you put the name
|
|
// 'geoBoundaries' with a link back to this website, www.geoboundaries.org,
|
|
// somewhere prominent on the page that uses the boundaries."
|
|
//
|
|
// gbOpen is ALSO a MIXED-LICENCE collection — it passes through each country's
|
|
// upstream licence, verified live against their API:
|
|
// India ADM1 CC BY 2.5 IN (source: github.com/datameet/maps)
|
|
// Sudan ADM0 CC BY 3.0 IGO (source: HDX)
|
|
// Brazil ADM0 CC BY 3.0 IGO (source: HDX)
|
|
// USA ADM0 Public Domain (US Census)
|
|
// China ADM0 Public Domain (Wikimedia Commons)
|
|
// Several therefore carry a genuine per-country attribution requirement. A repo
|
|
// comment previously called this "Apache-2.0-ish", which is wrong and would be the
|
|
// wrong basis for a publication decision.
|
|
//
|
|
// SELF-HOSTED, NO NETWORK. This is plain DOM + inline style: it adds no request, so
|
|
// it cannot leak a viewer's IP the way a third-party badge would. That matters here
|
|
// — see the open OPEN_DEFECT_mapbox_cdn finding.
|
|
|
|
const LINKS = {
|
|
boundaries: {
|
|
label: 'geoBoundaries',
|
|
href: 'https://www.geoboundaries.org',
|
|
title: 'Country and administrative boundaries — geoBoundaries gbOpen (mixed upstream licences; see docs)',
|
|
},
|
|
mapbox: { label: 'Mapbox', href: 'https://www.mapbox.com/about/maps/' },
|
|
osm: { label: '© OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
|
// Under MapLibre the basemap is OpenFreeMap's, built from OpenMapTiles on OpenStreetMap data — the
|
|
// credit its own TileJSON asks for. Mapbox serves nothing on those maps and must not be credited.
|
|
openfreemap: { label: 'OpenFreeMap', href: 'https://openfreemap.org' },
|
|
openmaptiles: { label: '© OpenMapTiles', href: 'https://www.openmaptiles.org/' },
|
|
osmContributors: { label: '© OpenStreetMap contributors', href: 'https://www.openstreetmap.org/copyright' },
|
|
};
|
|
|
|
/** The basemap credit for the engine that actually drew THIS map (read from the instance, not config). */
|
|
function basemapCredit(map) {
|
|
const ml = typeof window !== 'undefined' ? window.maplibregl : null;
|
|
const isMaplibre = !!(ml && ml.Map && map instanceof ml.Map);
|
|
return isMaplibre ? [LINKS.openfreemap, LINKS.openmaptiles, LINKS.osmContributors] : [LINKS.mapbox, LINKS.osm];
|
|
}
|
|
|
|
/**
|
|
* Add the bottom-right attribution bar to a Mapbox map.
|
|
*
|
|
* @param {Object} map a mapboxgl.Map
|
|
* @param {Object} [opts]
|
|
* @param {boolean} [opts.boundaries=true] credit geoBoundaries (set false only if
|
|
* the map draws NO gb polygons at all)
|
|
* @param {boolean} [opts.basemap=true] credit the basemap of the engine that drew this map:
|
|
* Mapbox + OpenStreetMap, or under MapLibre OpenFreeMap +
|
|
* OpenMapTiles + OpenStreetMap contributors
|
|
*/
|
|
export function addMapAttribution(map, opts = {}) {
|
|
if (!map || typeof map.addControl !== 'function') return null;
|
|
const { boundaries = true, basemap = true } = opts;
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'doxa-attribution mapboxgl-ctrl';
|
|
// Inline style: these maps live in SHADOW ROOTS, so a stylesheet in the host page
|
|
// would not reach here. Kept muted and small so it reads as a credit line, not UI.
|
|
el.style.cssText = [
|
|
'font:11px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif',
|
|
'background:rgba(255,255,255,.82)', 'color:#3a4048',
|
|
'padding:2px 6px', 'border-radius:3px 0 0 0',
|
|
'pointer-events:auto', 'max-width:min(92vw,420px)',
|
|
].join(';');
|
|
|
|
const parts = [];
|
|
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}"` : '')
|
|
+ ` style="color:#2a5d8f;text-decoration:none">${p.label}</a>`).join(' · ');
|
|
|
|
const control = {
|
|
onAdd: () => el,
|
|
onRemove: () => { el.parentNode && el.parentNode.removeChild(el); },
|
|
getDefaultPosition: () => 'bottom-right',
|
|
};
|
|
map.addControl(control, 'bottom-right');
|
|
return control;
|
|
}
|
|
|
|
/**
|
|
* The same credit as plain text — for posters, PDFs and print, which have no DOM — for the engine that
|
|
* drew the map ('maplibre' → OpenFreeMap / OpenMapTiles / OpenStreetMap contributors).
|
|
*/
|
|
export function attributionText(provider = 'mapbox') {
|
|
return provider === 'maplibre'
|
|
? 'Boundaries: geoBoundaries (geoboundaries.org) · OpenFreeMap © OpenMapTiles © OpenStreetMap contributors'
|
|
: 'Boundaries: geoBoundaries (geoboundaries.org) · © Mapbox © OpenStreetMap';
|
|
}
|
|
/** @deprecated the Mapbox credit only; use attributionText(provider). Kept for existing importers. */
|
|
export const ATTRIBUTION_TEXT = attributionText('mapbox');
|