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.
57 lines
3.6 KiB
JavaScript
57 lines
3.6 KiB
JavaScript
#!/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'}.`)
|