diff --git a/config.example.json b/config.example.json index b6f967315..191431af4 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 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": "" + "domain": "", + "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 d1d844cfd..35512bfa1 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.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 }).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, key: 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, key: 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, key: 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, key: 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'); + }); + + // ─── 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'); + }); + + // ─── 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); +})();