Skip to content

GeoLeaf Core — Security Guide ​

Applies to: @geoleaf/core v3.x

Guide for consumers of @geoleaf/core. It covers the required CSP directives, the security architecture, the responsible disclosure process, authentication, and what a lost device keeps.


GeoLeaf depends on MapLibre GL JS (WebGL, workers) and on external map resources. The recommended minimal CSP is:

http
Content-Security-Policy:
  default-src 'self';
  script-src  'self';
  style-src   'self';
  img-src     'self' data: https:;
  connect-src 'self' https:;
  worker-src  'self' blob:;
  font-src    'self';

INFO

font-src is 'self' because the core loads no external font — the typeface is a system stack. The authoritative value is the one in security/SECURITY_CONTRACT.md §4; both tables describe the same object.

WARNING

This table is the minimum required by the core, not the policy of the deployed demonstration application. The two do not coincide, and do not have to: the demonstration application serves font-src 'self' data: (the data: covers its embedded icon fonts), declared in its own index.html. An integrator composing their own document starts from the table above; there is no need to inherit the demonstration application's requirements.

Strict style-src (no 'unsafe-inline'): since v2.2.1, GeoLeaf no longer requires 'unsafe-inline'. Dynamic styles are applied through the CSSOM property by property (element.style.setProperty, public helpers GeoLeaf.Helpers.applyCssText / applyDeferredStyles) or through CSS classes — forms that are not subject to style-src, unlike element.style.cssText, setAttribute('style', …) and style attributes (now eliminated from rendering: markers, panels, sprite). For the full inventory and CSP matrix, see security/SECURITY_CONTRACT.md §4.

worker-src 'self' blob: — 'self' is what the workers need: the MapLibre worker is a same-origin .mjs module loaded by its URL, and so is GeoLeaf's GeoJSON worker. blob: covers the engine's fallback, taken only when MapLibre itself is loaded from another origin than the page (a CDN): it then starts its worker from a blob URL.


2. GeoLeaf Core security architecture ​

GeoLeaf implements several independent layers of protection:

LayerModuleKey functions
XSS protectionkernel/security/escapeHtml(), escapeAttribute(), sanitizeHTML(), sanitizeSvgContent()
DOM securitykernel/security/dom-security.tsDOMSecurity.setTextContent(), DOMSecurity.setSafeHTML() — no direct innerHTML
Input validationkernel/security/validators.tsURL protocol allowlist (https:, http:, data:image/*), coordinate bounds, GeoJSON structure
Fetch securityutils/general/fetch-helper.tsURL validation + rate limiting (50 requests / 10 s / domain)
Error sanitisationutils/errors/errors.tssanitizeErrorMessage() — escapes HTML in error messages
Prototype guardutils/general/object-path-guard.tsisUnsafeKey() / hasUnsafeSegment() — single canonical blocklist (__proto__, constructor, prototype), applied by 7 sink files

Covered vectors (summary) ​

  • DOM injection: 12 identified vectors (POI popup, tooltip, labels, search results, etc.) — all sanitised through escapeHtml() or DOMSecurity.*
  • URL injection: 7 vectors (url, website, image fields, permalink lat/lng/zoom) — validated through validateUrl() + validateCoordinates()
  • Prototype pollution: 5 vectors (JSON profile config, a profile's modules bag, POI properties, GeoJSON styles, a permalink — its compact payload, and the names of its per-field gl_f.* parameters) — blocked by a single canonical blocklist, isUnsafeKey() / hasUnsafeSegment() (utils/general/object-path-guard.ts), applied by the 7 sink files; the permalink additionally goes through a type revalidation

For the full inventory with source files and tests, see security/SECURITY_CONTRACT.md.


3. CSRF — no client-side module ​

GeoLeaf.Security.CSRFToken was removed in 3.4.0. It minted its token in the browser and checked it in the same context, so no server could verify it: it protected nothing while reading like a protection. GeoLeaf authenticates writes with the bearer token of @geoleaf-plugins/connector, which adds it to the requests it intercepts — and a browser never attaches a bearer token on its own, which is the condition a CSRF attack relies on.


4. Known limitations ​

LimitationReasonMitigation
data: URLs allowed for imagesPOI profiles support base64 iconsExact MIME whitelist (ALLOWED_DATA_URL_TYPES, via _validateDataUrl()) — not an image/* prefix test
http: allowed by default in validateUrl()Non-HTTPS contexts (development, intranet)Enforce HTTPS at the deployment layer (CSP upgrade-insecure-requests, HSTS). { httpsOnly: true } hardens only a call you make yourself: there is no setting for it
Unauthenticated service workerOutside the scope of the libraryImplement service worker authentication in the application

5. Responsible disclosure ​

Do not report vulnerabilities through public GitHub issues.

Contact ​

Emailcontact@geoleaf.dev
AcknowledgementWithin 48 h
Initial triageWithin 5 days
Fix or workaroundWithin 30 days
Public disclosureAfter the fix is released

GeoLeaf follows a coordinated disclosure model. Please allow time for the vulnerability to be addressed before any publication.

Scope ​

In scope: XSS in the security module, prototype pollution, unsafe HTML injection through the DOM, URL validation bypass, dependency vulnerabilities with a direct exploitation path.

Out of scope: vulnerabilities in MapLibre GL JS or other dependencies (report them to those projects directly), physical access, social engineering, unsupported versions (any major release earlier than the current one), denial of service.


6. References ​

DocumentDescription
security/GeoLeaf_Security_README.mdFull API of the security module (signatures, examples)
security/SECURITY_CONTRACT.mdExhaustive inventory of injection vectors and their tests
.github/SECURITY.mdOfficial security policy (responsible disclosure)

7. HTTP authentication — JWT integration guide ​

This section covers integrating the @geoleaf-plugins/connector plugin with external authentication providers. For installation and basic scenarios, see the docs/CONNECTOR_GUIDE.md shipped by @geoleaf-plugins/connector — that guide belongs to the plugin package, not to the core.


7.1 HTTP protocol expected by @geoleaf-plugins/connector ​

The wire contract — the sign-in and renewal requests, the fields each answer must carry, and how every status is read — is written once, in §3 of the server contract. What it means for security:

  • The token travels in Authorization: Bearer <token>, never in a query string. GeoLeaf never decodes it.
  • expiresIn is a lifetime in seconds, and sign-in fails without it.
  • A renewal the server refuses ends the session — a 404 included: the token is erased and geoleaf:connector:auth-error is emitted. A server that offers no POST {endpoint}/refresh therefore ends every session 30 seconds before its expiry. ⚠️ This page used to call the refresh route "optional", and said a 404 "degrades silently" with the token staying in use until it expires: the session does end then, and loudly.
  • Signing out calls no route: a token that must stop working before its expiry has to be revoked by the server.

7.2 JWT token lifecycle ​

The plugin manages the token through a three-level cache cycle:

PhaseBehaviour
configure()Reads the session once: IndexedDB → RAM, and renews an expired token
Synchronous accessRAM only — the MapLibre bridge and the worker hook, in auth.endpoint mode
Asynchronous accessRAM → IDB → refresh if expiry < 5 min
Proactive refreshTriggered in the background if expiry < 5 min, without blocking the current request
ExpiryForced refresh; geoleaf:connector:auth-error only if the renewal is refused
401 retryOne retry at most after a refresh attempt; synthetic 401 response on failure
OutageA renewal that cannot conclude keeps the session and fires nothing (see below)

Persistence: IndexedDB, database geoleaf-connector, store auth-tokens, key baseUrl.
The token survives page reloads but not navigation to another origin.

Only a refusal ends a session. A renewal the server refuses — a 401, a 403, no renewal offered, a 501, an answer received whole but unusable — erases the stored token and fires geoleaf:connector:auth-error. A renewal that cannot conclude — no network, the time budget spent (the whole exchange is bounded, body included), a 408, 429, 500, 502, 503 or 504, a body cut in transit — keeps the session: the request gets its 401, configure() resolves instead of blocking, and the renewal is tried again when the network returns, when the page comes back to the foreground, and when a capture enters the offline queue. A renewed token is stored, and a refused one erased, only if it is still the one the renewal presented — a sign-in or a sign-out made meanwhile wins. With no stored session at all, a 401 is returned as the server sent it, without a renewal and without geoleaf:connector:auth-error.

Security constraints enforced by the code:

ConstraintBehaviour
HTTPS required in productionConfigError thrown if baseUrl or auth.endpoint uses HTTP toward a host that is not a development host — localhost, *.localhost, loopback, *.test. The destination is judged, not the page
Token in the header onlyAuthorization: Bearer {token} — never in a query string
Password wiped after useString overwritten in memory post-login (OWASP A02)
Non-JWT tokenconsole.warn if the token contains no . — only in getToken callback mode (not in auth.endpoint mode)
Modal XSStextContent only in the login modal — no innerHTML with user data

7.3 Choosing the authentication mode ​

getToken and auth are mutually exclusive — a ConfigError is thrown if both are provided.

ModeConfigurationUse case
Standalone + modalauth: { endpoint, ui: true }Own backend returning { token, expiresIn }; the modal is handled by the plugin
Silent standaloneauth: { endpoint, ui: false }Token preloaded into IDB during an earlier session — no modal. If no session is stored, or its renewal is refused, a ConfigError is thrown; a stored session whose renewal cannot be reached is kept, and configure() resolves.
Async callbackgetToken: async () => tokenExternal SSO through an identity SDK running in the page — the plugin delegates resolution

7.4 Mode callback (getToken) — delegating to an identity provider ​

Use this mode when an identity SDK already runs in the page and owns the session: it holds the token, refreshes it, and the connector merely reads the current value at each request.

js
await GeoLeaf.Connector.configure({
    baseUrl: "https://api.example.com",
    getToken: async () => {
        // Ask the SDK to refresh if the token expires within 30 s, then hand it over.
        await authClient.refreshIfExpiringWithin(30);
        return authClient.token ?? null;
    },
});

Returning null sends the request without a token. If the server answers 401, the connector asks once more, and a second null emits geoleaf:connector:auth-error. Never return an expired token to avoid a null — a silent 401 is harder to diagnose than an explicit auth error.

The provider is called for every request that needs the token — each intercepted fetch, each vector tile, each GeoJSON load the worker makes — and nothing keeps a copy. A provider that answers with a promise reaches the tiles on MapLibre 5.21 or later and the worker on @geoleaf/core 3.4.0 or later. One that throws or rejects fails the page's request with its error; tiles and worker loads go without a token until it answers again.

A JWT signed with RS256 is passed straight into Authorization: Bearer, unchanged.


7.5 Mode auth.endpoint — a login endpoint on your server ​

Use this mode when the server exposes a login route. The connector posts { login, password } as JSON and expects { token, expiresIn } back. When ui: true, the plugin renders the modal itself.

js
await GeoLeaf.Connector.configure({
    baseUrl: "https://api.example.com",
    auth: {
        endpoint: "https://api.example.com/api/auth/login",
        ui: true,
    },
});

Refresh is derived, not configured: the connector reaches POST {endpoint}/refresh automatically — with the endpoint above, that is /api/auth/login/refresh.

⚠️ expiresIn is frequently missing. Many JWT libraries return only the token, and their default success response carries no lifetime. Without it, sign-in fails ("Invalid server response: missing token or expiresIn"). If your library omits it, add it to the response payload — its own token-TTL setting is the value to expose.


7.6 Three traps that do not depend on which server you run ​

① A native OAuth 2.0 password flow is NOT compatible with this contract. The ROPC grant (RFC 6749 §4.3, the /token endpoint of most OIDC servers) expects grant_type=password encoded as application/x-www-form-urlencoded. The connector sends JSON. Two ways out: use the callback mode of §7.4 with the provider's own SDK, or put a thin adapter in front:

POST /api/auth/login          ← receives { login, password }   (connector format)
    → POST <provider> /token  ← grant_type=password, form-encoded
    ← { token: access_token, expiresIn: expires_in }

⚠️ Note that ROPC is deprecated by most modern providers — prefer §7.4 when the provider offers a browser SDK.

② Opaque tokens are accepted, but only in one of the two modes. Some API-token schemes issue opaque strings rather than JWTs. Because they contain no ., the plugin emits a console.warnin getToken mode. Through auth.endpoint that check does not apply and no warning is emitted — the mode you choose changes the diagnostics you get, not the validity of the token.

③ Field names are part of the contract. The connector sends login, not username. Servers whose login route reads username need an explicit mapping — most authentication layers expose a setting for exactly this, and getting it wrong yields a 401 with no other symptom.


8. A lost or stolen device ​

GeoLeaf keeps on the device what it needs to work without a network, and does not encrypt it: a key the application can derive would protect nothing (Direction, "What is guaranteed"). Whoever unlocks the device, or reads its storage, reads what this section lists. What protects a lost device lies outside the library — the device's own encryption and lock, a remote wipe by your fleet management, and your server refusing the session.

8.1 What stays on the device ​

WhereWhat
IndexedDB geoleaf-db, store featuresThe entities pulled for offline use, and those edited on the device — each a GeoJSON feature, attribute values included
geoleaf-db, store outboxThe edits the server has not accepted yet. An entry names its entity; the values are in features
geoleaf-db, store local_imagesPhotos taken offline, or whose upload failed, as binary files — until the server acknowledges them. A photo on a field that declares no upload endpoint is never uploaded, and stays
geoleaf-db, store conflictsThe server's version of each entity a last-write-wins push overwrote
geoleaf-db, stores layers, metadata, preferencesWhat was prepared for offline use — layer data, tiles, styles, sprites, glyphs — its manifest, and the engine's state: what each pull learned, the last synchronisation time, option lists
IndexedDB geoleaf-connector, store auth-tokensIn auth.endpoint mode only: the session token, its expiry and the API's address (§7.2). Never the password — but a JWT is readable by whoever holds it: what your server puts in its claims is on the device too
Cache Storage, caches named geoleaf-…The application's files, the profile's resources, the tiles viewed, and other responses kept as an offline fallback. The service worker keeps no response to a request that carries an Authorization header or is sent with credentials: "include", nor one the server marks private or no-store — a server that authenticates by cookie should mark its responses private
localStorageDisplay choices — theme, language, palette, last profile, dismissed install banners. With @geoleaf-plugins/measure: the measures and annotations drawn, a GPS measure being the device's own track (GeoLeaf.Measure.clearAll() erases them). With @geoleaf-plugins/position-share: the random identifier sent with every position (GeoLeaf.PositionShare.clearClientId())

GeoLeaf sets no cookie.

No GeoLeaf call removes it all:

  • GeoLeaf.Connector.logout() erases the token, from memory and from auth-tokens, and emits geoleaf:connector:signed-out. It calls no route and erases nothing else: edits, photos and prepared data stay. In getToken mode it erases nothing — the host application holds the token, and keeps it where it chooses.
  • GeoLeaf.Storage.clearAll() empties what was prepared for offline use and the engine's state. It leaves entities, edits, photos and conflicts alone, by design.
  • An edit leaves the device when the server accepts it, or when a quarantined edit is discarded, one at a time (GeoLeaf.Storage.discardQuarantined()). Nothing evicts it to make room.
  • Unmounting the application (unmount()) leaves stored data in place.

8.2 When a device is lost ​

A lost device cannot sign out: its session ends on your server.

  • In auth.endpoint mode, revoke the session there: reject the token on your data routes, and refuse its renewal — the renewal presents the stored token, expired or not, and your server decides (§3.2 of the server contract). The next request the device makes gets a 401, the renewal that follows is refused, and the device erases the token and emits geoleaf:connector:auth-error. For as long as it stays offline, it keeps the token — and the data.
  • A token copied off the device works wherever it is presented, until it expires or your server rejects it. expiresIn bounds the first; revocation is the second.
  • In getToken mode, the session belongs to the host's identity provider, and so does revoking it.
  • Edits not yet sent exist only on the device, and are lost with it. They are not bound to the user who made them: if the device comes back, the next session that signs in sends them.

8.3 What GeoLeaf does not provide ​

  • Encryption at rest — the decision is in Direction.
  • A remote wipe. Erasing a lost device belongs to your fleet management.
  • A call that erases everything at once (§8.1).
  • Support for a device shared between users (Direction).

Released under the MIT License.