mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
A foreign Origin or Sec-Fetch-Site: cross-site request was answered and counted against the client's 60-a-minute quota, and could start a refresh. Run the keyless same-site checks the other map routes use before rate limiting and cache acquisition; reverse-proxy forwarding headers stay accepted. List the route with the keyless map routes in SECURITY.md.
293 lines
8.9 KiB
JavaScript
293 lines
8.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js';
|
|
|
|
const feature = (id = 310, type = 'P') => ({
|
|
properties: {
|
|
NAME: `${type}-${id}`,
|
|
TYPE_CODE: type,
|
|
OBJECTID: id,
|
|
UPPER_DESC: 'TNI',
|
|
},
|
|
geometry: {
|
|
type: 'Polygon',
|
|
coordinates: [
|
|
[
|
|
[-77, 38],
|
|
[-76, 38],
|
|
[-76, 39],
|
|
[-77, 38],
|
|
],
|
|
],
|
|
},
|
|
});
|
|
|
|
function install(options = {}, hook = 'configureServer') {
|
|
let handler;
|
|
const plugin = droneAirspaceProxy(options);
|
|
assert.equal(typeof plugin.configureServer, 'function');
|
|
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
|
plugin[hook]({
|
|
middlewares: {
|
|
use(path, callback) {
|
|
assert.equal(path, '/api/drone-airspace');
|
|
handler = callback;
|
|
},
|
|
},
|
|
});
|
|
return async (url = '/', method = 'GET', peer = 'local', headers = {}) => {
|
|
const res = {
|
|
headers: {},
|
|
writeHead(status, headers) {
|
|
this.status = status;
|
|
this.headers = headers;
|
|
},
|
|
setHeader(name, value) {
|
|
this.headers[name] = value;
|
|
},
|
|
end(body) {
|
|
this.status ??= this.statusCode;
|
|
this.body = JSON.parse(body);
|
|
},
|
|
};
|
|
await handler(
|
|
{ url, method, headers, socket: { remoteAddress: peer } },
|
|
res,
|
|
);
|
|
return res;
|
|
};
|
|
}
|
|
|
|
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
|
test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => {
|
|
const calls = [];
|
|
const request = install(
|
|
{
|
|
now: () => 1234,
|
|
fetchImpl: async (url, options) => {
|
|
calls.push(new URL(url));
|
|
assert.ok(options.signal instanceof AbortSignal);
|
|
assert.equal(options.redirect, 'error');
|
|
return Response.json({
|
|
features: [feature(calls.length)],
|
|
exceededTransferLimit: calls.length < 3,
|
|
});
|
|
},
|
|
},
|
|
hook,
|
|
);
|
|
const res = await request('/?url=https://invalid.example&resultOffset=999');
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.fetchedAt, 1234);
|
|
assert.equal(res.body.rows.length, 3);
|
|
assert.equal(calls.length, 3);
|
|
assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')");
|
|
assert.equal(calls[0].searchParams.get('f'), 'geojson');
|
|
assert.equal(calls[0].searchParams.has('resultOffset'), false);
|
|
assert.equal(calls[1].searchParams.get('resultOffset'), '1');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serve one good snapshot, then let the upstream answer `bad` after the TTL:
|
|
* the bad answer must not replace it, so the client gets the last good rows
|
|
* marked stale, and a cold proxy answers 502 rather than caching the bad one.
|
|
*/
|
|
async function assertRejectedSnapshot(bad) {
|
|
let now = 1000;
|
|
let broken = false;
|
|
let calls = 0;
|
|
const request = install({
|
|
now: () => now,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return broken ? bad(calls) : Response.json({ features: [feature(7)] });
|
|
},
|
|
});
|
|
const first = await request();
|
|
assert.equal(first.status, 200);
|
|
broken = true;
|
|
now += 3_600_001;
|
|
const stale = await request();
|
|
assert.equal(stale.status, 200);
|
|
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
|
assert.equal(stale.body.stale, true);
|
|
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
|
|
assert.deepEqual(
|
|
stale.body.rows.map((row) => row.stableId),
|
|
['7'],
|
|
);
|
|
calls = 0;
|
|
const cold = install({ fetchImpl: async () => bad(++calls) });
|
|
const res = await cold();
|
|
assert.equal(res.status, 502);
|
|
assert.equal(res.body.error, 'drone_airspace_unavailable');
|
|
}
|
|
|
|
test('an empty upstream snapshot keeps the last good one', () =>
|
|
assertRejectedSnapshot(() => Response.json({ features: [] })));
|
|
|
|
test('a page whose every feature is invalid keeps the last good one', () =>
|
|
assertRejectedSnapshot(() =>
|
|
Response.json({
|
|
features: [
|
|
{
|
|
...feature(8),
|
|
properties: { ...feature(8).properties, TYPE_CODE: 'W' },
|
|
},
|
|
{ ...feature(9), geometry: null },
|
|
],
|
|
}),
|
|
));
|
|
|
|
test('paging that exhausts its page limit still flagged keeps the last good one', () =>
|
|
assertRejectedSnapshot((call) =>
|
|
Response.json({
|
|
features: [feature(100 + call)],
|
|
exceededTransferLimit: true,
|
|
}),
|
|
));
|
|
|
|
test('a flagged page that adds no features keeps the last good one', () =>
|
|
assertRejectedSnapshot((call) =>
|
|
Response.json(
|
|
call % 2
|
|
? { features: [feature(100 + call)], exceededTransferLimit: true }
|
|
: { features: [], properties: { exceededTransferLimit: true } },
|
|
),
|
|
));
|
|
|
|
test('a cached snapshot is reused until TTL, then refreshed', async () => {
|
|
let now = 1000;
|
|
let fetches = 0;
|
|
const request = install({
|
|
now: () => now,
|
|
fetchImpl: async () => {
|
|
fetches += 1;
|
|
return Response.json({ features: [feature(fetches)] });
|
|
},
|
|
});
|
|
const first = await request();
|
|
now += 3_599_000;
|
|
const cached = await request();
|
|
now += 2000;
|
|
const refreshed = await request();
|
|
assert.equal(first.body.rows[0].stableId, '1');
|
|
assert.equal(cached.body.rows[0].stableId, '1');
|
|
assert.equal(refreshed.body.rows[0].stableId, '2');
|
|
assert.equal(fetches, 2);
|
|
});
|
|
|
|
test('a failed refresh serves the last good snapshot as stale', async () => {
|
|
let now = 1000;
|
|
let fail = false;
|
|
const request = install({
|
|
now: () => now,
|
|
fetchImpl: async () => {
|
|
if (fail) return new Response('nope', { status: 503 });
|
|
return Response.json({ features: [feature(7)] });
|
|
},
|
|
});
|
|
const first = await request();
|
|
fail = true;
|
|
now += 3_600_001;
|
|
const stale = await request();
|
|
assert.equal(first.status, 200);
|
|
assert.equal(stale.status, 200);
|
|
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
|
assert.equal(stale.body.stale, true);
|
|
assert.equal(stale.body.rows[0].stableId, '7');
|
|
});
|
|
|
|
test('method, unknown routes and rate limits are rejected', async () => {
|
|
const request = install({
|
|
fetchImpl: async () => Response.json({ features: [feature()] }),
|
|
});
|
|
assert.equal((await request('/', 'POST')).status, 405);
|
|
assert.equal((await request('/nope')).status, 404);
|
|
const limited = install({
|
|
fetchImpl: async () => Response.json({ features: [feature()] }),
|
|
});
|
|
let last;
|
|
for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9');
|
|
assert.equal(last.status, 429);
|
|
});
|
|
|
|
test('a cold upstream failure is 502, not an empty snapshot', async () => {
|
|
const request = install({
|
|
fetchImpl: async () => new Response('nope', { status: 503 }),
|
|
});
|
|
const res = await request();
|
|
assert.equal(res.status, 502);
|
|
assert.equal(res.body.error, 'drone_airspace_unavailable');
|
|
});
|
|
|
|
test('a stale snapshot is served for at most a day, then refused', async () => {
|
|
let now = 1_000_000_000;
|
|
let up = true;
|
|
const request = install({
|
|
now: () => now,
|
|
fetchImpl: async () => {
|
|
if (!up) throw new Error('FAA unreachable');
|
|
return Response.json({ features: [feature(7)] });
|
|
},
|
|
});
|
|
const fresh = await request();
|
|
assert.equal(fresh.status, 200);
|
|
up = false;
|
|
now += 23 * 60 * 60 * 1000;
|
|
const stale = await request();
|
|
assert.equal(stale.status, 200);
|
|
assert.equal(stale.body.stale, true);
|
|
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
|
|
now += 2 * 60 * 60 * 1000;
|
|
assert.equal((await request()).status, 502, 'a day-old snapshot is refused');
|
|
});
|
|
|
|
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
|
|
let fetches = 0;
|
|
const request = install({
|
|
fetchImpl: async () => {
|
|
fetches += 1;
|
|
return Response.json({ features: [feature()] });
|
|
},
|
|
});
|
|
const host = 'localhost:5173';
|
|
const refused = [
|
|
{ host, 'sec-fetch-site': 'cross-site' },
|
|
{ host, origin: 'https://evil.example' },
|
|
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
|
|
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
|
];
|
|
// More refusals than the 60/min per-client quota, all from one peer.
|
|
for (let i = 0; i < 64; i++) {
|
|
const headers = refused[i % refused.length];
|
|
const res = await request('/', 'GET', '10.0.0.5', headers);
|
|
assert.equal(res.status, 403, JSON.stringify(headers));
|
|
}
|
|
assert.equal(fetches, 0, 'refusals cost no upstream call');
|
|
const app = await request('/', 'GET', '10.0.0.5', {
|
|
host,
|
|
origin: `http://${host}`,
|
|
'sec-fetch-site': 'same-origin',
|
|
});
|
|
assert.equal(app.status, 200, 'the app keeps its full quota');
|
|
assert.equal(fetches, 1);
|
|
});
|
|
|
|
test('the app served behind a reverse proxy still gets the zones', async () => {
|
|
const request = install({
|
|
fetchImpl: async () => Response.json({ features: [feature()] }),
|
|
});
|
|
const host = 'gev.example.com';
|
|
const res = await request('/', 'GET', '127.0.0.1', {
|
|
host,
|
|
origin: `https://${host}`,
|
|
'sec-fetch-site': 'same-origin',
|
|
'x-forwarded-for': '203.0.113.9',
|
|
'x-forwarded-proto': 'https',
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.rows.length, 1);
|
|
});
|