mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(server): admit reverse-proxied same-origin terrain requests
Review follow-up on #975. The shared same-site gate refuses any forwarding header, which is right for routes that spend a key, but terrain is keyless and needed for rendering: behind the reverse proxy the README documents, every /api/terrain/heights chunk got 403 and fell back to the geoid. Terrain now uses admitSameSiteKeyless, which keeps the Sec-Fetch-Site and exact-Origin checks (comparing Origin against a single X-Forwarded-Proto scheme) but does not refuse forwarding headers. The shared admitSameSite gate is unchanged for every other route. The changelog entry moves to changelog.d/975-terrain-cross-site-gate.md. Co-authored-by: daikaginza <[email protected]>
This commit is contained in:
1 parent
77768e9fff
commit
c7ff33ac2e
6 files changed
+156
-6
No files matched your search
+1
-1
@@ -73,7 +73,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.
|
||||
- **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 terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy header values arrive client-controlled; read one only when it is a
|
||||
* single scheme token, never a comma-separated chain.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {'http:'|'https:'}
|
||||
*/
|
||||
function forwardedProtocol(req) {
|
||||
if (req.socket?.encrypted) return 'https:';
|
||||
const proto = String(req.headers?.['x-forwarded-proto'] || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return proto === 'https' ? 'https:' : 'http:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
* and refusing them would break rendering for an instance served behind the
|
||||
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
|
||||
* the scheme an `Origin` is compared against. That header cannot be attached
|
||||
* to a cross-site browser request without a CORS preflight this server never
|
||||
* answers, and `Sec-Fetch-Site` is still enforced regardless.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSiteKeyless(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: forwardedProtocol(req),
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
||||
@@ -29,6 +29,8 @@ import { admitSameSite } from './common/same-site.js';
|
||||
* 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.
|
||||
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
|
||||
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
|
||||
*/
|
||||
export function terrainHeightsProxy(options = {}) {
|
||||
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
|
||||
@@ -236,8 +238,9 @@ export function terrainHeightsProxy(options = {}) {
|
||||
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;
|
||||
// requests, non-browser loopback callers (MCP panel_request) and the app
|
||||
// served behind a reverse proxy pass.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const send = (status, bodyObj) => {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
|
||||
@@ -29,7 +29,7 @@ function fixture(name, overrides = {}, preview = false) {
|
||||
makeRateLimiter, clientKey,
|
||||
// terrain.js gates cross-site requests first; fixture requests carry no
|
||||
// browser headers, so admit them like a loopback tool.
|
||||
admitSameSite: () => false,
|
||||
admitSameSiteKeyless: () => false,
|
||||
path, process: { cwd: () => '/fixture', env: {} },
|
||||
fsp: {
|
||||
readFile: async () => { throw new Error('cache absent'); },
|
||||
|
||||
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { terrainHeightsProxy } from '../server/providers/terrain.js';
|
||||
import { admitSameSite } from '../server/providers/common/same-site.js';
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
@@ -224,7 +225,8 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
|
||||
{ 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
|
||||
// Behind a reverse proxy the browser's own Sec-Fetch-Site still decides.
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
];
|
||||
// More refused requests than the 90/min quota, each a distinct new key.
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
@@ -249,6 +251,98 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
|
||||
assert.equal(asked.length, 2);
|
||||
});
|
||||
|
||||
test('the app served behind a reverse proxy still gets terrain', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const host = 'globe.example.lan';
|
||||
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
|
||||
// cost-bearing routes refuse these; terrain is keyless and needed to render,
|
||||
// so a 403 here would drop every chunk to the geoid fallback.
|
||||
const proxied = [
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'cf-connecting-ip': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
forwarded: 'for=203.0.113.9;proto=https',
|
||||
via: '1.1 caddy',
|
||||
},
|
||||
// TLS terminated at the proxy: the page origin is https, the hop is http.
|
||||
{
|
||||
host,
|
||||
origin: `https://${host}`,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
];
|
||||
for (const [i, reqHeaders] of proxied.entries()) {
|
||||
const answer = await request(route, {
|
||||
url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`,
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length);
|
||||
|
||||
// Proxied or not, a cross-site page is still refused before any work.
|
||||
for (const reqHeaders of [
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
{
|
||||
host,
|
||||
'x-forwarded-proto': 'https',
|
||||
origin: 'https://evil.example',
|
||||
},
|
||||
// A forwarded scheme does not make an http Origin match an https page.
|
||||
{ host, 'x-forwarded-proto': 'https', origin: `http://${host}` },
|
||||
]) {
|
||||
const answer = await request(route, {
|
||||
url: '/?points=-97.9,30.26',
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length, 'refusals cost no upstream call');
|
||||
});
|
||||
|
||||
test('cost-bearing routes still refuse proxied requests', () => {
|
||||
// The keyless exemption is scoped to terrain; the shared gate is unchanged.
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
const refused = admitSameSite(
|
||||
{
|
||||
headers: {
|
||||
host: 'globe.example.lan',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
socket: {},
|
||||
},
|
||||
res,
|
||||
);
|
||||
assert.equal(refused, true);
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'Proxied requests are not accepted',
|
||||
});
|
||||
});
|
||||
|
||||
test('a second client keeps its own quota', async (t) => {
|
||||
stubFetch(t, async () =>
|
||||
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
|
||||
|
||||
Reference in new issue
Block a user