diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e1af1ed50..610986341 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,9 @@ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js", "server/providers/common/rate-limit.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index ca9442db1..b2615094e 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -9,6 +9,7 @@ import { validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -25,7 +26,9 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; * Bounds, because every distinct 5dp point is its own key: coordinates must be * in WGS-84 range (rejected at parse), the point cache has an entry ceiling * with oldest-first eviction, an over-large cache file is not read back, and - * the route carries an always-on per-client limiter. + * the route carries an always-on per-client limiter. The route also refuses + * cross-site browser requests, so another web page cannot use the visitor's + * browser to fill the cache or spend the visitor's own rate-limit quota. */ export function terrainHeightsProxy(options = {}) { // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` @@ -231,6 +234,10 @@ export function terrainHeightsProxy(options = {}) { const installMiddleware = (server) => { server.middlewares.use('/api/terrain/heights', async (req, res) => { + // Refuse cross-site browser requests (an or fetch from another + // site) before the limiter, cache or upstream see them. Same-origin app + // requests and non-browser loopback callers (MCP panel_request) pass. + if (admitSameSite(req, res)) return; const send = (status, bodyObj) => { if (res.headersSent) return; res.writeHead(status, { 'Content-Type': 'application/json' }); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 9cc1ff9c7..35845ee88 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -26,6 +26,9 @@ function fixture(name, overrides = {}, preview = false) { // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, + // terrain.js gates cross-site requests first; fixture requests carry no + // browser headers, so admit them like a loopback tool. + admitSameSite: () => false, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 1a0c80290..83edc262d 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -29,10 +29,18 @@ function install(plugin) { return routes; } -function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { +function request( + handler, + { url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, +) { return new Promise((resolve, reject) => { const headers = new Map(); - const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const req = { + method: 'GET', + url, + headers: reqHeaders, + socket: { remoteAddress }, + }; const res = { statusCode: 200, headersSent: false, @@ -207,6 +215,40 @@ test('the route refuses a flood of requests from one client', async (t) => { assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); }); +test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { + const asked = stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const host = 'localhost:5173'; + const refused = [ + { host, 'sec-fetch-site': 'cross-site' }, // from any web page + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://evil.example' }, // fetch from another page + { host, origin: 'null' }, // sandboxed frame + { host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy + ]; + // More refused requests than the 90/min quota, each a distinct new key. + for (let i = 0; i < 100; i += 1) { + const reqHeaders = refused[i % refused.length]; + const answer = await request(route, { + url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`, + reqHeaders, + }); + assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders)); + } + assert.deepEqual(asked, [], 'nothing reached upstream or the cache'); + + // The visitor's own app still has its full quota afterwards, and a + // loopback tool that sends no browser headers is admitted too. + const app = await request(route, { + url: '/?points=-97.74,30.26', + reqHeaders: { host, 'sec-fetch-site': 'same-origin' }, + }); + assert.equal(app.statusCode, 200); + const tool = await request(route, { url: '/?points=-97.75,30.26' }); + assert.equal(tool.statusCode, 200); + assert.equal(asked.length, 2); +}); + test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),