From c6b43a365d3081e199ddce7b052b08b27568a62b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 16:50:30 +0700 Subject: [PATCH 1/2] fix(tiles): derive the tile origin from the configured TileJSON (#933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createVectorTileSource` took `tileJsonUrl` and `allowedOrigin` as independent options, and `createOpenFreeMapSource` hardcoded both to the default host. A caller that repointed only `tileJsonUrl` — the obvious single change — kept the default `allowedOrigin`, so the TileJSON's absolute upstream `tiles[0]` passed the origin check and every tile was fetched from the default host instead. No error, no warning, and nothing reached the configured mirror. `allowedOrigin` now defaults to the origin of `tileJsonUrl`, so repointing one option is enough, and a mirror that serves the default host's TileJSON verbatim fails with the existing `Invalid vector tile origin` instead of silently bypassing itself. The origin check is unchanged: a TileJSON still cannot point the client at a host the caller did not configure. All three existing callers pass `allowedOrigin` explicitly (`openFreeMap.js`, `alpr/source.js`, `traffic/flowSource.js`), and an explicit value still overrides the derived one, so behaviour is unchanged for every current call site. --- src/sources/openFreeMap.js | 11 +++++++-- src/sources/openFreeMap.test.mjs | 38 +++++++++++++++++++++++++++++ src/sources/vectorTiles.js | 18 +++++++++++++- src/sources/vectorTiles.test.mjs | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 3f40af1e8..58208a1fd 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -214,11 +214,18 @@ function decodeLayers(bytes, z, x, y, layerNames) { return { roads, military }; } -/** Construct an immutable-version road/military tile source without starting I/O. */ +/** + * Construct an immutable-version road/military tile source without starting I/O. + * + * `allowedOrigin` is deliberately not set here: `createVectorTileSource` + * derives it from whichever `tileJsonUrl` is in effect, so a caller that + * repoints only `tileJsonUrl` gets tiles from that host or a named error — + * never a silent fall back to the default host (#933). Passing + * `allowedOrigin` explicitly still overrides both. + */ export function createOpenFreeMapSource(options = {}) { return createVectorTileSource({ tileJsonUrl: 'https://tiles.openfreemap.org/planet', - allowedOrigin: 'https://tiles.openfreemap.org', decode: decodeOpenFreeMapTile, maxEntries: 192, maxCacheBytes: 64 * 1024 * 1024, diff --git a/src/sources/openFreeMap.test.mjs b/src/sources/openFreeMap.test.mjs index f2a91098c..47a835917 100644 --- a/src/sources/openFreeMap.test.mjs +++ b/src/sources/openFreeMap.test.mjs @@ -320,3 +320,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri assert.ok(records.some((r) => r.area === area && !r.drivable)); } }); + +test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => { + const urls = []; + const mirror = 'http://127.0.0.1:12348'; + const source = createOpenFreeMapSource({ + fetchImpl: async (url) => { + urls.push(url); + return url.endsWith('/planet') + ? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] }) + : new Response(fixture('ofm-austin-14-3743-6745.pbf')); + }, + tileJsonUrl: `${mirror}/planet`, + }); + await source.fetchBounds( + { south: 30.264, north: 30.271, west: -97.75, east: -97.74 }, + { zoom: 14 }, + ); + assert.ok(urls.length > 1, 'tiles were fetched'); + assert.deepEqual( + urls.filter((url) => !url.startsWith(mirror)), + [], + 'a repointed source must not fetch from tiles.openfreemap.org', + ); +}); + +test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => { + // A transparent caching proxy returns upstream's TileJSON unchanged, whose + // tiles[0] is an absolute upstream URL. That must be an error, not 100% of + // tile traffic quietly leaving the configured mirror. + const source = createOpenFreeMapSource({ + fetchImpl: async () => + Response.json({ + tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'], + }), + tileJsonUrl: 'http://127.0.0.1:12348/planet', + }); + await assert.rejects(source.getMetadata(), { retryable: false }); +}); diff --git a/src/sources/vectorTiles.js b/src/sources/vectorTiles.js index 7eef0d3e7..6a90f4aaf 100644 --- a/src/sources/vectorTiles.js +++ b/src/sources/vectorTiles.js @@ -16,11 +16,27 @@ export function validTileBounds(box) { ); } +/** + * The origin a configured TileJSON is served from, or undefined when there is + * no absolute URL to read one off. Deriving `allowedOrigin` from it means + * repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from + * the old host: the origin check below then names the mismatch instead of + * passing it (#933). + */ +function tileJsonOrigin(tileJsonUrl) { + if (typeof tileJsonUrl !== 'string') return undefined; + try { + return new URL(tileJsonUrl).origin; + } catch { + return undefined; + } +} + /** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */ export function createVectorTileSource({ tileJsonUrl, template, - allowedOrigin, + allowedOrigin = tileJsonOrigin(tileJsonUrl), decode, fetchImpl = (...args) => globalThis.fetch(...args), maxTiles = 16, diff --git a/src/sources/vectorTiles.test.mjs b/src/sources/vectorTiles.test.mjs index bfc366964..92bde2f70 100644 --- a/src/sources/vectorTiles.test.mjs +++ b/src/sources/vectorTiles.test.mjs @@ -203,3 +203,45 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue 'over-budget selection is rejected before fetching', ); }); + +test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => { + // Without an explicit allowedOrigin the mirror's own TileJSON is honoured… + const mirrored = createVectorTileSource({ + tileJsonUrl: 'http://127.0.0.1:12348/planet', + decode: () => [], + fetchImpl: async () => + Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }), + }); + assert.equal( + (await mirrored.getMetadata()).template, + 'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf', + ); + + // …while a TileJSON that names a different host is refused rather than + // silently fetched, which is what the origin check is for. + const foreign = createVectorTileSource({ + tileJsonUrl: 'http://127.0.0.1:12348/planet', + decode: () => [], + fetchImpl: async () => + Response.json({ + tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'], + }), + }); + await assert.rejects(foreign.getMetadata(), { retryable: false }); +}); + +test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => { + const source = createVectorTileSource({ + tileJsonUrl: 'http://127.0.0.1:12348/planet', + allowedOrigin: 'https://tiles.example', + decode: () => [], + fetchImpl: async () => + Response.json({ + tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'], + }), + }); + assert.equal( + (await source.getMetadata()).template, + 'https://tiles.example/planet/{z}/{x}/{y}.pbf', + ); +}); From 4f2b304ea31c973e300d59e0cf618bd9f5bc2ef3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 8 Oct 2026 10:44:56 -0700 Subject: [PATCH 2/2] docs: note tile origin follows tileJsonUrl and credit #935 Co-Authored-By: Claude --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 747c6227d..64232fa29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Vector tile sources take their allowed tile origin from the configured + `tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the + mirror or fails visibly instead of silently using OpenFreeMap. Thanks to + [daikaginza](https://github.com/daikaginza) (#935). + - MCP setup examples use the app's default port, `4173`. Thanks to [Huanyi Xie](https://github.com/xiehuanyi) (#934) and [shirunjies8-png](https://github.com/shirunjies8-png) (#959) for the same fix. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee3bfbdb2..e216fab72 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4704,6 +4704,10 @@ easier to meet (detection is now on more often), but does not create it. shown dot against the allowed source classes and records phase timings. Positions and segment distances are precomputed, with no new animation-loop allocation. Visible-globe roads use cached terrain without offscreen mesh picks. The road-source label names the geometry being drawn, with partial/unavailable states shown plainly. +- Vector tile sources accept tiles only from an allowed origin, which defaults + to the configured `tileJsonUrl` origin; an explicit `allowedOrigin` overrides + it. Repointing only `tileJsonUrl` loads tiles from that host or fails with an + origin error rather than silently falling back to OpenFreeMap. - TileJSON caches successful metadata. Transient failures retry after a five-second cooldown; invalid metadata/origins stay unavailable until the source is cleared. Clear resets metadata and cancels pending requests.