mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
bea76df454
commit
ff799d1520
4 files changed
+58
-4
No files matched your search
@@ -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": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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'); },
|
||||||
|
|||||||
@@ -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 }]),
|
||||||
|
|||||||
Reference in new issue
Block a user