fix(osm): gate place search cross-site and share its slots fairly

From review:
- /api/osm/features now refuses cross-site browser requests like the other
  quota-bearing routes, so a page elsewhere cannot spend the operator's
  Overpass. SECURITY.md lists the route, its limits and its disk cache.
- One client runs one search at a time and two run at once in all, instead
  of one global slot any single client could hold.
- A search gets 45 s across every configured endpoint in turn, answered 504
  when it runs out.
- A cache write no longer waits on the disk prune; prune() waits until the
  cache is idle for callers that need it.
This commit is contained in:
Manjunath committed 2026-11-03 19:36:16 +05:30
1 parent 0b6cdc8e1f
commit fb74354d30
6 files changed
+156 -27

No files matched your search

+114 -19
View File
@@ -160,7 +160,12 @@ test('answers are compacted: centres, kept tags, truncation and counts', () => {
function mount(
fetchPayload,
{ featuresCache, configured = () => true, upstreamTag } = {},
{
featuresCache,
configured = () => true,
upstreamTag,
upstreamBudgetMs,
} = {},
) {
const routes = new Map();
const writes = [];
@@ -181,11 +186,17 @@ function mount(
cacheRoot: null,
configured,
...(upstreamTag ? { upstreamTag } : {}),
...(upstreamBudgetMs ? { upstreamBudgetMs } : {}),
featuresCache: featuresCache || memoryCache(),
},
);
let client = 0;
const start = (search, route = '/api/osm/features') => {
const start = (
search,
route = '/api/osm/features',
headers = {},
from = null,
) => {
let req;
let res;
const promise = new Promise((resolve) => {
@@ -193,8 +204,8 @@ function mount(
req = Object.assign(new EventEmitter(), {
method: 'GET',
url: `/?${search}`,
headers: {},
socket: { remoteAddress: `10.9.0.${client}` },
headers,
socket: { remoteAddress: from || `10.9.0.${client}` },
});
res = Object.assign(new EventEmitter(), {
writableEnded: false,
@@ -202,10 +213,13 @@ function mount(
res.status = status;
res.headers = headers;
},
setHeader(name, value) {
res.headers = { ...(res.headers || {}), [name]: value };
},
end(body) {
res.writableEnded = true;
resolve({
status: res.status,
status: res.status ?? res.statusCode,
headers: res.headers,
body: JSON.parse(body),
});
@@ -220,20 +234,18 @@ function mount(
},
};
};
const request = (search, route = '/api/osm/features') =>
start(search, route).promise;
const request = (search, route = '/api/osm/features', headers, from) =>
start(search, route, headers, from).promise;
return { request, start, writes };
}
test('the route answers, caches, and runs one upstream query at a time', async () => {
let release;
test('the route answers, caches, and shares upstream slots fairly', async () => {
const release = [];
let upstream = 0;
const { request, writes } = mount(async (body) => {
upstream += 1;
assert.match(decodeURIComponent(body), /^data=\[out:json\]/);
await new Promise((r) => {
release = r;
});
await new Promise((r) => release.push(r));
return {
status: 200,
body: JSON.stringify({
@@ -241,23 +253,81 @@ test('the route answers, caches, and runs one upstream query at a time', async (
}),
};
});
const first = request('preset=hospital&bbox=0,0,2,2');
await new Promise((r) => setTimeout(r, 5));
const other = await request('preset=school&bbox=0,0,2,2');
assert.equal(other.status, 503, 'a second, different search waits its turn');
release();
const tick = () => new Promise((r) => setTimeout(r, 5));
const first = request(
'preset=hospital&bbox=0,0,2,2',
undefined,
{},
'10.1.0.1',
);
await tick();
const sameClient = await request(
'preset=school&bbox=0,0,2,2',
undefined,
{},
'10.1.0.1',
);
assert.equal(
sameClient.status,
503,
"one client's second search waits for its first",
);
const shared = request(
'preset=hospital&bbox=0,0,2,2',
undefined,
{},
'10.1.0.2',
);
const second = request(
'preset=school&bbox=0,0,2,2',
undefined,
{},
'10.1.0.3',
);
await tick();
assert.equal(
upstream,
2,
'another client runs alongside; the same search is shared',
);
const third = await request(
'preset=pharmacy&bbox=0,0,2,2',
undefined,
{},
'10.1.0.4',
);
assert.equal(third.status, 503, 'the global cap holds');
release.forEach((r) => r());
const answered = await first;
assert.equal(answered.status, 200);
assert.equal(answered.body.count, 1);
assert.equal((await shared).status, 200);
assert.equal((await second).status, 200);
const again = await request('preset=hospital&bbox=0,0,2,2');
assert.equal(again.headers['X-OSM-Cache'], 'HIT');
assert.equal(upstream, 1);
assert.equal(writes.length, 1);
assert.equal(upstream, 2);
assert.equal(writes.length, 2);
const refused = await request('preset=hospital&bbox=0,0,9,9');
assert.equal(refused.status, 400);
assert.equal(refused.body.code, 'AREA_TOO_LARGE');
});
test('a hung Overpass is cut off by the overall budget, across every endpoint', async () => {
const { request } = mount(
(_body, _max, { signal }) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
{ upstreamBudgetMs: 30 },
);
const started = Date.now();
const answer = await request('preset=hospital&bbox=0,0,2,2');
assert.ok(Date.now() - started < 1000);
assert.equal(answer.status, 504);
});
test('a cached answer is served only by the Overpass instance that gave it', async () => {
let tag = 'instance-a';
let upstream = 0;
@@ -287,6 +357,31 @@ test('a cached answer is served only by the Overpass instance that gave it', asy
);
});
test('a cross-site page cannot spend the operator Overpass', async () => {
let upstream = 0;
const { request } = mount(async () => {
upstream += 1;
return { status: 200, body: JSON.stringify({ elements: [] }) };
});
for (const headers of [
{ 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
{ 'sec-fetch-site': 'cross-site' },
{ origin: 'https://evil.example' },
]) {
const refused = await request('preset=hospital&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
...headers,
});
assert.equal(refused.status, 403, JSON.stringify(headers));
}
assert.equal(upstream, 0, 'no cross-site request reaches Overpass');
const own = await request('preset=hospital&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
'sec-fetch-site': 'same-origin',
});
assert.equal(own.status, 200);
});
test('an upstream refusal is not an empty answer', async () => {
const { request } = mount(async () => ({
status: 429,