fix(server): refuse cross-site requests on the terrain-heights route

Follow-up to #686 requested on #923. The route's per-client limiter keys on the socket address, which for a cross-site <img> is the visitor's own browser, so any web page could fill the bounded cache with junk keys and spend the visitor's 90/min quota, leaving the app's own terrain lookups on 429. Gate the route with admitSameSite before the limiter, like the Places routes.

Co-authored-by: daikaginza <[email protected]>
This commit is contained in:
Sunilanddaikaginza committed 2026-10-08 21:13:16 +05:30
1 parent bea76df454
commit ff799d1520
4 files changed
+58 -4

No files matched your search

+3 -1
View File
@@ -113,7 +113,9 @@
"server/providers/terrain.js", "server/providers/terrain.js",
"src/data/terrainHeightsProxy.js", "src/data/terrainHeightsProxy.js",
"server/providers/common/rate-limit.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": [] "external": []
}, },
+8 -1
View File
@@ -9,6 +9,7 @@ import {
validTerrainResult, validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js'; } from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.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 * 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 * 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 * 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 * 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 = {}) { export function terrainHeightsProxy(options = {}) {
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
@@ -231,6 +234,10 @@ export function terrainHeightsProxy(options = {}) {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/terrain/heights', async (req, res) => { server.middlewares.use('/api/terrain/heights', async (req, res) => {
// Refuse cross-site browser requests (an <img> 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) => { const send = (status, bodyObj) => {
if (res.headersSent) return; if (res.headersSent) return;
res.writeHead(status, { 'Content-Type': 'application/json' }); res.writeHead(status, { 'Content-Type': 'application/json' });
+3
View File
@@ -26,6 +26,9 @@ function fixture(name, overrides = {}, preview = false) {
// The rate-limited proxies reach for these; give them the real ones so a // The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be. // fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey, 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: {} }, path, process: { cwd: () => '/fixture', env: {} },
fsp: { fsp: {
readFile: async () => { throw new Error('cache absent'); }, readFile: async () => { throw new Error('cache absent'); },
+44 -2
View File
@@ -29,10 +29,18 @@ function install(plugin) {
return routes; 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) => { return new Promise((resolve, reject) => {
const headers = new Map(); const headers = new Map();
const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; const req = {
method: 'GET',
url,
headers: reqHeaders,
socket: { remoteAddress },
};
const res = { const res = {
statusCode: 200, statusCode: 200,
headersSent: false, 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'); 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' }, // <img src> 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) => { test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () => stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),