diff --git a/client/package.json b/client/package.json
index 37e131919..0cda4972f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "prebuild": "node scripts/generate-icons.mjs",
+ "prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -26,7 +26,9 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
- "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
+ "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
+ "postinstall": "node scripts/patch-maplibre.mjs",
+ "predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
@@ -49,7 +51,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
- "maplibre-gl": "^5.24.0",
+ "maplibre-gl": "5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
diff --git a/client/scripts/patch-maplibre.mjs b/client/scripts/patch-maplibre.mjs
new file mode 100644
index 000000000..d9f99a89d
--- /dev/null
+++ b/client/scripts/patch-maplibre.mjs
@@ -0,0 +1,34 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+
+const require = createRequire(import.meta.url)
+const packagePath = require.resolve('maplibre-gl/package.json')
+const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
+
+if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
+
+// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
+const loops = [
+ ['of elem.attributes)', 'of Array.from(elem.attributes))'],
+ ['of e.attributes)', 'of Array.from(e.attributes))'],
+]
+
+for (const filename of [
+ 'src/util/dom.ts',
+ 'dist/maplibre-gl.js',
+ 'dist/maplibre-gl-dev.js',
+ 'dist/maplibre-gl-csp.js',
+ 'dist/maplibre-gl-csp-dev.js',
+]) {
+ const target = join(dirname(packagePath), filename)
+ const code = readFileSync(target, 'utf8')
+ const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
+ if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
+ const [before, after] = loop
+ if (code.split(before).length === 1 && code.split(after).length === 2) continue
+ if (code.split(before).length !== 2 || code.includes(after)) {
+ throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
+ }
+ writeFileSync(target, code.replace(before, after))
+}
diff --git a/client/tests/unit/maplibre-sanitizer.test.ts b/client/tests/unit/maplibre-sanitizer.test.ts
new file mode 100644
index 000000000..2705d05df
--- /dev/null
+++ b/client/tests/unit/maplibre-sanitizer.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import maplibregl from 'maplibre-gl'
+
+function attribution(html: string, fromSource = false) {
+ const control = new maplibregl.AttributionControl({
+ compact: false,
+ customAttribution: fromSource ? undefined : html,
+ })
+ const map = {
+ style: {
+ tileManagers: fromSource
+ ? { tiles: { used: true, getSource: () => ({ attribution: html }) } }
+ : {},
+ },
+ _getUIString: () => 'Toggle attribution',
+ on: () => {},
+ off: () => {},
+ getCanvasContainer: () => document.createElement('div'),
+ } as unknown as maplibregl.Map
+ return control.onAdd(map)
+}
+
+describe('MapLibre attribution sanitizer backport', () => {
+ it.each([false, true])('removes adjacent event handlers (source: %s)', fromSource => {
+ const container = attribution('Credit ', fromSource)
+ const details = container.querySelector('.maplibregl-ctrl-attrib-inner details')!
+ expect(details.hasAttribute('onload')).toBe(false)
+ expect(details.hasAttribute('ontoggle')).toBe(false)
+ expect(details.textContent).toBe('Credit')
+ })
+
+ it('removes consecutive dangerous URLs and handlers in nested markup', () => {
+ const container = attribution('Credit')
+ const link = container.querySelector('.maplibregl-ctrl-attrib-inner a')!
+ expect(link.attributes.length).toBe(0)
+ })
+
+ it('preserves provider credit and safe links', () => {
+ const container = attribution('OpenStreetMap')
+ const link = container.querySelector('.maplibregl-ctrl-attrib-inner a')!
+ expect(link.getAttribute('href')).toBe('https://www.openstreetmap.org/copyright')
+ expect(link.getAttribute('rel')).toBe('noopener')
+ expect(link.textContent).toBe('OpenStreetMap')
+ })
+})
diff --git a/package-lock.json b/package-lock.json
index c13d0ee17..76976674b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,7 +47,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
- "maplibre-gl": "^5.24.0",
+ "maplibre-gl": "5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
@@ -99,7 +99,8 @@
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
- }
+ },
+ "hasInstallScript": true
},
"client/node_modules/cookie": {
"version": "1.1.1",