Skip to content

GeoLeaf Security Contract v3.0.0

Applies to: @geoleaf/core v3.x — MapLibre GL JS security audit

This document is the security reference. Every identified injection vector, the sanitisation method applied to it and the matching test file are listed below. After each sprint, verify that every listed vector still has a passing test.


1. Injection vector inventory

1.1 DOM vectors (HTML/SVG injection into the DOM)

Source paths are relative to packages/core/src/ unless a plugin is named. Tests live under packages/core/__tests__/ (or packages/<plugin>/src/__tests__/).

VectorSource file (sink)SanitisationTest file
Feature popup (click on an entity)capabilities/feature-info/surfaces/popup.ts:131.setDOMContent(node)No innerHTML sink at all: the popup is a constructed DOM node. Title render/popup-content.ts:132-138 and fields render/fields.ts:95,109,152,189 use textContent; URLs go through safeUrl (render/dom.ts:202-214)capabilities/feature-info/popup.test.js · renderers.test.js:60,122,184
Feature tooltip (hover)capabilities/feature-info/surfaces/tooltip.ts:84 and :89 (innerHTML)escapeHtml(String(value))render/popup-content.ts:310; escapeHtml is render/dom.ts:185-189capabilities/feature-info/tooltip.test.js:94-100 — covers the live path
Feature sidepanel (3rd surface)capabilities/feature-info/render/sidepanel-content.ts:140,144,146,224,228textContentNone
Filter categoriescapabilities/filter/panel/render.ts:205 (category), :237 (subcategory), :308 (badge)createElement({ textContent })modules/utils/general/dom-helpers.ts:92capabilities/filter/panel-dom-golden.test.js<img onerror> payload on all three sites, and DOMSecurity.setSafeHTML never called
Address search resultsgeocoding/src/control.ts:132 (li.textContent = result.label)textContentgeocoding/src/__tests__/control.test.ts:130
Layer names (layer manager)modules/built-in/layer-manager/section-renderer.ts:22,52textContentlayer-manager.test.js
Mobile toolbar iconsmodules/built-in/ui/mobile/mobile-toolbar-pill.ts:324DOMSecurity.setSafeHTML(btn, icon.icon, _MOBILE_SVG_TAGS) — allowlist at :32None
Desktop toolbar iconsmodules/built-in/ui/desktop/desktop-panel-registry.ts:156DOMSecurity.setSafeHTML(btn, btnDef.icon, _SVG_ALLOWED)None
Marker icons (profile SVG)adapters/maplibre/maplibre-adapter.ts:431DOMSecurity.setSafeHTML(el, iconHtml, SVG_ALLOWED_TAGS)None
Share QR code SVGcapabilities/permalink/share/share-modal.ts:125setSafeHTML(container, svg, QR_ALLOWED_TAGS); URL at :65 through input.valueNone
Theme selector labelscapabilities/theme-selector/theme-selector-primary.ts:129,132 · -secondary.ts:114textContenttheme-selector.test.js
Search bar (permalink → UI)capabilities/filter/panel/write.ts:64 (input.value = sf?.text ?? "")element.value (never innerHTML) plus a MAX_TEXT_LEN = 200 truncation (permalink-url.ts:50,87,207)permalink-injection.test.js
Taxonomy badge colours (CSSOM)capabilities/feature-info/render/fields.ts:155-157render/dom.ts:294-303Values pre-validated by the taxonomy seam; written property by property (CSP style-src)None
SVG sprite referencecapabilities/feature-info/render/dom.ts:250-251 (use.setAttribute("href", "#" + symbolId))symbolId is an allowlisted id resolved by taxonomyNone
Navigation arrowsmodules/built-in/ui/mobile/mobile-toolbar-pill.ts:377-378 and :407-408Hard-coded SVG literalsN/A (safe by design)
iOS PWA bannercapabilities/pwa/ios-banner.ts:112 and :122 (innerHTML = SHARE_ICON_SVG)SVG constant (:26). The title at :80 is hard-coded rather than localisedN/A (safe by design)
Temporary addpoi markeraddpoi/src/poi/poi-placement.ts:280 (el.innerHTML)color (:268) is a ternary over two hex literals — no user dataN/A (safe by design)

Out of XSS scope. GeoJSON labels no longer reach the DOM: capabilities/labels/label-renderer.ts:120 sets "text-field": ["get", labelId] on a native MapLibre symbol layer (GPU rendering), so the file contains no textContent or innerHTML — the data never meets an HTML parser.

1.2 URL vectors (injection through protocol or parameters)

VectorSanitisationTest file
POI URLs (url, website, image, photo, icon)validateUrl() — protocol allowlist (http/https/data:image)security.test.js, xss-injection-vectors.test.js
Data URLs_validateDataUrl() — MIME allowlist (image/* only)security.test.js
Permalink lat/lng/zoomvalidateNumber() and validateCoordinates()permalink-injection.test.js
Permalink layer IDsString filtering, capped at 100 entriespermalink-injection.test.js
Permalink filter textTruncated to 200 characters (MAX_TEXT_LEN)permalink-injection.test.js
Permalink compact form (base64)JSON.parse(atob()) followed by _validateRaw() re-validationpermalink-injection.test.js
Permalink ratingNumber() and a > 0 checkpermalink-injection.test.js

1.3 Prototype pollution vectors

Every protection below calls the same blocklist — isUnsafeKey() / hasUnsafeSegment() from modules/utils/general/object-path-guard.ts. That module has no imports of its own, which is what makes it importable from any layer without creating an edge or a cycle.

VectorProtectionTest file
Profile JSON config_isUnsafeKey() (built-in/config/storage.ts, logging wrapper) on set(), merge() and deepMerge()sprint1-sink-hardening.test.js (M3)
POI properties (mapping.json)_isUnsafeKey() on setValueByPath() — the write path of normalizePoiWithMappingprototype-pollution.test.js
A profile's modules bagisUnsafeKey() on mergeModulesBag(), mergeModuleBags() and the Files.modules loadermodule-config-pollution.test.js
Permalink compact base64JSON.parse() (safe) plus _validateRaw() type checkspermalink-injection.test.js
GeoJSON styles (paint normalisation)_safeCopy(), _mergeNativePaint(), _resolveRuleStyle() and _buildPaintFromRules() (adapters/maplibre/maplibre-style-converter.ts)s14-style-converter-paint.test.js
Public path utilitiesdeepMerge() (utils/general/utils-base.ts) and setNestedValue() (utils/general/object-utils.ts)utils-base.test.js, object-utils.test.js (@security)
The inventory itselfcheck-dynamic-key-writes.cjs — any new unguarded X[k] = … write fails at commit time and in CIguards/prototype-pollution-sinks.guard.test.js

Scope of the guarantee

On setValueByPath, global pollution of Object.prototype was never reachable: the own-property check replaces the intermediate with a fresh object and breaks the constructor.prototype chain. What was reachable is a prototype injection scoped to the POIs under construction ("__proto__.x" yielding an inherited property on every POI), which then flowed into feature properties, popups and table columns. Medium severity, not critical.

GeoJSON feature properties are only read in the core pipeline (validated by feature-validator.ts), never merged into another object, so there is no sink to guard for them. The "styles" row above covers the only path where they are copied.


2. Security module API

Sanitisation functions

FunctionInputOutput guaranteeUsage
escapeHtml(str)Any stringHTML characters (<, >, &, ", ') escaped as entitiesText content in the DOM
escapeAttribute(str)Any stringSame characters plus ' escapedHTML attribute values
containsDangerousHtml(str)Any stringtrue when XSS patterns are detectedFast detection/rejection
stripHtml(html)HTML stringPlain text with every tag removedPlain-text display
sanitizeSvgContent(svg)Raw SVG stringAn SVGElement without <script>, <foreignObject>, on* handlers or javascript: hrefsExternal SVG icons
parseHtmlSafely(html, tags)HTML string plus tag allowlistA DocumentFragment containing only the allowed tagsControlled rich content
sanitizeHTML(el, html, opts)DOM element plus HTMLSanitised injection through parseHtmlSafelyMain wrapper
validateUrl(url, base, opts)URL stringA validated URL (allowlisted protocol), or throwsLinks, images, media
validateCoordinates(lat, lng)NumbersA valid [lat, lng] tuple, or throwsMap coordinates
validateNumber(val, min, max)Any valueA finite number within [min, max], or nullNumeric parameters

Safe DOM functions

FunctionInputGuaranteeUsage
DOMSecurity.setTextContent(el, text)Element plus textAssignment through textContent (never innerHTML)Any non-HTML text
DOMSecurity.setSafeHTML(el, html, tags?)Element, HTML, optional allowlistRouted through Security.sanitizeHTML(), falls back to textContentControlled HTML
DOMSecurity.clearElement(el)ElementChildren removed through a removeChild loopDOM cleanup
DOMSecurity.createElement(tag, attrs, children)Tag, attributes, childrenElement created through the safe DOM APIElement creation
DOMSecurity.createSVGIcon(w, h, path, opts)Dimensions plus path dataAn SVGElement built with createElementNS (not innerHTML)Internal SVG icons

CSRF protection

FunctionDescription
CSRFToken.init()Generates a crypto-random token (32 bytes, base64url)
CSRFToken.getToken()Returns the current token, regenerating it if expired
CSRFToken.validateToken(token)Constant-time token validation
CSRFToken.rotateToken()Manual rotation plus a geoleaf:csrf:rotated event
CSRFToken.addTokenToHeaders(opts)Adds X-CSRF-Token to the headers
CSRFToken.addTokenToForm(form)Adds <input type="hidden" name="csrf_token">
CSRFToken.setSecureCookie(name, val, opts)Cookie with Secure, SameSite and HttpOnly

3. Dangerous patterns — audit result

PatternOccurrencesStatus
eval()0OK
new Function(0OK
setTimeout(string, ...)0OK
setInterval(string, ...)0OK
document.write0OK
insertAdjacentHTML0OK
outerHTML (write)0OK (one read in label-renderer.ts — safe)
innerHTMLsee 3.1All safe — every occurrence is classified in 3.1

The innerHTML count is not restated here because it moves with every refactor. Re-measure it with grep -rn "innerHTML" packages/core/src --include="*.ts"; the sinks that actually carry user data are the ones listed in 1.1.

3.1 innerHTML classification

  • DOM construction (no innerHTML) — the feature popup: capabilities/feature-info/surfaces/popup.ts:131 passes a node to .setDOMContent().
  • Escape pattern (textContent → innerHTML read): modules/built-in/security/index.ts · capabilities/feature-info/surfaces/tooltip.ts:84,89 (through escapeHtml, render/popup-content.ts:310).
  • DOMSecurity.setSafeHTML wrapper: modules/utils/general/dom-helpers.ts · modules/built-in/ui/mobile/mobile-toolbar-pill.ts:324 · modules/built-in/ui/desktop/desktop-panel-registry.ts:156 · adapters/maplibre/maplibre-adapter.ts:431 · capabilities/permalink/share/share-modal.ts:125.
  • Clearing (innerHTML = ""): modules/built-in/ui/mobile/mobile-toolbar.ts · mobile-toolbar-sheet.ts.
  • Hard-coded SVG constants: capabilities/pwa/ios-banner.ts:112,122 · capabilities/theme-selector/theme-selector-primary.ts · modules/built-in/ui/mobile/mobile-toolbar-pill.ts:377-378,407-408 · addpoi/src/poi/poi-placement.ts:280.

4. CSP compatibility (Content Security Policy)

Minimum directives required for GeoLeaf plus MapLibre GL JS v6:

DirectiveValueReason
script-src'self'No eval, no Function, no inline scripts. Same-origin workers.
style-src'self'Dynamic styles are applied through the CSSOM (element.style.setProperty, applyCssText helper) or through CSS classes, neither of which is subject to style-src. 'unsafe-inline' is not required.
img-src'self' data: https:Data URLs for markers and icons, HTTPS tiles
connect-src'self' https:Fetching GeoJSON, profiles and tile URLs
worker-src'self' blob:The GeoJSON worker is loaded from a blob URL (worker-manager.ts)
font-src'self'The core loads no external font
default-src'self'Safe fallback

Notable points:

  • unsafe-eval is not required — confirmed for MapLibre GL JS v6 and GeoLeaf.
  • unsafe-inline is not required: dynamic styles go through the CSSOM (element.style.setProperty, the applyCssText helper) or CSS classes, and property-by-property CSSOM writes escape style-src entirely. This is guarded by the 18-security end-to-end suite.
  • After a MapLibre upgrade, check whether the new version requires additional directives (WebGL, workers).

5. Review checklist

The MapLibre migration is complete (v3.0.0, native engine end to end), so this section is no longer a migration checklist but the review to run against this contract on every sprint that touches rendering.

  • [ ] Does every Source file in 1.1 still exist? A refactor moves the protections, never the map.
  • [ ] Does every Test file in 1.1 actually import the code it claims to test? A test with no import only tests the browser.
  • [ ] Does each cited test reach the real sink, and not just the sanitisation primitive?
  • [ ] Does feature rendering (popup / tooltip / sidepanel) still build DOM, or does it route through escapeHtml before innerHTML?
  • [ ] Do markers and SVG icons still go through DOMSecurity.setSafeHTML plus an allowlist?
  • [ ] Does validateUrl cover tile and media URLs?
  • [ ] Is every new injection surface recorded in 1.1?
  • [ ] CSP: does a new dependency require unsafe-eval or unsafe-inline? Neither is required today.
  • [ ] Prototype pollution: are new configuration entry points covered?
  • [ ] Prototype pollution: does every new path writer (a.b.c) apply a guard to each segment, including the last? A single-segment path skips the descent loop entirely.
  • [ ] Is the guard tested against the real implementation, with no mock of the sink?
  • [ ] Is npm run check:dynamic-key-writes green with no new baseline entry? A new entry must be justified in the commit message.
  • [ ] Does the guard import object-path-guard.js rather than redeclaring its list? The prototype-pollution-sinks.guard.test.js guard test refuses a fifth copy.
  • [ ] Does the new test fail when the guard is neutralised? Manual mutation protocol: make isUnsafeKey return false, check that the test turns red, then restore. A security test that has never been seen failing proves nothing.
  • [ ] Do all __tests__/security/ tests pass?

6. Security test files

FileTestsCoverage
security/security.test.js42escapeHtml, escapeAttribute, validateUrl, validateCoordinates
security/sprint1-sink-hardening.test.js12Guards wired at the sink: sprite loader (M1), Config deepMerge/set/merge (M3), setValueByPathreal StorageHelper, not mocked
security/csrf-token.test.js23Full CSRFToken lifecycle
security/security-comprehensive.test.js~60Extended coverage of escapeHtml and coordinates
security/security-extended.test.js~50sanitizeSvgContent, validateNumber, parseHtmlSafely
security/security.esm.test.js~70ESM tests across all functions
security/prototype-pollution.test.js6The real normalizePoiWithMappingsetValueByPath pipeline, with no mock of the sink
security/xss-prevention.test.js12Browser DOM escaping only — the file has no import and loads no GeoLeaf code. To rewrite against a real sink from 1.1, or to delete
security/xss-injection-vectors.test.js~90The primitives only (Security, DOMSecurity, its sole imports at :17-18). Reaches none of the 1.1 vectors
security/permalink-injection.test.js~30URL parameter injection plus compact mode
security/file-validator.test.js~25Safe upload (size, extension, MIME)
security/dom-security.test.js24Full DOMSecurity wrapper
capabilities/feature-info/tooltip.test.js:94-100 injects <b>x</b> and asserts querySelector("b") is null. A real test on the live path
capabilities/feature-info/renderers.test.js:60,122,184javascript: payloads

6.1 Vectors that are protected but not tested

These vectors are genuinely protected (see 1.1), but no test verifies it. The protection rests on review, not on a safety net.

VectorSanitisation in place
Filter categoriescreateElement({ textContent })
Mobile and desktop toolbar iconsDOMSecurity.setSafeHTML plus an SVG allowlist
Marker icons (profile SVG)DOMSecurity.setSafeHTML plus SVG_ALLOWED_TAGS
Share QR code SVGsetSafeHTML plus QR_ALLOWED_TAGS
feature-info sidepaneltextContent
Taxonomy badge colours (CSSOM)Values pre-validated by the taxonomy seam

Released under the MIT License.