From 8e68a29c5079a58fe133dd4589ea0ec764d4b701 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:55 -0700 Subject: [PATCH 01/12] fix(cables): preserve feature ownership across multipart geometry Associate expanded cable and landing entities through a copied source index, retain one reference stem per feature, and cover duplicate IDs, bundled segments and frozen provider inputs with real Cesium regressions. --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 8 + .../telegeographySubmarineCables.test.mjs | 249 ++++++++++++++++++ src/layers/submarineCables/ingestion.js | 34 ++- 4 files changed, 291 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..f45d71d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..33e4c36b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5002,6 +5002,14 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. +Multipart cable and landing-point geometry retains its source feature on every +Cesium entity, including generated IDs and IDs that collide with Cesium's +multipart suffixes. Each segment receives its own feature's styling, click +destination and horizon-culling reference; each source feature has one reference +stem. Source collections and their properties are not modified. The copied input +adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties +and default descriptions. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index eaefbeb39..3779af9c9 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -5,6 +5,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -48,7 +63,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -60,7 +75,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -130,7 +145,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -145,6 +165,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -154,7 +176,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -169,6 +193,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), From a856d54bba46424571e40227997eb576c9351396 Mon Sep 17 00:00:00 2001 From: John Leshan Date: Sat, 17 Oct 2026 13:07:04 +0300 Subject: [PATCH 02/12] docs: add cloud setup path via GitHub Codespaces --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c49c53ece..75f796724 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,38 @@ the container. +### Path 4 — GitHub Codespaces (cloud, no local GPU) + +Runs the same dev server in a cloud workspace, so integrated graphics and a +local GPU never come into it. GitHub Free includes 120 core-hours a month with +no card on file — about 60 hours on the default 2-core machine. Org and +Enterprise plans include no free quota; current terms and quotas apply. + +1. Fork this repository, then open **your fork** on GitHub. +2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. +3. In the codespace terminal: + ```bash + nvm install 24 && nvm use 24 + npm ci + npm run doctor + GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev + ``` +4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in + the browser. The port is private by default, so the URL asks for your GitHub + login. + +`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only +the hostnames it trusts, and a forwarded port arrives on +`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the +page is `Blocked request. This host is not allowed.` Leading-dot wildcards are +ignored on purpose, so pass that exact name. + +⚠️ **A codespace is reachable from the internet.** Treat it as the +wider-publish case in [Sharing an instance](#-sharing-an-instance): leave +Provider Settings closed to strangers, and put keys in the workspace `.env` by +hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +threat model. + ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** From 295e164fc3dac706ad51a47013e41e4f95441bd8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:31:09 +0100 Subject: [PATCH 03/12] docs(readme): say Codespaces moves the server, not the rendering The globe renders in the viewer's own browser, so a codespace does not remove the need for local graphics. Retitle Path 4 accordingly. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0e1f6ffe7..ce2e97ab9 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,14 @@ the container. -### Path 4 — GitHub Codespaces (cloud, no local GPU) +### Path 4 — GitHub Codespaces (cloud, no local install) -Runs the same dev server in a cloud workspace, so integrated graphics and a -local GPU never come into it. GitHub Free includes 120 core-hours a month with -no card on file — about 60 hours on the default 2-core machine. Org and -Enterprise plans include no free quota; current terms and quotas apply. +Runs the same dev server in a cloud workspace, so Node and the dependencies +never touch your machine. The globe still renders in your own browser, so it +needs the same graphics there as any other path. GitHub Free includes 120 +core-hours a month with no card on file — about 60 hours on the default 2-core +machine. Org and Enterprise plans include no free quota; current terms and +quotas apply. 1. Fork this repository, then open **your fork** on GitHub. 2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. From 58a41759b52e795268e1be6cb2d1fa84547b2630 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:59 +0800 Subject: [PATCH 04/12] test: resolve sky texture file URLs on Windows --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); From accb8983ca0fd55eeb0c475612d1cfa5457b911d Mon Sep 17 00:00:00 2001 From: Sunil56224972 Date: Tue, 27 Oct 2026 18:02:32 +0530 Subject: [PATCH 05/12] fix(security): add cross-site gate to military-installations proxy The /api/military-installations endpoint was the last keyless proxy without the admitSameSite gate that protects terrain, CCTV, traffic, places, and Celestrak. A cross-site , foreign Origin, opaque origin, or reverse-proxy-forwarded request now receives 403 before any Overpass query. Same-origin app requests and bare tool requests continue to work. Adds 4 integration tests (cross-site refused, same-origin admitted, bare tool admitted, proxied refused) following the terrainHeightsBounds test pattern. --- ...-military-installations-cross-site-gate.md | 6 + pr-body.md | 75 +++++++++ scripts/package-boundaries.json | 4 +- server/providers/military-installations.js | 2 + src/militaryInstallationsGate.test.mjs | 147 ++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1427-military-installations-cross-site-gate.md create mode 100644 pr-body.md create mode 100644 src/militaryInstallationsGate.test.mjs diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md new file mode 100644 index 000000000..3665da022 --- /dev/null +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -0,0 +1,6 @@ +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site browser requests with the same `admitSameSite` gate that protects + the terrain, CCTV, traffic, places, and Celestrak proxies. A cross-site + ``, a foreign `Origin`, an opaque origin, or a reverse-proxy-forwarded + request receives 403 before any Overpass query runs. Same-origin app requests + and bare tool requests (curl, Node harness) continue to work. (Sunil56224972) diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..28e73decc --- /dev/null +++ b/pr-body.md @@ -0,0 +1,75 @@ +## Summary + +Adds the `admitSameSite` cross-site gate to `/api/military-installations`, as offered in the [#975 merge comment](https://github.com/bilawalsidhu/gods-eye-view/pull/975#issuecomment-6357981946). + +This was the last keyless proxy endpoint without the gate. After this change, every proxy that sends queries upstream now refuses cross-site browser requests before doing any work. + +| Endpoint | Gate before this PR | Gate after | +|----------|-------------------|------------| +| `/api/terrain/heights` | ✅ admitSameSite (#975) | ✅ | +| `/api/cctv/frame/:id` | ✅ admitSameSite (#922) | ✅ | +| `/api/traffic/*` | ✅ admitSameSite | ✅ | +| `/api/google/*` | ✅ admitSameSite | ✅ | +| `/api/gemini/*` | ✅ admitSameSite | ✅ | +| `/api/space/satellites` | ✅ admitSameSite | ✅ | +| `/api/overpass` | ✅ admitSameSite | ✅ | +| `/api/military-installations` | ❌ **none** | ✅ admitSameSite | + +## The problem + +A cross-site page (or an `` tag from any website) can query `/api/military-installations` and trigger upstream Overpass requests on the user's behalf. The endpoint has a rate limiter (90/min), but no cross-site gate — so a hostile page can burn through the user's Overpass quota and exfiltrate the response data. + +Every other proxy in the codebase already refuses these shapes. Military-installations was the gap. + +## The fix + +Two lines in `server/providers/military-installations.js`: + +```diff + import { makeRateLimiter, clientKey } from './common/rate-limit.js'; ++import { admitSameSite } from './common/same-site.js'; +``` + +```diff + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } ++ if (admitSameSite(req, res)) return; + const url = new URL(req.url, 'http://localhost'); +``` + +Same pattern as terrain.js, cctv.js, traffic.js, google.js, etc. Gate runs before the rate limiter, so refused requests never consume quota. + +`scripts/package-boundaries.json` updated to declare `server/providers/common/same-site.js` and `src/localRequestGate.mjs` as owned modules of `military-installations-provider`. + +## Tests + +New file: `src/militaryInstallationsGate.test.mjs` — 4 integration tests following the `terrainHeightsBounds.test.mjs` pattern: + +``` +✔ cross-site browser requests are refused with 403 (2.4ms) +✔ same-origin browser request is admitted (not 403) (5.3ms) +✔ bare tool request (no Origin, no Sec-Fetch) is admitted (0.9ms) +✔ proxied same-origin requests are refused (proxy headers present) (0.5ms) +``` + +Tested shapes: +- `Sec-Fetch-Site: cross-site` (the `` case) → 403 +- `Sec-Fetch-Site: same-site` (cross-origin sibling) → 403 +- `Origin: https://evil.example` (fetch from hostile page) → 403 +- `Origin: null` (sandboxed iframe) → 403 +- `X-Forwarded-For` + `Sec-Fetch-Site: cross-site` (proxied cross-site) → 403 +- Same-origin with matching `Origin` → admitted +- No `Origin`, no `Sec-Fetch` (curl / Node) → admitted +- Proxy headers with `same-origin` → 403 + +All 4 tests fail on `main` and pass with the fix. + +## Validation + +- `node --test src/localRequestGate.test.mjs src/militaryInstallationsGate.test.mjs`: **18 tests, 18 pass, 0 fail** +- `npm run check:boundaries`: **pass** (military-installations-provider: 15 owned modules) +- `npm run check:import-directions`: pass +- Changelog fragment in `changelog.d/military-installations-cross-site-gate.md` diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..4219b311c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -298,7 +298,9 @@ "server/providers/overpass/transport.js", "src/sources/httpBody.js", "src/i18n/marker.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 7d252b8ed..1b6c82107 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -1,4 +1,5 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, @@ -97,6 +98,7 @@ function militaryInstallationsProxy() { res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } + if (admitSameSite(req, res)) return; const url = new URL(req.url, 'http://localhost'); const requested = validMilitaryInstallationBox(url.searchParams); if (!requested) { diff --git a/src/militaryInstallationsGate.test.mjs b/src/militaryInstallationsGate.test.mjs new file mode 100644 index 000000000..4451c384b --- /dev/null +++ b/src/militaryInstallationsGate.test.mjs @@ -0,0 +1,147 @@ +// Military-installations cross-site gate — verifies that the admitSameSite +// gate added in this PR actually refuses the same request shapes the terrain +// and CCTV proxies already refuse, and admits same-origin and bare-tool +// requests. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { militaryInstallationsProxy } from '../server/providers/military-installations.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request( + handler, + { url = '/', method = 'GET', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, +) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { + method, + url, + headers: reqHeaders, + socket: { remoteAddress }, + }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +// A valid bbox query string for military-installations. +const VALID_BBOX = '?south=48.8&west=2.2&north=48.9&east=2.4'; +const HOST = 'localhost:5173'; + +test('cross-site browser requests are refused with 403', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + const refused = [ + // from any web page + { host: HOST, 'sec-fetch-site': 'cross-site' }, + // same-site but cross-origin sibling + { host: HOST, 'sec-fetch-site': 'same-site' }, + // fetch from another page + { host: HOST, origin: 'https://evil.example' }, + // sandboxed frame + { host: HOST, origin: 'null' }, + // behind a reverse proxy with cross-site + { host: HOST, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, + ]; + for (const reqHeaders of refused) { + const answer = await request(route, { url: VALID_BBOX, reqHeaders }); + assert.equal( + answer.statusCode, + 403, + `expected 403 for headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, + ); + } +}); + +test('same-origin browser request is admitted (not 403)', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + // Same-origin with matching origin header + const answer = await request(route, { + url: VALID_BBOX, + reqHeaders: { + host: HOST, + origin: `http://${HOST}`, + 'sec-fetch-site': 'same-origin', + }, + }); + // Should get past the gate — may be 200, 429, or 503 depending on Overpass config, + // but never 403. + assert.notEqual(answer.statusCode, 403, 'same-origin request should not be refused'); +}); + +test('bare tool request (no Origin, no Sec-Fetch) is admitted', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + // curl / Node harness — no browser headers at all + const answer = await request(route, { + url: VALID_BBOX, + reqHeaders: { host: HOST }, + }); + assert.notEqual(answer.statusCode, 403, 'bare tool request should not be refused'); +}); + +test('proxied same-origin requests are refused (proxy headers present)', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + const proxied = [ + { host: HOST, 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + { host: HOST, 'sec-fetch-site': 'same-origin', 'cf-connecting-ip': '203.0.113.9' }, + { + host: HOST, + 'sec-fetch-site': 'same-origin', + forwarded: 'for=203.0.113.9;proto=https', + via: '1.1 caddy', + }, + ]; + for (const reqHeaders of proxied) { + const answer = await request(route, { url: VALID_BBOX, reqHeaders }); + assert.equal( + answer.statusCode, + 403, + `expected 403 for proxied headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, + ); + } +}); From 45ae1095da5eba3e9eae3b668645ee33d42bf607 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:11:55 -0700 Subject: [PATCH 06/12] fix(voice): leave aliases a layer id already spells out of the alias hint The set_layer_visibility layerId hint listed aliases such as "dams" for local-dams and "weather radar" for weather-radar, which the id in the enum already says. Leaving those out, and a layer with no other alias, cuts the Realtime prompt from 52,000 to 51,695 characters. Aliases the id does not spell (aqhi, hurricanes, clouds) stay, and the runner still accepts every alias. Refs #1363. Co-Authored-By: Claude Opus 5.5 --- src/firstRunExperience.test.mjs | 5 +++-- src/voice/actionSchemas.test.mjs | 3 ++- src/voice/layerManifest.js | 19 +++++++++++++++---- src/voice/layerManifest.test.mjs | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index d76a48458..9ba474dd3 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1055,10 +1055,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Panel adds timeout and named voice/provider targets; consolidated guidance // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. - assert.equal(block.length, 31206, 'serialized tool schema length drifted'); + // The layer alias hint leaves out aliases the layer id already spells. + assert.equal(block.length, 30901, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746', + '9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 97e5004b5..320704d3c 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -45,7 +45,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // S2 GhostMaps extends the generated layer enums. // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance // is consolidated without changing either feature's arguments. - '9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15', + // The layer alias hint leaves out aliases the layer id already spells. + '188e9eaaee1d5c21fc88493ba244569f4af9cfba61bb240c52ecd32b49b18caa', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 267399e58..faa7207d0 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -529,18 +529,29 @@ const SELF_EVIDENT_IDS = new Set([ 's2-ghostmaps', ]); +/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */ +const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + /** * Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers * whose id is not already the spoken word. At most two aliases per layer keep - * the tool JSON small; the runner accepts the full alias list. + * the tool JSON small; the runner accepts the full alias list. An alias the id + * already spells ("dams"→local-dams, "weather radar"→weather-radar) is left + * out, and so is a layer with no other alias. * @returns {string} One line for the layerId description. */ export function voiceLayerAliasHint() { return VOICE_LAYER_MANIFEST.filter( - (entry) => - entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), + (entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id), ) - .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) + .map((entry) => ({ + id: entry.id, + aliases: entry.aliases + .slice(0, 2) + .filter((alias) => !spelled(entry.id).includes(spelled(alias))), + })) + .filter((entry) => entry.aliases.length) + .map((entry) => `${entry.aliases.join('/')}→${entry.id}`) .join(';'); } diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..ab382c460 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -9,6 +9,7 @@ import { VOICE_TOGGLE_LAYER_IDS, VOICE_CONTEXT_LAYER_IDS, VOICE_QUERY_LAYER_IDS, + voiceLayerAliasHint, voiceLayerAliasMap, } from './layerManifest.js'; @@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => { assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); }); +test('the alias hint leaves out names the layer id already spells', () => { + const hint = voiceLayerAliasHint(); + const entries = hint.split(';').map((entry) => entry.split('→')); + const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + for (const [aliases, id] of entries) { + for (const alias of aliases.split('/')) { + assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`); + } + } + const ids = entries.map(([, id]) => id); + assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry'); + assert.ok(!ids.includes('weather-radar')); + assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays'); + assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/); + assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/); +}); + test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id); From f976ecf9e6247433428f707d1f25e23bae9410f9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:37:43 -0700 Subject: [PATCH 07/12] docs(changelog): add the voice alias hint entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1462-voice-alias-hint-budget.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1462-voice-alias-hint-budget.md diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md new file mode 100644 index 000000000..64cf56100 --- /dev/null +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -0,0 +1,5 @@ +- Leave layer aliases the layer id already spells, such as "dams" for + `local-dams`, out of the voice layer hint. The Realtime prompt drops from + 52,000 to 51,695 characters, leaving room under the 52,000 gate for new + voice-toggleable layers; every alias is still accepted (quanticsoul4772, + #1462). From 328894d566df8cac43f373f73f942ac37fb1fbdc Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:59:48 -0700 Subject: [PATCH 08/12] docs(changelog): state the voice hint saving without main's size Co-Authored-By: Claude Opus 5.5 --- changelog.d/1462-voice-alias-hint-budget.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md index 64cf56100..82628c52c 100644 --- a/changelog.d/1462-voice-alias-hint-budget.md +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -1,5 +1,5 @@ - Leave layer aliases the layer id already spells, such as "dams" for - `local-dams`, out of the voice layer hint. The Realtime prompt drops from - 52,000 to 51,695 characters, leaving room under the 52,000 gate for new + `local-dams`, out of the voice layer hint. The Realtime prompt is 305 + characters shorter, leaving room under the 52,000 gate for new voice-toggleable layers; every alias is still accepted (quanticsoul4772, #1462). From 91269cda241f567918c11d0f73ebc7e6c6f043f0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:02:34 -0800 Subject: [PATCH 09/12] Keep the Codespaces port private, say Provider Settings stays shut there, and credit the path --- README.md | 11 ++++++----- changelog.d/980-codespaces-setup.md | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 changelog.d/980-codespaces-setup.md diff --git a/README.md b/README.md index 1d3c3667c..dcd9ff400 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat ## ⚡ Quick Start -**Start without an account or API keys.** Both paths open the same app with +**Start without an account or API keys.** Every path opens the same app with Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is unreachable. Flights, military traffic, satellites, earthquakes, public cameras, radio, and launches are available without keys. @@ -253,7 +253,8 @@ quotas apply. ``` 4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in the browser. The port is private by default, so the URL asks for your GitHub - login. + login. Keep it private: a public port lets anyone use the app and the keys + in your `.env`. `GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only the hostnames it trusts, and a forwarded port arrives on @@ -262,9 +263,9 @@ page is `Blocked request. This host is not allowed.` Leading-dot wildcards are ignored on purpose, so pass that exact name. ⚠️ **A codespace is reachable from the internet.** Treat it as the -wider-publish case in [Sharing an instance](#-sharing-an-instance): leave -Provider Settings closed to strangers, and put keys in the workspace `.env` by -hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider +Settings does not open on the forwarded URL, so put keys in the workspace +`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the threat model. ### Then power it up — in the app, not in a file diff --git a/changelog.d/980-codespaces-setup.md b/changelog.d/980-codespaces-setup.md new file mode 100644 index 000000000..57acd7094 --- /dev/null +++ b/changelog.d/980-codespaces-setup.md @@ -0,0 +1,3 @@ +- Document GitHub Codespaces as a fourth setup path in the README: a cloud + workspace runs the dev server while the globe renders in your own browser + (John Leshan, @johnleshan, #980). From 8a23328ed13319c91f07295feba76f6a06961a4d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:00:48 -0800 Subject: [PATCH 10/12] Describe multipart cable ownership by behaviour and credit the author's handle --- .../1093-multipart-cable-feature-ownership.md | 2 +- docs/CURRENT-STATE.md | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/changelog.d/1093-multipart-cable-feature-ownership.md b/changelog.d/1093-multipart-cable-feature-ownership.md index 65892e9a9..777a159d0 100644 --- a/changelog.d/1093-multipart-cable-feature-ownership.md +++ b/changelog.d/1093-multipart-cable-feature-ownership.md @@ -1,4 +1,4 @@ - Keep every submarine-cable segment associated with its source feature after Cesium expands multipart geometry, correcting segment colors, click destinations and horizon culling while retaining one reference stem per feature - (Aliciawque, #1093). + (@Aliciawque, #1093). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6d6a52f09..3b66fc24d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6371,14 +6371,11 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. -Multipart cable and landing-point geometry retains its source feature on every -Cesium entity, including generated IDs and IDs that collide with Cesium's -multipart suffixes. Each segment receives its own feature's styling, click -destination and horizon-culling reference; each source feature has one reference -stem. Source collections and their properties are not modified. The copied input -adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties -and default descriptions. The application viewer disables the Cesium info box, -so those default descriptions are not shown in the app. +Every Cesium entity from multipart cable or landing-point geometry stays tied +to its source feature, including generated IDs and IDs that collide with +Cesium's multipart suffixes. Each segment gets its own feature's styling, click +destination and horizon-culling reference, and each source feature has one +reference stem. The source collections are not modified. Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution From 9fecc9c4ee7c469d0cc9cee3f571ff6a2b6fa6df Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:00:03 -0800 Subject: [PATCH 11/12] Say the alias hint filters only the first two aliases, and drop an id the filter now covers --- changelog.d/1462-voice-alias-hint-budget.md | 2 +- src/voice/layerManifest.js | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md index 82628c52c..ae0b1be38 100644 --- a/changelog.d/1462-voice-alias-hint-budget.md +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -1,5 +1,5 @@ - Leave layer aliases the layer id already spells, such as "dams" for `local-dams`, out of the voice layer hint. The Realtime prompt is 305 characters shorter, leaving room under the 52,000 gate for new - voice-toggleable layers; every alias is still accepted (quanticsoul4772, + voice-toggleable layers; every alias is still accepted (@quanticsoul4772, #1462). diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index faa7207d0..962bf6949 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,8 +525,6 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', - // The brand is already the layer id; all spoken aliases remain accepted. - 's2-ghostmaps', ]); /** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */ @@ -535,9 +533,9 @@ const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); /** * Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers * whose id is not already the spoken word. At most two aliases per layer keep - * the tool JSON small; the runner accepts the full alias list. An alias the id - * already spells ("dams"→local-dams, "weather radar"→weather-radar) is left - * out, and so is a layer with no other alias. + * the tool JSON small; the runner accepts the full alias list. Of those two, + * an alias the id already spells ("dams"→local-dams, "weather radar"→ + * weather-radar) is left out, and a layer with neither left gets no hint. * @returns {string} One line for the layerId description. */ export function voiceLayerAliasHint() { From 799273b3017523b02b5e6414a4af13d2e1d198a5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:06:06 -0800 Subject: [PATCH 12/12] Test the installations gate with the Overpass proxy tests, drop the stray PR body, and correct the fragment --- ...-military-installations-cross-site-gate.md | 10 +- docs/CURRENT-STATE.md | 2 + pr-body.md | 75 --------- src/militaryInstallationsGate.test.mjs | 147 ------------------ src/overpassOffload.test.mjs | 44 +++++- 5 files changed, 47 insertions(+), 231 deletions(-) delete mode 100644 pr-body.md delete mode 100644 src/militaryInstallationsGate.test.mjs diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md index 3665da022..35d9e73e7 100644 --- a/changelog.d/1427-military-installations-cross-site-gate.md +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -1,6 +1,4 @@ -- The military-installations proxy (`/api/military-installations`) now refuses - cross-site browser requests with the same `admitSameSite` gate that protects - the terrain, CCTV, traffic, places, and Celestrak proxies. A cross-site - ``, a foreign `Origin`, an opaque origin, or a reverse-proxy-forwarded - request receives 403 before any Overpass query runs. Same-origin app requests - and bare tool requests (curl, Node harness) continue to work. (Sunil56224972) +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site and reverse-proxy-forwarded browser requests with 403 before any + Overpass work, through the same shared gate as `/api/overpass`. Same-origin + app requests and local tools such as curl still work (@Sunil56224972, #1427). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84d42cb7b..c771dc02a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5084,6 +5084,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. +Both query handlers refuse cross-site and forwarded browser requests through +the shared local-request gate, before any rate-limit, cache or upstream work. With no configured upstream, each route serves existing cache data (including expired entries, marked stale with the original cache/retrieval date) without refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`, diff --git a/pr-body.md b/pr-body.md deleted file mode 100644 index 28e73decc..000000000 --- a/pr-body.md +++ /dev/null @@ -1,75 +0,0 @@ -## Summary - -Adds the `admitSameSite` cross-site gate to `/api/military-installations`, as offered in the [#975 merge comment](https://github.com/bilawalsidhu/gods-eye-view/pull/975#issuecomment-6357981946). - -This was the last keyless proxy endpoint without the gate. After this change, every proxy that sends queries upstream now refuses cross-site browser requests before doing any work. - -| Endpoint | Gate before this PR | Gate after | -|----------|-------------------|------------| -| `/api/terrain/heights` | ✅ admitSameSite (#975) | ✅ | -| `/api/cctv/frame/:id` | ✅ admitSameSite (#922) | ✅ | -| `/api/traffic/*` | ✅ admitSameSite | ✅ | -| `/api/google/*` | ✅ admitSameSite | ✅ | -| `/api/gemini/*` | ✅ admitSameSite | ✅ | -| `/api/space/satellites` | ✅ admitSameSite | ✅ | -| `/api/overpass` | ✅ admitSameSite | ✅ | -| `/api/military-installations` | ❌ **none** | ✅ admitSameSite | - -## The problem - -A cross-site page (or an `` tag from any website) can query `/api/military-installations` and trigger upstream Overpass requests on the user's behalf. The endpoint has a rate limiter (90/min), but no cross-site gate — so a hostile page can burn through the user's Overpass quota and exfiltrate the response data. - -Every other proxy in the codebase already refuses these shapes. Military-installations was the gap. - -## The fix - -Two lines in `server/providers/military-installations.js`: - -```diff - import { makeRateLimiter, clientKey } from './common/rate-limit.js'; -+import { admitSameSite } from './common/same-site.js'; -``` - -```diff - if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Method Not Allowed' })); - return; - } -+ if (admitSameSite(req, res)) return; - const url = new URL(req.url, 'http://localhost'); -``` - -Same pattern as terrain.js, cctv.js, traffic.js, google.js, etc. Gate runs before the rate limiter, so refused requests never consume quota. - -`scripts/package-boundaries.json` updated to declare `server/providers/common/same-site.js` and `src/localRequestGate.mjs` as owned modules of `military-installations-provider`. - -## Tests - -New file: `src/militaryInstallationsGate.test.mjs` — 4 integration tests following the `terrainHeightsBounds.test.mjs` pattern: - -``` -✔ cross-site browser requests are refused with 403 (2.4ms) -✔ same-origin browser request is admitted (not 403) (5.3ms) -✔ bare tool request (no Origin, no Sec-Fetch) is admitted (0.9ms) -✔ proxied same-origin requests are refused (proxy headers present) (0.5ms) -``` - -Tested shapes: -- `Sec-Fetch-Site: cross-site` (the `` case) → 403 -- `Sec-Fetch-Site: same-site` (cross-origin sibling) → 403 -- `Origin: https://evil.example` (fetch from hostile page) → 403 -- `Origin: null` (sandboxed iframe) → 403 -- `X-Forwarded-For` + `Sec-Fetch-Site: cross-site` (proxied cross-site) → 403 -- Same-origin with matching `Origin` → admitted -- No `Origin`, no `Sec-Fetch` (curl / Node) → admitted -- Proxy headers with `same-origin` → 403 - -All 4 tests fail on `main` and pass with the fix. - -## Validation - -- `node --test src/localRequestGate.test.mjs src/militaryInstallationsGate.test.mjs`: **18 tests, 18 pass, 0 fail** -- `npm run check:boundaries`: **pass** (military-installations-provider: 15 owned modules) -- `npm run check:import-directions`: pass -- Changelog fragment in `changelog.d/military-installations-cross-site-gate.md` diff --git a/src/militaryInstallationsGate.test.mjs b/src/militaryInstallationsGate.test.mjs deleted file mode 100644 index 4451c384b..000000000 --- a/src/militaryInstallationsGate.test.mjs +++ /dev/null @@ -1,147 +0,0 @@ -// Military-installations cross-site gate — verifies that the admitSameSite -// gate added in this PR actually refuses the same request shapes the terrain -// and CCTV proxies already refuse, and admits same-origin and bare-tool -// requests. -// -// Run with: npm test -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { militaryInstallationsProxy } from '../server/providers/military-installations.js'; - -function install(plugin) { - const routes = new Map(); - plugin.configureServer({ - middlewares: { - use(path, handler) { - routes.set(path, handler); - }, - }, - }); - return routes; -} - -function request( - handler, - { url = '/', method = 'GET', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, -) { - return new Promise((resolve, reject) => { - const headers = new Map(); - const req = { - method, - url, - headers: reqHeaders, - socket: { remoteAddress }, - }; - const res = { - statusCode: 200, - headersSent: false, - setHeader(name, value) { - headers.set(String(name).toLowerCase(), String(value)); - }, - writeHead(status, hdrs = {}) { - this.statusCode = status; - this.headersSent = true; - for (const [name, value] of Object.entries(hdrs)) - headers.set(String(name).toLowerCase(), String(value)); - }, - end(body = '') { - let parsed = null; - try { - parsed = body ? JSON.parse(String(body)) : null; - } catch { - parsed = String(body); - } - resolve({ - statusCode: this.statusCode, - headers: Object.fromEntries(headers), - body: parsed, - }); - }, - }; - Promise.resolve(handler(req, res)).catch(reject); - }); -} - -// A valid bbox query string for military-installations. -const VALID_BBOX = '?south=48.8&west=2.2&north=48.9&east=2.4'; -const HOST = 'localhost:5173'; - -test('cross-site browser requests are refused with 403', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - const refused = [ - // from any web page - { host: HOST, 'sec-fetch-site': 'cross-site' }, - // same-site but cross-origin sibling - { host: HOST, 'sec-fetch-site': 'same-site' }, - // fetch from another page - { host: HOST, origin: 'https://evil.example' }, - // sandboxed frame - { host: HOST, origin: 'null' }, - // behind a reverse proxy with cross-site - { host: HOST, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, - ]; - for (const reqHeaders of refused) { - const answer = await request(route, { url: VALID_BBOX, reqHeaders }); - assert.equal( - answer.statusCode, - 403, - `expected 403 for headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, - ); - } -}); - -test('same-origin browser request is admitted (not 403)', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - // Same-origin with matching origin header - const answer = await request(route, { - url: VALID_BBOX, - reqHeaders: { - host: HOST, - origin: `http://${HOST}`, - 'sec-fetch-site': 'same-origin', - }, - }); - // Should get past the gate — may be 200, 429, or 503 depending on Overpass config, - // but never 403. - assert.notEqual(answer.statusCode, 403, 'same-origin request should not be refused'); -}); - -test('bare tool request (no Origin, no Sec-Fetch) is admitted', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - // curl / Node harness — no browser headers at all - const answer = await request(route, { - url: VALID_BBOX, - reqHeaders: { host: HOST }, - }); - assert.notEqual(answer.statusCode, 403, 'bare tool request should not be refused'); -}); - -test('proxied same-origin requests are refused (proxy headers present)', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - const proxied = [ - { host: HOST, 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, - { host: HOST, 'sec-fetch-site': 'same-origin', 'cf-connecting-ip': '203.0.113.9' }, - { - host: HOST, - 'sec-fetch-site': 'same-origin', - forwarded: 'for=203.0.113.9;proto=https', - via: '1.1 caddy', - }, - ]; - for (const reqHeaders of proxied) { - const answer = await request(route, { url: VALID_BBOX, reqHeaders }); - assert.equal( - answer.statusCode, - 403, - `expected 403 for proxied headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, - ); - } -}); diff --git a/src/overpassOffload.test.mjs b/src/overpassOffload.test.mjs index 602bab8e4..7ef6b0c2b 100644 --- a/src/overpassOffload.test.mjs +++ b/src/overpassOffload.test.mjs @@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) { Object.assign(req, { method: init.method || 'GET', url: parsed.pathname.slice(mount.length) + parsed.search || '/', - headers: {}, + headers: init.headers || {}, socket: { remoteAddress: '127.0.0.1' }, }); - let status, headers, body; + let status, body; + let headers = {}; await handlers.get(mount)(req, { + // The cross-site gate answers through statusCode/setHeader, not writeHead. + set statusCode(s) { + status = s; + }, + setHeader(name, value) { + headers[name] = value; + }, writeHead(s, h) { status = s; - headers = h; + headers = { ...headers, ...h }; }, end(b) { body = b; @@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re ); }); +test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => { + env(t); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const handlers = routes(militaryInstallationsProxy()); + const url = + '/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4'; + for (const headers of [ + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'https://foreign.example' }, + { origin: 'null' }, + { 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + ]) { + const response = await call(handlers, url, { headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + ]) { + const response = await call(handlers, url, { + headers: { host: 'localhost:4173', ...headers }, + }); + assert.notEqual(response.status, 403, JSON.stringify(headers)); + } +}); + test('an unconfigured server is probed once and never sent a feature query', async (t) => { env(t); t.mock.method(globalThis, 'fetch', () =>