From 87387762b8b086adfdd53d2e5b6bc8624d5b26bb Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 1 Sep 2026 08:35:50 +0200 Subject: [PATCH 1/3] feat(map): support CARTO basemap API keys From August 2026 CARTO requires a Basemaps API key on raster tile requests; without one the tiles come back stamped "API KEY REQUIRED -- carto.com/basemapsapikey". CoreScope built its CARTO URLs by hand in five different places and never sent a key. Adds map.tiles.providers.carto.token and routes every CARTO surface through one helper, window.MC_getCartoTileUrl(path), which owns the base URL (including the enterprise `domain` override), trims the token and appends ?key= -- or nothing at all, so a missing token never leaves a bare "?". It takes a tile path only, never a full URL, so no caller can smuggle in its own host or querystring. Surfaces converted: the five registry styles, roles.js' TILE_DARK / TILE_LIGHT (now accessors that re-resolve on every read, which also fixes their load-order and async-config problems), both Customize geo-filter maps, and the standalone geofilter-builder page -- which previously had no config load of its own and now fetches /api/config/client before building its layer. Leaflet requests tiles the moment a layer joins a map, so resolving the token late was not enough on its own: map.js and live.js now build the Auto layer and the layer picker inside one idempotent MC_whenTileConfigReady callback, so the first CARTO request already carries the key instead of caching a watermarked tile. Map creation, panes, zoom and fullscreen controls are unaffected. If the config fetch fails -- or map-tile-providers.js is missing entirely -- everything falls back to the previous keyless behaviour rather than an empty map. Backwards compatible: carto.enabled=false still removes CARTO from the registered styles (the dedicated geo-filter maps keep using it, as the config comment now spells out), and an install with no token behaves exactly as before, just watermarked by CARTO. No real key is committed; config.example.json ships an empty token and documents the YOUR_CARTO_BASEMAP_KEY placeholder. Co-Authored-By: Claude Opus 5 --- config.example.json | 5 +- public/customize-v2.js | 8 +- public/geofilter-builder.html | 35 +- public/live.js | 41 +- public/map-tile-providers.js | 109 ++++- public/map.js | 47 +- public/roles.js | 35 +- test-all.sh | 1 + test-carto-basemap-key.js | 855 ++++++++++++++++++++++++++++++++++ 9 files changed, 1100 insertions(+), 36 deletions(-) create mode 100644 test-carto-basemap-key.js diff --git a/config.example.json b/config.example.json index b6f967315..030df3b50 100644 --- a/config.example.json +++ b/config.example.json @@ -78,10 +78,11 @@ "darkDefault": "carto-dark", "lightDefault": "carto-light", "providers": { - "_comment_carto": "Carto is the default free-tier provider. Optional: specify 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com').", + "_comment_carto": "Carto is the default provider. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a token here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'token' (e.g. 'YOUR_CARTO_BASEMAP_KEY'). WARNING: the token is sent to the browser, so restrict it by origin/referrer in the CARTO dashboard. Optional: 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'token' set to avoid the watermark.", "carto": { "enabled": true, - "domain": "" + "domain": "", + "token": "" }, "_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.", "osm": { diff --git a/public/customize-v2.js b/public/customize-v2.js index d1d844cfd..79b7494dc 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -1835,7 +1835,10 @@ var modalClosingLine = null; _gfModalMap = L.map(mapDiv, { zoomControl: true }); - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + // #7: resolve through the shared helper so the CARTO Basemaps API key + // (map.tiles.providers.carto.token) is applied here too. The modal is + // opened by user action, long after /api/config/client has landed. + L.tileLayer(window.MC_getCartoTileUrl('/light_all/{z}/{x}/{y}{r}.png'), { attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 }).addTo(_gfModalMap); @@ -2044,7 +2047,8 @@ if (!mapEl || typeof L === 'undefined') return; _gfMap = L.map(mapEl, { zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false }); - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + // #7: same shared-helper resolution as the geo-filter modal above. + L.tileLayer(window.MC_getCartoTileUrl('/light_all/{z}/{x}/{y}{r}.png'), { attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 }).addTo(_gfMap); diff --git a/public/geofilter-builder.html b/public/geofilter-builder.html index 2a55ca840..6b1dad1df 100644 --- a/public/geofilter-builder.html +++ b/public/geofilter-builder.html @@ -85,13 +85,40 @@

GeoFilter Builder

+ + ', start)); + const bootstrap = inline.slice(0, inline.indexOf('let points')); + assert.ok(bootstrap.indexOf('MC_getCartoTileUrl') >= 0, 'fixture extraction failed'); + + const ctx = makeSandbox(); + loadProviders(ctx); + const added = []; + ctx.L = { + map: () => ({ setView: () => ({}), on() {}, removeLayer() {}, addLayer() {} }), + tileLayer: (url) => ({ addTo() { added.push(url); return this; }, setUrl() { return this; } }), + marker: () => ({ addTo() { return this; }, on() { return this; } }), + polygon: () => ({ addTo() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + ctx.window.L = ctx.L; + let resolveFetch; + const gate = new Promise((r) => { resolveFetch = r; }); + ctx.fetch = () => gate.then(() => ({ json: () => Promise.resolve(CFG_TOKEN) })); + ctx.window.fetch = ctx.fetch; + vm.runInContext(bootstrap, ctx, { filename: 'geofilter-builder.html (inline)' }); + + assert.deepStrictEqual(added, [], + 'builder must not add any tile layer before /api/config/client settles; added: ' + JSON.stringify(added)); + + resolveFetch(); + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); + + assert.strictEqual(added.length, 1, 'exactly one layer after config; got ' + added.length); + assert.strictEqual(added[0], 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN, + 'builder first (and only) request must already be keyed; got: ' + added[0]); + }); + + await atest('geofilter-builder falls back to a keyless layer when the config fetch fails', async () => { + const html = fs.readFileSync(P('geofilter-builder.html'), 'utf8'); + const OPEN = '', start)); + const bootstrap = inline.slice(0, inline.indexOf('let points')); + + const ctx = makeSandbox(); + loadProviders(ctx); + const added = []; + ctx.L = { + map: () => ({ setView: () => ({}), on() {}, removeLayer() {}, addLayer() {} }), + tileLayer: (url) => ({ addTo() { added.push(url); return this; }, setUrl() { return this; } }), + marker: () => ({ addTo() { return this; }, on() { return this; } }), + polygon: () => ({ addTo() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + ctx.window.L = ctx.L; + ctx.fetch = () => Promise.reject(new Error('offline')); + ctx.window.fetch = ctx.fetch; + vm.runInContext(bootstrap, ctx, { filename: 'geofilter-builder.html (inline)' }); + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); + + assert.strictEqual(added.length, 1, 'builder must still get exactly one layer when the fetch fails'); + assert.strictEqual(added[0], 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'offline builder must fall back to keyless tiles, not an empty map'); + }); + + await atest('MC_whenTileConfigReady fires once, on settle (resolve AND reject), never twice', async () => { + // resolve + const a = withCarto({ enabled: true, token: FAKE_TOKEN }); + let aN = 0; + let resA; a.window.MeshConfigReady = new Promise(r => { resA = r; }); + a.window.MC_whenTileConfigReady(() => { aN++; }); + assert.strictEqual(aN, 0, 'must not fire before the promise settles'); + resA(); await new Promise(r => setTimeout(r, 0)); + assert.strictEqual(aN, 1, 'must fire exactly once on resolve'); + + // reject + const b = withCarto({ enabled: true, token: FAKE_TOKEN }); + let bN = 0; + let rejB; b.window.MeshConfigReady = new Promise((_, rj) => { rejB = rj; }); + b.window.MeshConfigReady.catch(() => {}); + b.window.MC_whenTileConfigReady(() => { bN++; }); + rejB(new Error('x')); await new Promise(r => setTimeout(r, 0)); + assert.strictEqual(bN, 1, 'must fire exactly once on reject too (settled, not fulfilled)'); + + // absent → synchronous + const c = withCarto({ enabled: true, token: FAKE_TOKEN }); + let cN = 0; + c.window.MC_whenTileConfigReady(() => { cN++; }); + assert.strictEqual(cN, 1, 'must fire synchronously when there is no MeshConfigReady'); + }); + + await atest('test-carto-basemap-key.js is registered exactly once in test-all.sh', async () => { + const sh = fs.readFileSync(path.join(__dirname, 'test-all.sh'), 'utf8'); + const lines = sh.split('\n').filter(l => l.trim() === 'node test-carto-basemap-key.js'); + assert.strictEqual(lines.length, 1, + 'expected exactly one registration line in test-all.sh, got ' + lines.length); + assert.ok(/^set -e$/m.test(sh), 'test-all.sh must keep its set -e semantics'); + const idx = sh.indexOf('node test-carto-basemap-key.js'); + const tileIdx = sh.indexOf('node test-issue-1420-tile-providers.js'); + assert.ok(tileIdx > 0 && Math.abs(sh.slice(0, idx).split('\n').length - sh.slice(0, tileIdx).split('\n').length) <= 3, + 'should sit next to the existing tile-provider tests'); + }); + + await atest('roles.js TILE_DARK/TILE_LIGHT pick up the key once config lands', async () => { + const { ctx, landConfig } = loadRolesStack(CLIENT_CFG_WITH_KEY); + const beforeDark = ctx.window.TILE_DARK; + await landConfig(); + assert.strictEqual(ctx.window.TILE_DARK, + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN); + assert.strictEqual(ctx.window.TILE_LIGHT, + 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN); + assert.notStrictEqual(ctx.window.TILE_DARK, beforeDark, 'the keyless value must not stay frozen'); + }); + + await atest('getTileUrl() returns a keyed URL after config lands (dark + light)', async () => { + const dark = loadRolesStack(CLIENT_CFG_WITH_KEY, 'dark'); + await dark.landConfig(); + const d = dark.ctx.window.getTileUrl(); + assert.ok(d.indexOf('?key=' + FAKE_TOKEN) >= 0, 'dark getTileUrl must be keyed; got: ' + d); + + const light = loadRolesStack(CLIENT_CFG_WITH_KEY, 'light'); + await light.landConfig(); + const l = light.ctx.window.getTileUrl(); + assert.ok(l.indexOf('?key=' + FAKE_TOKEN) >= 0, 'light getTileUrl must be keyed; got: ' + l); + }); + + await atest('an explicit darkUrl/lightUrl override still wins over the CARTO derivation', async () => { + const CUSTOM_D = 'https://tiles.example.com/dark/{z}/{x}/{y}.png'; + const CUSTOM_L = 'https://tiles.example.com/light/{z}/{x}/{y}.png'; + const { ctx, landConfig } = loadRolesStack({ + map: { tiles: { darkUrl: CUSTOM_D, lightUrl: CUSTOM_L, providers: { carto: { enabled: true, token: FAKE_TOKEN } } } } + }); + await landConfig(); + assert.strictEqual(ctx.window.TILE_DARK, CUSTOM_D, 'explicit darkUrl override must be honoured'); + assert.strictEqual(ctx.window.TILE_LIGHT, CUSTOM_L, 'explicit lightUrl override must be honoured'); + }); + + await atest('roles.js stays keyless (no bare "?") when the server sends no token', async () => { + const { ctx, landConfig } = loadRolesStack({ map: { tiles: { providers: { carto: { enabled: true } } } } }); + await landConfig(); + assert.strictEqual(ctx.window.TILE_DARK, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + assert.ok(ctx.window.TILE_DARK.indexOf('?') < 0, 'no bare "?" without a token'); + }); + + console.log('\n#7 CARTO Basemaps API key: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed === 0 ? 0 : 1); +})(); From 29323681aa5edc4690dfc79ce9bdac01d52f9b7c Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 2 Sep 2026 16:16:38 +0200 Subject: [PATCH 2/3] reviewfix(map): align CARTO key with upstream #1919 and harden domain Upstream Kpa-clawbot/CoreScope#1919 merged as 7aa60c03 and named the config field `tiles.providers.carto.key`; ours was `carto.token`. Since this branch has never been pushed or deployed there is no migration to carry, so this is a clean rename to the upstream spelling rather than permanent dual support - one config now works on both forks. Also hardens `carto.domain`, which neither implementation validated. It is concatenated straight into the host, so a bad value escapes it: domain "evil.com/x?a=b" -> https://{s}.evil.com/x?a=b.cartocdn.com/dark_all/... -> host a.evil.com, and with a key set the ?key= suffix is sent THERE, so a config value exfiltrates the key '?' or '#' in the value also smuggle a query ahead of our own suffix and produce a second '?'. `domain` now accepts dot-separated DNS labels only (the documented enterprise form, e.g. "mycompany"), is trimmed, and any other value is ignored with one console warning, falling back to the public base. Valid domains are unaffected. This applies to upstream too. Everything else from 87387762 is kept: MC_getCartoTileUrl remains the one place a CARTO URL is built (verified - the only runtime `cartocdn` occurrence left in public/ is inside _getCartoBase itself), the MC_whenTileConfigReady deferral still prevents a keyless first paint on Map and Live, and roles.js / customize-v2.js / geofilter-builder.html still resolve through the helper. Key handling is unchanged: trimmed, whitespace-only treated as absent, encoded exactly once, and an empty or missing key emits the byte-identical pre-key URL. Tests: 64 passed (was 55), 5 consecutive clean runs. Nine new cases cover valid/dotted/trimmed domains, ten host-escaping values that must be ignored, proof the key never reaches an injected host and that exactly one querystring is emitted, domain+key composition across all five styles, and that the legacy `carto.token` field is now inert in code and absent from the example. The example's claim that the key can be "restricted by origin/referrer" is corrected - CARTO Basemaps keys cannot be - and the domain restriction is documented. Upstream's own suite still passes unchanged (33/33). Full frontend sweep over 300 files is identical to the 87387762 baseline (157/143; the 143 are pre-existing, and test-issue-1470 / test-issue-1438 fail on both trees). --- config.example.json | 4 +- public/customize-v2.js | 2 +- public/live.js | 2 +- public/map-tile-providers.js | 52 +++++++++++--- public/map.js | 2 +- test-carto-basemap-key.js | 129 +++++++++++++++++++++++++++++------ 6 files changed, 154 insertions(+), 37 deletions(-) diff --git a/config.example.json b/config.example.json index 030df3b50..52686310f 100644 --- a/config.example.json +++ b/config.example.json @@ -78,11 +78,11 @@ "darkDefault": "carto-dark", "lightDefault": "carto-light", "providers": { - "_comment_carto": "Carto is the default provider. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a token here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'token' (e.g. 'YOUR_CARTO_BASEMAP_KEY'). WARNING: the token is sent to the browser, so restrict it by origin/referrer in the CARTO dashboard. Optional: 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'token' set to avoid the watermark.", + "_comment_carto": "Carto is the default provider. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a key here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'key' (e.g. 'YOUR_CARTO_BASEMAP_KEY'); the field name matches upstream CoreScope so the same config works on both. WARNING: the key is sent to the browser and CARTO Basemaps keys cannot be restricted by origin or referrer -- treat it as public, use a key dedicated to this deployment, and rotate it if abused. Optional: 'domain' for Carto enterprise -- an enterprise SUBDOMAIN LABEL only, e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'; a value containing a scheme, '/', '?', '#' or whitespace is ignored (it would move the tile host and send 'key' somewhere else). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'key' set to avoid the watermark.", "carto": { "enabled": true, "domain": "", - "token": "" + "key": "" }, "_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.", "osm": { diff --git a/public/customize-v2.js b/public/customize-v2.js index 79b7494dc..35512bfa1 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -1836,7 +1836,7 @@ _gfModalMap = L.map(mapDiv, { zoomControl: true }); // #7: resolve through the shared helper so the CARTO Basemaps API key - // (map.tiles.providers.carto.token) is applied here too. The modal is + // (map.tiles.providers.carto.key) is applied here too. The modal is // opened by user action, long after /api/config/client has landed. L.tileLayer(window.MC_getCartoTileUrl('/light_all/{z}/{x}/{y}{r}.png'), { attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 diff --git a/public/live.js b/public/live.js index 0d63ec92d..023b653fe 100644 --- a/public/live.js +++ b/public/live.js @@ -1473,7 +1473,7 @@ const _liveInitTile = _liveResolveTile(isDark); // #7: same deferral as map.js — the layer is built now but only joins the // map once /api/config/client has settled, so the very first CARTO - // request already carries carto.token instead of being watermarked and + // request already carries carto.key instead of being watermarked and // cached. Map creation, zoom/layer controls and panes are unaffected. let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }); // One idempotent config-ready step for both tile-dependent pieces — see diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 95767493a..5e4529055 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -28,24 +28,56 @@ var _cfg = null; - var _getCartoBase = function() { return (_cfg && _cfg.providers && _cfg.providers.carto && _cfg.providers.carto.domain) ? 'https://{s}.' + _cfg.providers.carto.domain + '.cartocdn.com' : 'https://{s}.basemaps.cartocdn.com'; }; + // `domain` is the CARTO *enterprise subdomain* label only — the documented + // form is 'mycompany' for https://{s}.mycompany.cartocdn.com. It is + // concatenated straight into the host, so an unvalidated value escapes the + // host entirely: 'evil.com/x?a=b' yields + // https://{s}.evil.com/x?a=b.cartocdn.com/dark_all/... + // whose host is {s}.evil.com — and with a key configured the ?key= suffix + // is then sent to THAT host. A '?' or '#' in the value also smuggles a + // query/fragment ahead of our own suffix, producing a second '?'. + // So: accept dot-separated DNS labels only, and ignore anything else + // (falling back to the public base) rather than build a broken or + // key-leaking URL. Trimmed, because a stray space would fail the same way. + var _CARTO_DOMAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/; + var _warnedDomain = false; + var _getCartoDomain = function() { + var d = (_cfg && _cfg.providers && _cfg.providers.carto) ? _cfg.providers.carto.domain : null; + if (typeof d !== 'string') return ''; + d = d.trim(); + if (!d) return ''; + if (!_CARTO_DOMAIN_RE.test(d)) { + if (!_warnedDomain && typeof console !== 'undefined' && console.warn) { + _warnedDomain = true; + console.warn('[tiles] ignoring invalid carto.domain (expected an enterprise subdomain label such as "mycompany"):', d); + } + return ''; + } + return d; + }; + + var _getCartoBase = function() { + var d = _getCartoDomain(); + return d ? 'https://{s}.' + d + '.cartocdn.com' : 'https://{s}.basemaps.cartocdn.com'; + }; // CARTO Basemaps API key (#7). From August 2026 CARTO requires a key on // raster basemap requests; keyless tiles come back watermarked // ("API KEY REQUIRED — carto.com/basemapsapikey"). Never hardcode a key - // here — it comes from map.tiles.providers.carto.token, which the server - // hands to the browser via MC_MAP_CFG. - var _getCartoToken = function() { - var t = (_cfg && _cfg.providers && _cfg.providers.carto) ? _cfg.providers.carto.token : null; - return (typeof t === 'string' && t.trim()) ? t.trim() : ''; + // here — it comes from map.tiles.providers.carto.key, which the server + // hands to the browser via MC_MAP_CFG. The field name matches upstream + // (Kpa-clawbot/CoreScope#1919) so one config works on both. + var _getCartoKey = function() { + var k = (_cfg && _cfg.providers && _cfg.providers.carto) ? _cfg.providers.carto.key : null; + return (typeof k === 'string' && k.trim()) ? k.trim() : ''; }; // Single source of truth for the key querystring, so no style has to // repeat (or drift on) the "?key=" spelling and encoding. Returns the // suffix or an empty string — never a bare "?". var _getCartoKeySuffix = function() { - var t = _getCartoToken(); - return t ? '?key=' + encodeURIComponent(t) : ''; + var k = _getCartoKey(); + return k ? '?key=' + encodeURIComponent(k) : ''; }; // MC_getCartoTileUrl — the ONE place a CARTO tile URL is built, for the @@ -83,7 +115,7 @@ // MC_whenTileConfigReady — run cb once the server config has SETTLED, so a // tile layer is never added to a map (and therefore never fires a request) - // while the CARTO token is still unknown. Resolving the token late is not + // while the CARTO key is still unknown. Resolving the token late is not // enough on its own: Leaflet starts fetching the moment a layer is added, // so a keyless first paint would still hit CARTO and get watermarked tiles // into the browser cache before setUrl() could swap them. @@ -156,7 +188,7 @@ // render CARTO (and still need a token). // - token missing/empty → the styles stay registered and keep the // pre-key, keyless behaviour, which CARTO - // now serves watermarked. Set carto.token + // now serves watermarked. Set carto.key // to clear the watermark. // - token non-empty → every CARTO URL is authenticated. // Registration is not used as a key-enforcement mechanism: the token diff --git a/public/map.js b/public/map.js index 6fd9b8430..c96f901c7 100644 --- a/public/map.js +++ b/public/map.js @@ -362,7 +362,7 @@ // #7: created but deliberately NOT added yet. Leaflet issues tile // requests the moment a layer joins a map, so adding it here would fire // keyless CARTO requests before /api/config/client has delivered - // carto.token — the watermarked tiles would already be in the browser + // carto.key — the watermarked tiles would already be in the browser // cache by the time the URL could be swapped. The map, panes and // controls below are still created immediately; only the first tile // request waits. diff --git a/test-carto-basemap-key.js b/test-carto-basemap-key.js index ebf913c6d..bf72bbff9 100644 --- a/test-carto-basemap-key.js +++ b/test-carto-basemap-key.js @@ -133,13 +133,13 @@ console.log('── #7 CARTO Basemaps API key ──'); // ─── The shared helper ─────────────────────────────────────────────────────── test('MC_getCartoTileUrl is exposed publicly', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN }); assert.strictEqual(typeof ctx.window.MC_getCartoTileUrl, 'function', 'other files (roles.js, customize-v2.js, geofilter-builder.html) depend on this global'); }); test('helper composes base + path + key, with no bare "?" when unkeyed', () => { - const keyed = withCarto({ enabled: true, token: FAKE_TOKEN }); + const keyed = withCarto({ enabled: true, key: FAKE_TOKEN }); assert.strictEqual( keyed.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN); @@ -151,7 +151,7 @@ test('helper composes base + path + key, with no bare "?" when unkeyed', () => { }); test('helper accepts a tile path ONLY — never a complete URL', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN }); const bad = [ 'https://evil.example.com/{z}/{x}/{y}.png', // full URL 'dark_all/{z}/{x}/{y}.png', // no leading slash @@ -168,7 +168,7 @@ test('helper accepts a tile path ONLY — never a complete URL', () => { // ─── Registry styles ───────────────────────────────────────────────────────── test('token is appended to ALL five CARTO registry styles', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN }); for (const id of ALL_CARTO_IDS) { assert.ok(urlFor(ctx, id).indexOf('?key=' + FAKE_TOKEN) >= 0, id + ' must carry ?key=; got: ' + urlFor(ctx, id)); @@ -176,7 +176,7 @@ test('token is appended to ALL five CARTO registry styles', () => { }); test('each CARTO style keeps its own distinct tile path alongside the key', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN }); const expectPath = { 'carto-dark': '/dark_all/', 'carto-light': '/light_all/', 'carto-voyager': '/rastertiles/voyager/', 'carto-voyager-dark': '/rastertiles/voyager/', @@ -192,7 +192,7 @@ test('each CARTO style keeps its own distinct tile path alongside the key', () = // ─── Encoding ──────────────────────────────────────────────────────────────── test('token is URL-encoded exactly once', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN_NEEDING_ENCODING }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN_NEEDING_ENCODING }); const expected = '?key=' + encodeURIComponent(FAKE_TOKEN_NEEDING_ENCODING); for (const id of ALL_CARTO_IDS) { const url = urlFor(ctx, id); @@ -206,7 +206,7 @@ test('token is URL-encoded exactly once', () => { }); test('token is trimmed before encoding', () => { - const ctx = withCarto({ enabled: true, token: ' ' + FAKE_TOKEN + ' ' }); + const ctx = withCarto({ enabled: true, key: ' ' + FAKE_TOKEN + ' ' }); assert.ok(urlFor(ctx, 'carto-dark').endsWith('?key=' + FAKE_TOKEN), 'surrounding whitespace must be trimmed, not encoded as %20'); }); @@ -224,7 +224,7 @@ test('no carto config at all → registered, no querystring (back-compat)', () = test('empty / whitespace / non-string token → no bare "?" left behind', () => { for (const tok of ['', ' ', 123, null, true, {}, []]) { - const ctx = withCarto({ enabled: true, token: tok }); + const ctx = withCarto({ enabled: true, key: tok }); for (const id of ALL_CARTO_IDS) { const url = urlFor(ctx, id); assert.ok(url.indexOf('?') < 0, id + ' with token ' + JSON.stringify(tok) + ' must not emit "?": ' + url); @@ -241,7 +241,7 @@ test('enabled=true without a token still registers CARTO (no hidden-layer mode)' }); test('enabled=false still removes CARTO, even with a token present', () => { - const ctx = withCarto({ enabled: false, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: false, key: FAKE_TOKEN }); for (const id of ALL_CARTO_IDS) { assert.ok(!ctx.window.MC_TILE_PROVIDERS[id], id + ' must be absent when carto.enabled=false'); } @@ -250,7 +250,7 @@ test('enabled=false still removes CARTO, even with a token present', () => { // ─── Domain override ───────────────────────────────────────────────────────── test('enterprise domain override composes on every CARTO path, keyed and unkeyed', () => { - const withKey = withCarto({ enabled: true, domain: 'mycompany', token: FAKE_TOKEN }); + const withKey = withCarto({ enabled: true, domain: 'mycompany', key: FAKE_TOKEN }); const noKey = withCarto({ enabled: true, domain: 'mycompany' }); for (const id of ALL_CARTO_IDS) { const a = urlFor(withKey, id); @@ -270,7 +270,7 @@ test('enterprise domain override composes on every CARTO path, keyed and unkeyed // ─── Querystring shape + provider isolation ────────────────────────────────── test('never emits a double "?" or a duplicate key parameter', () => { - const ctx = withCarto({ enabled: true, domain: 'mycompany', token: FAKE_TOKEN_NEEDING_ENCODING }); + const ctx = withCarto({ enabled: true, domain: 'mycompany', key: FAKE_TOKEN_NEEDING_ENCODING }); for (const id of ALL_CARTO_IDS) { const url = urlFor(ctx, id); assert.strictEqual(url.split('?').length - 1, 1, id + ' must contain exactly one "?": ' + url); @@ -280,7 +280,7 @@ test('never emits a double "?" or a duplicate key parameter', () => { test('CARTO token never leaks onto Esri / OSM / Stamen URLs', () => { const ctx = withCarto( - { enabled: true, token: FAKE_TOKEN }, + { enabled: true, key: FAKE_TOKEN }, { osm: { enabled: true, provider: 'maptiler', token: 'OSM_FAKE_TOKEN' }, stamen: { enabled: true, token: 'STAMEN_FAKE_TOKEN' } } ); @@ -295,7 +295,7 @@ test('CARTO token never leaks onto Esri / OSM / Stamen URLs', () => { // ─── Unrelated behaviour must not regress ──────────────────────────────────── test('dark/light defaults, switching, labels, attribution and filters unchanged', () => { - const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const ctx = withCarto({ enabled: true, key: FAKE_TOKEN }); assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-dark'); assert.strictEqual(ctx.window.MC_getLightTileProvider(), 'carto-light'); assert.strictEqual(ctx.window.MC_setDarkTileProvider('carto-voyager-dark'), true); @@ -327,7 +327,7 @@ test('async config load swaps the early keyless registry URL for the keyed one', const before = urlFor(ctx, 'carto-dark'); assert.ok(before.indexOf('?') < 0, 'no key before config arrives'); - ctx.window.MC_MAP_CFG = { tiles: { providers: { carto: { enabled: true, token: FAKE_TOKEN } } } }; + ctx.window.MC_MAP_CFG = { tiles: { providers: { carto: { enabled: true, key: FAKE_TOKEN } } } }; ctx.window.MC_initTileRegistry(true); const after = urlFor(ctx, 'carto-dark'); @@ -364,7 +364,7 @@ function loadRolesStack(clientCfg, theme) { return { ctx, landConfig: () => { resolveFetch(); return ctx.window.MeshConfigReady; } }; } -const CLIENT_CFG_WITH_KEY = { map: { tiles: { providers: { carto: { enabled: true, token: FAKE_TOKEN } } } } }; +const CLIENT_CFG_WITH_KEY = { map: { tiles: { providers: { carto: { enabled: true, key: FAKE_TOKEN } } } } }; test('roles.js TILE_DARK/TILE_LIGHT are keyless before config, and never frozen literals', () => { const { ctx } = loadRolesStack(CLIENT_CFG_WITH_KEY); @@ -475,8 +475,8 @@ test('no real API key is hardcoded in production source or fixtures', () => { } const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8')); const carto = cfg.map.tiles.providers.carto; - assert.ok(Object.prototype.hasOwnProperty.call(carto, 'token'), 'carto.token must be documented in the example'); - assert.strictEqual(carto.token, '', 'config.example.json must ship an EMPTY carto token'); + assert.ok(Object.prototype.hasOwnProperty.call(carto, 'key'), 'carto.key must be documented in the example'); + assert.strictEqual(carto.key, '', 'config.example.json must ship an EMPTY carto token'); assert.ok(!Object.prototype.hasOwnProperty.call(carto, 'requireKey'), 'requireKey must be gone from the example'); assert.ok(readPub('map-tile-providers.js').indexOf('requireKey') < 0, 'requireKey must be gone from production code'); }); @@ -574,7 +574,7 @@ function runTileInit(which, opts) { return { ctx, added, created, controlBuilds, baseLayerChangeHandlers, settle: settle || (() => {}), wait: () => new Promise(r => setTimeout(r, 0)).then(() => new Promise(r => setTimeout(r, 0))) }; } -const CFG_TOKEN = { map: { tiles: { providers: { carto: { enabled: true, token: FAKE_TOKEN } } } } }; +const CFG_TOKEN = { map: { tiles: { providers: { carto: { enabled: true, key: FAKE_TOKEN } } } } }; const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } } }; // ─── Async-dependent checks ────────────────────────────────────────────────── @@ -773,7 +773,7 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } await atest('MC_whenTileConfigReady fires once, on settle (resolve AND reject), never twice', async () => { // resolve - const a = withCarto({ enabled: true, token: FAKE_TOKEN }); + const a = withCarto({ enabled: true, key: FAKE_TOKEN }); let aN = 0; let resA; a.window.MeshConfigReady = new Promise(r => { resA = r; }); a.window.MC_whenTileConfigReady(() => { aN++; }); @@ -782,7 +782,7 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } assert.strictEqual(aN, 1, 'must fire exactly once on resolve'); // reject - const b = withCarto({ enabled: true, token: FAKE_TOKEN }); + const b = withCarto({ enabled: true, key: FAKE_TOKEN }); let bN = 0; let rejB; b.window.MeshConfigReady = new Promise((_, rj) => { rejB = rj; }); b.window.MeshConfigReady.catch(() => {}); @@ -791,7 +791,7 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } assert.strictEqual(bN, 1, 'must fire exactly once on reject too (settled, not fulfilled)'); // absent → synchronous - const c = withCarto({ enabled: true, token: FAKE_TOKEN }); + const c = withCarto({ enabled: true, key: FAKE_TOKEN }); let cN = 0; c.window.MC_whenTileConfigReady(() => { cN++; }); assert.strictEqual(cN, 1, 'must fire synchronously when there is no MeshConfigReady'); @@ -836,7 +836,7 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } const CUSTOM_D = 'https://tiles.example.com/dark/{z}/{x}/{y}.png'; const CUSTOM_L = 'https://tiles.example.com/light/{z}/{x}/{y}.png'; const { ctx, landConfig } = loadRolesStack({ - map: { tiles: { darkUrl: CUSTOM_D, lightUrl: CUSTOM_L, providers: { carto: { enabled: true, token: FAKE_TOKEN } } } } + map: { tiles: { darkUrl: CUSTOM_D, lightUrl: CUSTOM_L, providers: { carto: { enabled: true, key: FAKE_TOKEN } } } } }); await landConfig(); assert.strictEqual(ctx.window.TILE_DARK, CUSTOM_D, 'explicit darkUrl override must be honoured'); @@ -850,6 +850,91 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } assert.ok(ctx.window.TILE_DARK.indexOf('?') < 0, 'no bare "?" without a token'); }); + // ─── carto.domain hardening ────────────────────────────────────────────── + // `domain` is concatenated straight into the host, so an unvalidated value + // escapes the host and (with a key set) sends the key somewhere else. + // Upstream Kpa-clawbot/CoreScope#1919 has the same unvalidated _getCartoBase. + + test('a valid enterprise domain still builds the documented host', () => { + const ctx = withCarto({ enabled: true, domain: 'mycompany' }); + const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + assert.strictEqual(u, 'https://{s}.mycompany.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + }); + + test('a dotted enterprise domain is still accepted', () => { + const ctx = withCarto({ enabled: true, domain: 'eu.mycompany' }); + assert.ok(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png') + .indexOf('https://{s}.eu.mycompany.cartocdn.com/') === 0); + }); + + test('domain is trimmed', () => { + const ctx = withCarto({ enabled: true, domain: ' mycompany ' }); + assert.strictEqual(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.mycompany.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + }); + + test('a domain that would move the tile host is ignored', () => { + const BAD = ['evil.com/x?a=b', 'foo?a=b', 'foo#frag', 'https://evil.com', + 'foo bar', 'evil.com@real', '../evil', 'foo/', '?a=b', '//evil.com']; + for (const d of BAD) { + const ctx = withCarto({ enabled: true, domain: d }); + const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + assert.strictEqual(u, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'domain ' + JSON.stringify(d) + ' must fall back to the public base, got: ' + u); + } + }); + + test('an injected domain can never receive the key', () => { + for (const d of ['evil.com/x?a=b', 'https://evil.com', 'foo?a=b']) { + const ctx = withCarto({ enabled: true, domain: d, key: FAKE_TOKEN }); + const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + const host = u.replace('{s}', 'a').split('/')[2]; + assert.strictEqual(host, 'a.basemaps.cartocdn.com', + 'key must never be sent to an injected host, got: ' + u); + assert.ok(u.indexOf('?key=' + FAKE_TOKEN) > 0, 'the key still reaches the real host: ' + u); + assert.strictEqual(u.split('?').length, 2, 'exactly one querystring: ' + u); + } + }); + + test('a valid domain and the key compose on every style', () => { + const ctx = withCarto({ enabled: true, domain: 'mycompany', key: FAKE_TOKEN }); + for (const id of ALL_CARTO_IDS) { + const u = urlFor(ctx, id); + assert.ok(u.indexOf('https://{s}.mycompany.cartocdn.com/') === 0, id + ': ' + u); + assert.ok(u.indexOf('?key=' + FAKE_TOKEN) > 0, id + ' missing key: ' + u); + assert.strictEqual(u.split('?').length, 2, id + ' has more than one querystring: ' + u); + } + }); + + // ─── clean rename: token -> key (no permanent dual support) ────────────── + + test('the legacy carto.token field is NOT honoured', () => { + const ctx = withCarto({ enabled: true, token: FAKE_TOKEN }); + const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + assert.strictEqual(u, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'carto.token must be inert after the rename to carto.key, got: ' + u); + }); + + test('production source no longer reads carto.token', () => { + const src = readPub('map-tile-providers.js'); + assert.ok(src.indexOf('carto.token') < 0, 'map-tile-providers.js still mentions carto.token'); + assert.ok(/providers\.carto\.key/.test(src) || /_cfg\.providers\.carto\) \? _cfg\.providers\.carto\.key/.test(src), + 'map-tile-providers.js must read carto.key'); + }); + + test('config.example.json exposes key and no longer exposes token', () => { + const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8')); + const carto = cfg.map.tiles.providers.carto; + assert.ok(Object.prototype.hasOwnProperty.call(carto, 'key'), 'carto.key must exist'); + assert.ok(!Object.prototype.hasOwnProperty.call(carto, 'token'), 'carto.token must be gone'); + assert.strictEqual(carto.key, '', 'the shipped example must carry an EMPTY key'); + const cmt = cfg.map.tiles.providers._comment_carto; + assert.ok(/'key'/.test(cmt), 'the comment must name the key field'); + assert.ok(!/restrict it by origin\/referrer/i.test(cmt), + 'the comment must not claim CARTO basemap keys can be origin/referrer restricted'); + assert.ok(/SUBDOMAIN LABEL/i.test(cmt), 'the comment must document the domain restriction'); + }); + console.log('\n#7 CARTO Basemaps API key: ' + passed + ' passed, ' + failed + ' failed'); process.exit(failed === 0 ? 0 : 1); })(); From 3efa31e6ae023ea80b81ea82c755b2ce12512636 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 2 Sep 2026 16:32:37 +0200 Subject: [PATCH 3/3] reviewfix(map): neutral key guidance, quiet warning, DNS-correct domain Four review findings on top of 29323681. 1. config.example.json asserted "CARTO Basemaps keys cannot be restricted by origin or referrer". I could not document that from CARTO's current basemap-key interface, and the previous wording asserted the opposite just as confidently. Replaced with guidance that holds either way: follow any domain/referrer restrictions offered when CARTO issues the key, use a key dedicated to this deployment, monitor its usage, and rotate it if abused. 2. Finished the token -> key rename in prose. Six comments in map-tile-providers.js plus one each in live.js and roles.js still said "token" while describing carto.key. OSM and Stamen keep "token" - that is their actual field name - and a test now asserts both halves so the rename cannot regress or over-reach. 3. The invalid-domain warning echoed the rejected value. That value is operator input of unknown provenance and the line can reach shared logs or a screenshot, so it now names only the expected form. Still exactly one warning per page, still falling back to the public base. 4. The validator accepted over-long labels: the regex had no length rule, so a 64-character label - or a 300-character value - built a host that is invalid per DNS. Labels are now capped at 63 and the whole value at 238, which keeps "a." + domain + ".cartocdn.com" inside the 253-character host limit. Empty labels are rejected explicitly, which also covers leading/trailing dots and '..' runs. This is URL and misconfiguration hardening, not an authorization boundary: the value comes from the operator's own config.json, and the point is that a typo or a pasted full URL degrades to the public base instead of silently retargeting tiles and the key at another host. Runtime semantics and the config-ready lifecycle are unchanged: MC_getCartoTileUrl is still the single URL builder, MC_whenTileConfigReady still defers the first attach on Map and Live, and key handling (trim, whitespace-only as absent, encoded once, byte-identical keyless URL) is untouched. carto.token stays inert. Tests: 74 passed (was 64), 5 consecutive clean runs. Ten new cases cover the 63/64-character label boundary, an over-long overall value, empty labels and leading/trailing dots and hyphens, an internal hyphen still being valid, the warning never reprinting the rejected value (asserted with a value carrying a fake secret), at most one warning, no warning at all for a blank domain, a static guard against carto.token and _getCartoToken returning to any production file, the example carrying neither the field nor the wording while OSM keeps its own, and the absence of any unverifiable claim about key restrictions in either direction. Against 29323681 exactly these four fail: the 64-character label, the over-long value, the value-echoing warning and the "cannot be restricted" claim. Upstream's suite still passes unchanged (33/33). Full frontend sweep over 300 files identical to the 29323681 baseline (157/143). --- config.example.json | 2 +- public/live.js | 2 +- public/map-tile-providers.js | 45 +++++++++++---- public/roles.js | 2 +- test-carto-basemap-key.js | 109 +++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) diff --git a/config.example.json b/config.example.json index 52686310f..191431af4 100644 --- a/config.example.json +++ b/config.example.json @@ -78,7 +78,7 @@ "darkDefault": "carto-dark", "lightDefault": "carto-light", "providers": { - "_comment_carto": "Carto is the default provider. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a key here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'key' (e.g. 'YOUR_CARTO_BASEMAP_KEY'); the field name matches upstream CoreScope so the same config works on both. WARNING: the key is sent to the browser and CARTO Basemaps keys cannot be restricted by origin or referrer -- treat it as public, use a key dedicated to this deployment, and rotate it if abused. Optional: 'domain' for Carto enterprise -- an enterprise SUBDOMAIN LABEL only, e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'; a value containing a scheme, '/', '?', '#' or whitespace is ignored (it would move the tile host and send 'key' somewhere else). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'key' set to avoid the watermark.", + "_comment_carto": "Carto is the default provider. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a key here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'key' (e.g. 'YOUR_CARTO_BASEMAP_KEY'); the field name matches upstream CoreScope so the same config works on both. WARNING: the key is sent to the browser. Follow any domain/referrer restrictions offered when CARTO issues the key, use a key dedicated to this deployment, monitor its usage, and rotate it if abused. Optional: 'domain' for Carto enterprise -- dot-separated enterprise SUBDOMAIN LABELS only, e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'. A value containing a scheme, '/', '?', '#' or whitespace, or with empty/over-long labels, is ignored and the public Carto base is used instead (it would otherwise move the tile host and send 'key' there). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'key' set to avoid the watermark.", "carto": { "enabled": true, "domain": "", diff --git a/public/live.js b/public/live.js index 023b653fe..417353263 100644 --- a/public/live.js +++ b/public/live.js @@ -1480,7 +1480,7 @@ // the equivalent block in map.js for the reasoning. The layer picker is // built here rather than immediately because it materialises a real // L.tileLayer per registry style at build time; building it before the - // token is known would offer the user selectable keyless CARTO layers. + // key is known would offer the user selectable keyless CARTO layers. let _liveTilesReady = false; function _liveAttachTiles() { if (_liveTilesReady) return; // exactly once per map init diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 5e4529055..a4fb833f6 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -39,17 +39,42 @@ // So: accept dot-separated DNS labels only, and ignore anything else // (falling back to the public base) rather than build a broken or // key-leaking URL. Trimmed, because a stray space would fail the same way. - var _CARTO_DOMAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/; + // + // This is URL/misconfiguration hardening, not an authorization boundary: + // `domain` comes from the operator's own config.json, so the point is that + // a typo or a copy-pasted full URL degrades to the public base instead of + // silently retargeting tile requests (and the key) at another host. + // + // Lengths follow DNS: each label at most 63 characters, and the whole value + // capped so the final host stays inside the 253-character limit — + // "a." + + ".cartocdn.com" is len + 15. + var _CARTO_LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/; + var _CARTO_LABEL_MAX = 63; + var _CARTO_DOMAIN_MAX = 238; + var _isValidCartoDomain = function(d) { + if (d.length > _CARTO_DOMAIN_MAX) return false; + var labels = d.split('.'); + for (var i = 0; i < labels.length; i++) { + var l = labels[i]; + // Rejects empty labels, which also covers a leading/trailing dot + // and any '..' run. + if (!l || l.length > _CARTO_LABEL_MAX || !_CARTO_LABEL_RE.test(l)) return false; + } + return true; + }; var _warnedDomain = false; var _getCartoDomain = function() { var d = (_cfg && _cfg.providers && _cfg.providers.carto) ? _cfg.providers.carto.domain : null; if (typeof d !== 'string') return ''; d = d.trim(); - if (!d) return ''; - if (!_CARTO_DOMAIN_RE.test(d)) { + if (!d) return ''; // unset/blank is normal — no warning + if (!_isValidCartoDomain(d)) { if (!_warnedDomain && typeof console !== 'undefined' && console.warn) { _warnedDomain = true; - console.warn('[tiles] ignoring invalid carto.domain (expected an enterprise subdomain label such as "mycompany"):', d); + // The rejected value is deliberately NOT echoed: it is operator input + // of unknown provenance and this line can end up in shared logs or a + // screenshot. The expected form is enough to fix the config. + console.warn('[tiles] ignoring invalid carto.domain; expected dot-separated enterprise subdomain labels such as "mycompany"'); } return ''; } @@ -115,7 +140,7 @@ // MC_whenTileConfigReady — run cb once the server config has SETTLED, so a // tile layer is never added to a map (and therefore never fires a request) - // while the CARTO key is still unknown. Resolving the token late is not + // while the CARTO key is still unknown. Resolving the key late is not // enough on its own: Leaflet starts fetching the moment a layer is added, // so a keyless first paint would still hit CARTO and get watermarked tiles // into the browser cache before setUrl() could swap them. @@ -177,7 +202,7 @@ _cfg = (typeof window !== 'undefined' && window.MC_MAP_CFG && window.MC_MAP_CFG.tiles) ? window.MC_MAP_CFG.tiles : null; // CARTO gating (#7) — unchanged from before the API-key work, and - // deliberately independent of the token: + // deliberately independent of the key: // - carto.enabled === false → the CARTO styles are not registered, so // they leave the main map and the layer // picker. It does NOT disable CARTO @@ -185,13 +210,13 @@ // customize-v2.js and the standalone // geofilter-builder call // MC_getCartoTileUrl directly and still - // render CARTO (and still need a token). - // - token missing/empty → the styles stay registered and keep the + // render CARTO (and still need a key). + // - key missing/empty → the styles stay registered and keep the // pre-key, keyless behaviour, which CARTO // now serves watermarked. Set carto.key // to clear the watermark. - // - token non-empty → every CARTO URL is authenticated. - // Registration is not used as a key-enforcement mechanism: the token + // - key non-empty → every CARTO URL is authenticated. + // Registration is not used as a key-enforcement mechanism: the key // question is answered by MC_getCartoTileUrl, which every CARTO surface // now goes through. var HAS_CARTO = !_cfg || !_cfg.providers || !_cfg.providers.carto || _cfg.providers.carto.enabled !== false; diff --git a/public/roles.js b/public/roles.js index 423fc2197..dffe0180f 100644 --- a/public/roles.js +++ b/public/roles.js @@ -594,7 +594,7 @@ // Resolving on read instead of on parse sidesteps that entirely. // - async config: MC_MAP_CFG only arrives with the // /api/config/client fetch below, so a value frozen now would never - // pick up the token. Every read re-resolves, so the first read after + // pick up the key. Every read re-resolves, so the first read after // config lands returns the keyed URL with no re-assignment plumbing. // Assignment is still supported (see the cfg.tiles.dark / map.tiles.darkUrl // overrides below): setting the property pins an explicit URL and stops diff --git a/test-carto-basemap-key.js b/test-carto-basemap-key.js index bf72bbff9..4baa7d102 100644 --- a/test-carto-basemap-key.js +++ b/test-carto-basemap-key.js @@ -935,6 +935,115 @@ const CFG_NO_TOKEN = { map: { tiles: { providers: { carto: { enabled: true } } } assert.ok(/SUBDOMAIN LABEL/i.test(cmt), 'the comment must document the domain restriction'); }); + // ─── domain length contract (DNS) ──────────────────────────────────────── + + test('a 63-character label is accepted, 64 is not', () => { + const ok = 'a'.repeat(63), tooLong = 'a'.repeat(64); + assert.strictEqual(withCarto({ enabled: true, domain: ok }) + .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.' + ok + '.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + assert.strictEqual(withCarto({ enabled: true, domain: tooLong }) + .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'a 64-character label exceeds the DNS label limit and must be ignored'); + }); + + test('an over-long overall domain is ignored', () => { + const many = Array(60).fill('abcd').join('.'); // 299 chars + assert.ok(many.length > 238); + assert.strictEqual(withCarto({ enabled: true, domain: many }) + .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + }); + + test('empty labels, leading/trailing dots and hyphens are ignored', () => { + for (const d of ['.mycompany', 'mycompany.', 'my..company', '-mycompany', + 'mycompany-', 'eu.-my', 'eu.my-', '.', '..']) { + assert.strictEqual(withCarto({ enabled: true, domain: d }) + .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'domain ' + JSON.stringify(d) + ' must be ignored'); + } + }); + + test('an internal hyphen is still allowed', () => { + assert.strictEqual(withCarto({ enabled: true, domain: 'my-company' }) + .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.my-company.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + }); + + // ─── the warning must not echo operator input, and fires once ──────────── + + test('the invalid-domain warning never reprints the rejected value', () => { + const SECRETISH = 'https://evil.example/leak?token=SUPERSECRET'; + const ctx = makeSandbox(); + const seen = []; + ctx.console = Object.assign({}, ctx.console, { warn: (...a) => seen.push(a.map(String).join(' ')) }); + loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: SECRETISH } } } }); + ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + const joined = seen.join('\n'); + assert.ok(joined.indexOf('carto.domain') >= 0, 'expected a warning, got: ' + JSON.stringify(seen)); + assert.ok(joined.indexOf(SECRETISH) < 0, 'the warning must not echo the value: ' + joined); + assert.ok(joined.indexOf('SUPERSECRET') < 0, 'no fragment of the value may leak: ' + joined); + assert.ok(joined.indexOf('evil.example') < 0, 'no fragment of the value may leak: ' + joined); + assert.ok(/mycompany/.test(joined), 'the warning should show the expected form'); + }); + + test('the invalid-domain warning is emitted at most once', () => { + const ctx = makeSandbox(); + let n = 0; + ctx.console = Object.assign({}, ctx.console, { warn: (...a) => { if (/carto\.domain/.test(a.map(String).join(' '))) n++; } }); + loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: 'evil.com/x' } } } }); + for (let i = 0; i < 12; i++) ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'); + for (const id of ALL_CARTO_IDS) urlFor(ctx, id); + assert.strictEqual(n, 1, 'expected exactly one warning, got ' + n); + }); + + test('a blank or whitespace-only domain warns not at all', () => { + for (const d of ['', ' ', '\t']) { + const ctx = makeSandbox(); + let n = 0; + ctx.console = Object.assign({}, ctx.console, { warn: (...a) => { if (/carto\.domain/.test(a.map(String).join(' '))) n++; } }); + loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: d } } } }); + assert.strictEqual(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'), + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'); + assert.strictEqual(n, 0, 'blank domain must not warn (' + JSON.stringify(d) + ')'); + } + }); + + // ─── static guards: carto.token must not come back ─────────────────────── + + test('no production source mentions carto.token or _getCartoToken', () => { + for (const f of ['map-tile-providers.js', 'roles.js', 'customize-v2.js', 'map.js', 'live.js', 'geofilter-builder.html']) { + const src = readPub(f); + assert.ok(src.indexOf('carto.token') < 0, f + ' still mentions carto.token'); + assert.ok(src.indexOf('_getCartoToken') < 0, f + ' still defines/uses _getCartoToken'); + } + }); + + test('config.example.json carries no carto token field or wording', () => { + const raw = fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8'); + const cfg = JSON.parse(raw); + const carto = cfg.map.tiles.providers.carto; + assert.ok(!Object.prototype.hasOwnProperty.call(carto, 'token'), 'carto.token must be gone'); + assert.ok(Object.prototype.hasOwnProperty.call(carto, 'key'), 'carto.key must exist'); + const cmt = cfg.map.tiles.providers._comment_carto; + assert.ok(!/\btoken\b/i.test(cmt), 'the carto comment must not say "token": ' + cmt.slice(0, 120)); + // OSM/Stamen keep their own token wording — make sure we did not over-rename. + assert.ok(/token/i.test(cfg.map.tiles.providers._comment_osm), 'the OSM comment should still say token'); + }); + + test('the example makes no unverifiable claim about key restrictions', () => { + const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8')); + const cmt = cfg.map.tiles.providers._comment_carto; + assert.ok(!/cannot be restricted/i.test(cmt), 'must not assert restrictions are unavailable'); + assert.ok(!/restrict it by origin\/referrer in the CARTO dashboard/i.test(cmt), + 'must not assert restrictions are available either'); + assert.ok(/Follow any domain\/referrer restrictions offered when CARTO issues the key/.test(cmt), + 'expected the neutral guidance'); + assert.ok(/rotate it if abused/i.test(cmt), 'expected rotation guidance'); + }); + console.log('\n#7 CARTO Basemaps API key: ' + passed + ' passed, ' + failed + ' failed'); process.exit(failed === 0 ? 0 : 1); })();