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
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
// 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'); },
+44 -2
View File
@@ -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' }, // <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) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),