From c6b43a365d3081e199ddce7b052b08b27568a62b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 16:50:30 +0700 Subject: [PATCH] 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', + ); +});