mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34: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
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user