Every runtime CDN script and stylesheet is an exact version with an integrity hash
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`, so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5). - library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the @5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384. Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin. - ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches. - check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry; --online re-fetches each and compares the hash (all 9 match today). MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader migration of its own. The attribution strings rendered today come from our configured basemap styles and our own sources; the note in cdn.js says who is exposed until then.
This commit is contained in:
parent
71858d713c
commit
4a12b009aa
6 changed files with 127 additions and 35 deletions
57
internal/check-cdn-pins.mjs
Normal file
57
internal/check-cdn-pins.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* check-cdn-pins — every runtime third-party script/stylesheet is an EXACT version with an integrity hash.
|
||||||
|
*
|
||||||
|
* CRITIC-5 (2026-09-26): maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime
|
||||||
|
* loader set `integrity`, so a CDN could serve any bytes to every partner's visitors unnoticed.
|
||||||
|
* library/constants/cdn.js is now the one registry; loaders set integrity + crossorigin from it.
|
||||||
|
*
|
||||||
|
* OFFLINE (default, in gates:post): in shipped code (library/, plugin-designs/ except scripts/,
|
||||||
|
* internal/generate-tree.mjs) no jsDelivr/unpkg/esm.sh/api.mapbox.com JS or CSS URL is a floating
|
||||||
|
* range (@7, @1.2, latest), and every such URL is either in the registry or on the reviewed list below.
|
||||||
|
* --online: also re-fetches every registry URL and requires its sha384 to equal the pinned hash.
|
||||||
|
* EXIT 0 pass · 1 fail · 75 (online) network unavailable
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { resolve, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { CDN } from '../library/constants/cdn.js'
|
||||||
|
|
||||||
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const ONLINE = process.argv.includes('--online')
|
||||||
|
// Loaded through something other than a <script>/<link> tag, so integrity cannot apply; pinned exactly.
|
||||||
|
const REVIEWED = new Map([
|
||||||
|
['https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
|
||||||
|
['https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
|
||||||
|
])
|
||||||
|
const known = new Set(Object.values(CDN).map((e) => e.url))
|
||||||
|
const URL_RE = /https:\/\/(?:cdn\.jsdelivr\.net|unpkg\.com|esm\.sh|api\.mapbox\.com\/mapbox-gl-js)\/[^\s'"`)<>]+?\.(?:m?js|css)\b/g
|
||||||
|
const FLOATING = /@(?:\d+|\d+\.\d+|latest|next)\/|@\^|@~/
|
||||||
|
|
||||||
|
const files = execFileSync('git', ['ls-files', '--', 'library', 'plugin-designs', 'internal/generate-tree.mjs'], { cwd: ROOT, encoding: 'utf8' })
|
||||||
|
.split('\n').filter((f) => /\.(m?js|vue|html)$/.test(f) && !/\/scripts\//.test(f) && !/\/index\.html$/.test(f) && !/^plugin-designs\/[^/]+\/data\//.test(f))
|
||||||
|
let bad = 0
|
||||||
|
const seen = new Map()
|
||||||
|
for (const f of files) {
|
||||||
|
const src = execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 })
|
||||||
|
for (const m of src.matchAll(URL_RE)) {
|
||||||
|
const u = m[0]
|
||||||
|
if (!seen.has(u)) seen.set(u, f)
|
||||||
|
if (FLOATING.test(u)) { bad++; console.log(` FAIL ${f}: floating version ${u}`) }
|
||||||
|
else if (!known.has(u) && !REVIEWED.has(u)) { bad++; console.log(` FAIL ${f}: not in library/constants/cdn.js (no integrity): ${u}`) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`CDN-PINS: ${seen.size} distinct runtime CDN url(s) in shipped code; registry holds ${known.size}.`)
|
||||||
|
if (ONLINE) {
|
||||||
|
for (const [k, e] of Object.entries(CDN)) {
|
||||||
|
let got = ''
|
||||||
|
try { const r = await fetch(e.url); if (!r.ok) throw new Error('HTTP ' + r.status); got = 'sha384-' + createHash('sha384').update(Buffer.from(await r.arrayBuffer())).digest('base64') }
|
||||||
|
catch (err) { console.log(`CDN-PINS: NOT MEASURED — ${e.url}: ${err.message}`); process.exit(75) }
|
||||||
|
const ok = got === e.integrity
|
||||||
|
if (!ok) bad++
|
||||||
|
console.log(` ${ok ? 'ok ' : 'FAIL'} ${k}: ${ok ? 'bytes match the pinned hash' : `served ${got}, pinned ${e.integrity}`}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bad) { console.log(`CDN-PINS: FAIL — ${bad} problem(s).`); process.exit(1) }
|
||||||
|
console.log(`CDN-PINS: PASS — every runtime CDN script/stylesheet is an exact version${ONLINE ? ', and every pinned hash matches what the CDN serves' : ' with an integrity hash'}.`)
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
|
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
|
||||||
* Wired into: bun run build (runs after the per-bundle vite builds)
|
* Wired into: bun run build (runs after the per-bundle vite builds)
|
||||||
*/
|
*/
|
||||||
|
import { CDN } from '../library/constants/cdn.js'
|
||||||
import {
|
import {
|
||||||
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
|
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
|
||||||
cpSync, rmSync, mkdtempSync,
|
cpSync, rmSync, mkdtempSync,
|
||||||
|
|
@ -167,15 +168,7 @@ const SCAFFOLD_BUNDLES = ['template-bundle']
|
||||||
// ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js),
|
// ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js),
|
||||||
// so the embed snippet's preload can never point at a different version than the one the bundle
|
// so the embed snippet's preload can never point at a different version than the one the bundle
|
||||||
// requests — a mismatched preload is not used, and the engine would download twice.
|
// requests — a mismatched preload is not used, and the engine would download twice.
|
||||||
const MAPBOX_GL_JS = (() => {
|
const MAPBOX_GL_JS = CDN.mapboxGlJs.url // the ONE registry the bundle's loader reads too (library/constants/cdn.js)
|
||||||
try {
|
|
||||||
const src = readFileSync(resolve(__dirname, '..', 'library', 'composables', 'useMapInstance.js'), 'utf8')
|
|
||||||
const m = src.match(/mapbox:\s*\{[^}]*?js:\s*'([^']+mapbox-gl\.js)'/)
|
|
||||||
if (m) return m[1]
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
console.warn('[generate-tree] could not read the Mapbox engine URL from useMapInstance.js; embed snippets get no preload')
|
|
||||||
return ''
|
|
||||||
})()
|
|
||||||
|
|
||||||
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
|
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
|
||||||
|
|
||||||
|
|
@ -807,8 +800,8 @@ function mapPageHtml(opts) {
|
||||||
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
|
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
|
||||||
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
|
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
|
<link href="${CDN.maplibreGlCss.url}" rel="stylesheet" integrity="${CDN.maplibreGlCss.integrity}" crossorigin="anonymous" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js"></script>`
|
<script src="${CDN.maplibreGlJs.url}" integrity="${CDN.maplibreGlJs.integrity}" crossorigin="anonymous"></script>`
|
||||||
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
|
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
|
||||||
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
|
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
|
||||||
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
|
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
|
||||||
|
|
@ -1306,15 +1299,14 @@ function scanBundles() {
|
||||||
// pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only
|
// pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only
|
||||||
// after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right
|
// after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right
|
||||||
// after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download
|
// after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download
|
||||||
// alongside the bundle. NO crossorigin attribute, on purpose: ensureEngineScript injects a plain
|
// alongside the bundle. It carries crossorigin + integrity because ensureEngineScript's <script> now
|
||||||
// <script src>, and a preload is only reused when its request mode matches — with crossorigin it
|
// does too (library/constants/cdn.js): a preload is reused only when its request mode matches. Mapbox only: a non-map profile gets none, and a partner switching to
|
||||||
// would download twice. Mapbox only: a non-map profile gets none, and a partner switching to
|
|
||||||
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
|
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
|
||||||
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
|
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
|
||||||
embed: {
|
embed: {
|
||||||
iframe: iframeEmbed,
|
iframe: iframeEmbed,
|
||||||
script: isMap && MAPBOX_GL_JS
|
script: isMap && MAPBOX_GL_JS
|
||||||
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed
|
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}" integrity="${CDN.mapboxGlJs.integrity}" crossorigin="anonymous">\n` + scriptEmbed
|
||||||
: scriptEmbed,
|
: scriptEmbed,
|
||||||
},
|
},
|
||||||
...(instances.length ? { instances } : {}),
|
...(instances.length ? { instances } : {}),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
geocoder — the raw MapboxGeocoder instance (for advanced usage)
|
geocoder — the raw MapboxGeocoder instance (for advanced usage)
|
||||||
-->
|
-->
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { CDN, pinElement } from '@map/constants/cdn.js'
|
||||||
import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
|
import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useEntitySearch } from '../../composables/useSearch.js'
|
import { useEntitySearch } from '../../composables/useSearch.js'
|
||||||
|
|
@ -380,16 +381,16 @@ const standaloneContainer = ref(null)
|
||||||
// the document head has no effect because the widget renders inside a
|
// the document head has no effect because the widget renders inside a
|
||||||
// ShadowRoot. We target the closest ShadowRoot of the map container.
|
// ShadowRoot. We target the closest ShadowRoot of the map container.
|
||||||
const GEOCODER_CSS = {
|
const GEOCODER_CSS = {
|
||||||
mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
|
mapbox: CDN.mapboxGeocoderCss.url,
|
||||||
maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
|
maplibre: CDN.maplibreGeocoderCss.url,
|
||||||
}
|
}
|
||||||
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
|
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
|
||||||
// index.html; MapLibre's is injected on demand so a profile can flip provider
|
// index.html; MapLibre's is injected on demand so a profile can flip provider
|
||||||
// with no host change.
|
// with no host change.
|
||||||
const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js'
|
const MAPLIBRE_GEOCODER_JS = CDN.maplibreGeocoderJs.url // exact version + SRI: library/constants/cdn.js
|
||||||
// Same version the generated leaf pages preload, so an on-demand load and a preload cannot end up
|
// Same version the generated leaf pages preload, so an on-demand load and a preload cannot end up
|
||||||
// on two different geocoders.
|
// on two different geocoders.
|
||||||
const MAPBOX_GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
|
const MAPBOX_GEOCODER_JS = CDN.mapboxGeocoderJs.url
|
||||||
|
|
||||||
function injectGeocoderCSS(shadowRoot, prov) {
|
function injectGeocoderCSS(shadowRoot, prov) {
|
||||||
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
|
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
|
||||||
|
|
@ -397,6 +398,7 @@ function injectGeocoderCSS(shadowRoot, prov) {
|
||||||
const link = document.createElement('link')
|
const link = document.createElement('link')
|
||||||
link.rel = 'stylesheet'
|
link.rel = 'stylesheet'
|
||||||
link.href = HREF
|
link.href = HREF
|
||||||
|
pinElement(link, HREF)
|
||||||
shadowRoot.prepend(link)
|
shadowRoot.prepend(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,6 +435,7 @@ async function ensureGeocoderScript(src, globalName) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const el = document.createElement('script')
|
const el = document.createElement('script')
|
||||||
el.src = src
|
el.src = src
|
||||||
|
pinElement(el, src)
|
||||||
el.async = true
|
el.async = true
|
||||||
// Resolve on error too: a blocked CDN must leave the map usable and the bar absent, which is
|
// Resolve on error too: a blocked CDN must leave the map usable and the bar absent, which is
|
||||||
// what happened before this existed — never a hang or an unhandled rejection.
|
// what happened before this existed — never a hang or an unhandled rejection.
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,10 @@ import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
|
||||||
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
|
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
|
||||||
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
|
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
|
||||||
// match the existing <script> tags so nothing double-loads or changes.
|
// match the existing <script> tags so nothing double-loads or changes.
|
||||||
|
// URLs and their integrity hashes come from ONE place (library/constants/cdn.js): exact versions, SRI.
|
||||||
const ENGINES = {
|
const ENGINES = {
|
||||||
mapbox: {
|
mapbox: { global: 'mapboxgl', js: CDN.mapboxGlJs.url, css: CDN.mapboxGlCss.url },
|
||||||
global: 'mapboxgl',
|
maplibre: { global: 'maplibregl', js: CDN.maplibreGlJs.url, css: CDN.maplibreGlCss.url },
|
||||||
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
|
|
||||||
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
|
|
||||||
},
|
|
||||||
maplibre: {
|
|
||||||
global: 'maplibregl',
|
|
||||||
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
|
|
||||||
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
|
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
|
||||||
|
|
@ -76,6 +69,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
|
||||||
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
||||||
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
||||||
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
|
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
|
||||||
|
import { CDN, pinElement } from '../constants/cdn.js'
|
||||||
import { installRtlTextPlugin } from '../maps/rtl-text.js'
|
import { installRtlTextPlugin } from '../maps/rtl-text.js'
|
||||||
import { localizeBasemapLabels } from '../maps/localize-labels.js'
|
import { localizeBasemapLabels } from '../maps/localize-labels.js'
|
||||||
|
|
||||||
|
|
@ -130,6 +124,7 @@ export async function ensureEngineScript(provider) {
|
||||||
const link = document.createElement('link')
|
const link = document.createElement('link')
|
||||||
link.rel = 'stylesheet'
|
link.rel = 'stylesheet'
|
||||||
link.href = cfg.css
|
link.href = cfg.css
|
||||||
|
pinElement(link, cfg.css)
|
||||||
document.head.appendChild(link)
|
document.head.appendChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +141,7 @@ export async function ensureEngineScript(provider) {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const s = document.createElement('script')
|
const s = document.createElement('script')
|
||||||
s.src = cfg.js
|
s.src = cfg.js
|
||||||
|
pinElement(s, cfg.js)
|
||||||
s.async = false
|
s.async = false
|
||||||
s.addEventListener('load', resolve, { once: true })
|
s.addEventListener('load', resolve, { once: true })
|
||||||
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
|
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
|
||||||
|
|
@ -196,6 +192,7 @@ export async function ensureEngineCSS(element, cssUrl) {
|
||||||
const link = document.createElement('link')
|
const link = document.createElement('link')
|
||||||
link.rel = 'stylesheet'
|
link.rel = 'stylesheet'
|
||||||
link.href = cssUrl
|
link.href = cssUrl
|
||||||
|
pinElement(link, cssUrl)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
|
|
|
||||||
44
library/constants/cdn.js
Normal file
44
library/constants/cdn.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version
|
||||||
|
* with its Subresource Integrity hash (CRITIC-5, 2026-09-26).
|
||||||
|
*
|
||||||
|
* WHY. Floating ranges (maplibre-gl@5, maplibre-gl-geocoder@1) meant a CDN could serve any future release
|
||||||
|
* to every partner's visitors, and with no integrity attribute nothing would notice a tampered file.
|
||||||
|
* Each entry is loaded with `integrity` + `crossorigin="anonymous"` (every host here answers
|
||||||
|
* Access-Control-Allow-Origin: *, measured), so the browser refuses a file whose bytes differ.
|
||||||
|
*
|
||||||
|
* TO BUMP A VERSION: change the url, recompute the hash, and run check:cdn-pins (it re-fetches every
|
||||||
|
* url and compares):
|
||||||
|
* curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
||||||
|
*
|
||||||
|
* Not here: the RTL text plugin, which the ENGINE fetches (setRTLTextPlugin), not a <script> tag, so
|
||||||
|
* integrity cannot be applied; it is pinned to an exact version in library/maps/rtl-text.js.
|
||||||
|
*/
|
||||||
|
export const CDN = {
|
||||||
|
mapboxGlJs: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js', integrity: 'sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ' },
|
||||||
|
mapboxGlCss: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css', integrity: 'sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV' },
|
||||||
|
// ⚠ MapLibre stays on 5.x FOR NOW, knowingly: GHSA-jrc7-96c5-q579 (an XSS bypass in DOM.sanitize, reached
|
||||||
|
// through a source's ATTRIBUTION string) affects every release <= 6.4.0, and 6.x is ESM-only (no UMD
|
||||||
|
// `maplibregl` global), so upgrading is a loader migration, not a version bump. Exposure meanwhile:
|
||||||
|
// the attribution strings rendered are those of the fixed basemap styles we configure and our own
|
||||||
|
// sources, none reader- or partner-supplied, and SRI pins these exact bytes. A partner who passes a
|
||||||
|
// style from an untrusted origin is exposed until the 6.x migration (tracked separately) lands.
|
||||||
|
maplibreGlJs: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js', integrity: 'sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp' },
|
||||||
|
maplibreGlCss: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css', integrity: 'sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK' },
|
||||||
|
mapboxGeocoderJs: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js', integrity: 'sha384-Lx9WK8XhZk57czwxt6B6nyaDHB5Ayrcrsu4Fw4bCp/Ot/QLOsUZ0xYqLOpOOSXZw' },
|
||||||
|
mapboxGeocoderCss: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', integrity: 'sha384-1KNhwJNCQTr0DCi/5/OnQcFytgycxTahxNZaqKbAtqmY8bUhu26qxXqvDGXXwplS' },
|
||||||
|
maplibreGeocoderJs: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.min.js', integrity: 'sha384-RGV5vfoJd7alk8HE+71IyVfu19ciH1FKA15+AuXs6OeBM2npXrmTPFqG23yMiHXi' },
|
||||||
|
maplibreGeocoderCss: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.css', integrity: 'sha384-UkDpPApjTpHJHpcJiSNAmbobh30W0/dmt0DoKm4V8CWYRYvQ/Jhd/ijFz0tFdVnH' },
|
||||||
|
// d3 for the PPLR maps and the Wall, which load it themselves (pplrData.js, wallD3.js) and keep it off
|
||||||
|
// window.d3; listed so check:cdn-pins can verify their pin.
|
||||||
|
d3: { url: 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js', integrity: 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set integrity + crossorigin on a <script>/<link> for a registry entry (no-op for an unknown url). */
|
||||||
|
export function pinElement(el, url) {
|
||||||
|
const e = Object.values(CDN).find((x) => x.url === url)
|
||||||
|
if (!e) return el
|
||||||
|
el.integrity = e.integrity
|
||||||
|
el.crossOrigin = 'anonymous'
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with
|
* which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with
|
||||||
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
|
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
|
||||||
*/
|
*/
|
||||||
|
import { CDN, pinElement } from '@map/constants/cdn.js'
|
||||||
let base = './assets'
|
let base = './assets'
|
||||||
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
|
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
|
||||||
export function dataUrl(file) { return base + '/pplr/' + file }
|
export function dataUrl(file) { return base + '/pplr/' + file }
|
||||||
|
|
@ -89,12 +90,9 @@ export function loadNames() {
|
||||||
// the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox
|
// the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox
|
||||||
// script made this the only request to api.mapbox.com in the keyless (maplibre) gallery.
|
// script made this the only request to api.mapbox.com in the keyless (maplibre) gallery.
|
||||||
export const GEOCODER = {
|
export const GEOCODER = {
|
||||||
mapbox: { js: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js',
|
// Exact versions + integrity, from the one registry (library/constants/cdn.js, CRITIC-5).
|
||||||
css: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
|
mapbox: { js: CDN.mapboxGeocoderJs.url, css: CDN.mapboxGeocoderCss.url, global: 'MapboxGeocoder' },
|
||||||
global: 'MapboxGeocoder' },
|
maplibre: { js: CDN.maplibreGeocoderJs.url, css: CDN.maplibreGeocoderCss.url, global: 'MaplibreGeocoder' },
|
||||||
maplibre: { js: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js',
|
|
||||||
css: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
|
|
||||||
global: 'MaplibreGeocoder' },
|
|
||||||
}
|
}
|
||||||
const geocoderPromises = {}
|
const geocoderPromises = {}
|
||||||
export function ensureGeocoder(provider = 'mapbox') {
|
export function ensureGeocoder(provider = 'mapbox') {
|
||||||
|
|
@ -103,6 +101,7 @@ export function ensureGeocoder(provider = 'mapbox') {
|
||||||
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
|
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
|
||||||
const s = document.createElement('script')
|
const s = document.createElement('script')
|
||||||
s.src = g.js
|
s.src = g.js
|
||||||
|
pinElement(s, g.js)
|
||||||
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
|
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
|
||||||
document.head.appendChild(s)
|
document.head.appendChild(s)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue