fix(news): gate /api/news same-site, answer 400 on a bad request line, relay key states

A malformed request line crashed the server, any web page could spend the
news keys, and the forwarding proxy turned the news server's 503 key and
configuration states into a generic 502.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Fable 5.2 committed 2026-11-10 21:26:48 +01:00
1 parent 3342d7a07a
commit 49ac289d00
3 files changed
+119 -10

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, `/api/tomtom/*`, backed by its persistent daily tile budget, and `/api/news/*`, which spends the World News API daily reads, Liveuamap reads and GDELT downloads) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+42 -7
View File
@@ -1,5 +1,6 @@
import { newsQueryAllowed } from '../../src/layers/news/source.js';
import { readResponseTextCapped } from './common/http.js';
import { sameSiteGated } from './common/same-site.js';
import { createKeyedNews } from './news/keyed.js';
// News (/api/news/*). With GEV_NEWS_ORIGIN set (a news server such as
@@ -62,6 +63,25 @@ function configuredOrigin(env, log) {
};
}
/** A 503's known key or configuration state, rebuilt from its fields; else null. */
async function upstreamState(upstream) {
let body = null;
try {
body = JSON.parse(await readResponseTextCapped(upstream, 16 * 1024));
} catch {
return null;
}
if (body?.error === 'news_not_configured')
return { error: body.error, unavailable: true, retryable: false };
if (
['no_key', 'key_rejected', 'key_quota'].includes(body?.error) &&
typeof body.key === 'string' &&
/^[a-z][a-z0-9-]{0,31}$/.test(body.key)
)
return { error: body.error, key: body.key };
return null;
}
/**
* A thin, validated GET proxy for the news layer's three read routes, or
* their keyed answers without a news server. A host that serves news only
@@ -91,11 +111,17 @@ export function newsProxy({
async function handler(req, res) {
if (req.method !== 'GET')
return json(res, 405, { error: 'method_not_allowed' });
const local = new URL(req.url || '/', 'http://local');
const target = new URL(
`/api/news${local.pathname}${local.search}`,
'http://local',
);
let local, target;
try {
// `//[` and the like are no URL: a 400, not an unhandled rejection.
local = new URL(req.url || '/', 'http://local');
target = new URL(
`/api/news${local.pathname}${local.search}`,
'http://local',
);
} catch {
return json(res, 400, { error: 'invalid_news_request' });
}
if (!newsQueryAllowed(target))
return json(res, 400, { error: 'invalid_news_request' });
const base = origin();
@@ -126,6 +152,12 @@ export function newsProxy({
signal: AbortSignal.timeout(timeoutMs),
},
);
if (upstream.status === 503) {
// The news server's own key and configuration states reach the
// panel as they are, so it can say which key is missing.
const state = await upstreamState(upstream);
if (state) return json(res, 503, state);
}
if (!upstream.ok) {
await upstream.body?.cancel().catch(() => {});
return json(res, 502, {
@@ -144,13 +176,16 @@ export function newsProxy({
}
}
// The routes spend keys and upstream reads: no other site's page, and no
// proxied request, may drive them (SECURITY.md, cross-site gate).
const gated = sameSiteGated(handler);
return {
name: 'news',
configureServer({ middlewares }) {
middlewares.use('/api/news', handler);
middlewares.use('/api/news', gated);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/news', handler);
middlewares.use('/api/news', gated);
},
};
}
+76 -2
View File
@@ -14,7 +14,7 @@ function mount(options) {
},
},
});
return async (url, method = 'GET') => {
return async (url, method = 'GET', headers = {}) => {
const res = {
destroyed: false,
status: 0,
@@ -24,11 +24,15 @@ function mount(options) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
if (this.statusCode) this.status = this.statusCode;
this.body = body;
},
};
await handler({ url, method }, res);
await handler({ url, method, headers }, res);
return { ...res, json: res.body ? JSON.parse(res.body) : null };
};
}
@@ -218,3 +222,73 @@ test('a host that serves news only from its own server turns the built-in backen
}
assert.equal(fetches, 0);
});
test('a request line that is no URL is a 400, not a crash', async () => {
const call = mount({
origin: () => null,
keyed: { answer: async () => assert.fail('nothing is answered') },
});
// `GET /api/news//[`: Connect strips the mount prefix and leaves `//[`.
const bad = await call('//[');
assert.equal(bad.status, 400);
assert.deepEqual(bad.json, { error: 'invalid_news_request' });
});
test('cross-site and proxied requests are refused before anything is read', async () => {
let answered = 0;
const call = mount({
origin: () => null,
keyed: {
answer: async () => {
answered++;
return { status: 200, body: { reports: [], nextBefore: null } };
},
},
});
const host = 'localhost:5173';
for (const headers of [
{ host, origin: 'https://evil.example' },
{ host, origin: 'null' },
{ host, 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9' },
]) {
const refused = await call('/feed?hotspot=all', 'GET', headers);
assert.equal(refused.status, 403, JSON.stringify(headers));
}
assert.equal(answered, 0);
for (const headers of [
{ host },
{ host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin' },
]) {
const passed = await call('/feed?hotspot=all', 'GET', headers);
assert.equal(passed.status, 200, JSON.stringify(headers));
}
assert.equal(answered, 2);
});
test("a news server's key and configuration states are relayed as they are", async () => {
for (const body of [
{ error: 'no_key', key: 'worldnewsapi' },
{ error: 'key_rejected', key: 'worldnewsapi' },
{ error: 'key_quota', key: 'worldnewsapi' },
{ error: 'news_not_configured', unavailable: true, retryable: false },
]) {
const call = mount({
origin: () => 'https://news.example',
fetchImpl: async () =>
new Response(JSON.stringify(body), { status: 503 }),
});
const relayed = await call('/feed?hotspot=all&at=31.95,35.93');
assert.equal(relayed.status, 503, body.error);
assert.deepEqual(relayed.json, body);
}
// Any other 503 stays a bounded upstream error.
const other = mount({
origin: () => 'https://news.example',
fetchImpl: async () =>
new Response('{"error":"maintenance","detail":"x"}', { status: 503 }),
});
const down = await other('/hotspots');
assert.equal(down.status, 502);
assert.deepEqual(down.json, { error: 'news_upstream_error', status: 503 });
});