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 }]),