mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(server): gate cost/log endpoints against cross-site requests and ship a full CSP
The cost-bearing endpoints (/api/realtime/token, /api/openai/hud-summary, /api/google/nearby-places) and the voice debug log refuse cross-site browser requests: a foreign or opaque Origin, a Sec-Fetch-Site other than same-origin/none, or proxy forwarding headers. Loopback tools without an Origin and the LAN opt-in keep working. The dev and preview servers send one shared Content-Security-Policy, and embed-mode framing rewrites only the frame-ancestors directive of it. Salvaged from #242 onto current main. Co-authored-by: Sameh Khamis <[email protected]> Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
42faed3aea
commit
7aa9bbd3be
11 files changed
+402
-15
No files matched your search
@@ -71,6 +71,8 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **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.
|
- **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`) 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`). **Limit: this gate does not survive DNS rebinding in the `HOST=0.0.0.0` mode.** By default Vite's host check (`allowedHosts`) rejects a rebinding `Host` before any handler runs, and the gate is defense in depth behind it. The LAN opt-in turns that host check off, so an attacker-controlled name that resolves to the operator's address yields a page whose `Origin` and `Host` agree and whose `Sec-Fetch-Site` is a truthful `same-origin`; all three checks pass. In that mode protection rests on the operator's network, not on `Origin`/`Host` agreement (see Network exposure below).
|
||||||
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:`: no inline script and no script from any other 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.
|
||||||
|
|
||||||
## Network exposure — the operator threat model
|
## Network exposure — the operator threat model
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -11,6 +11,15 @@
|
|||||||
|
|
||||||
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
|
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
|
||||||
|
|
||||||
|
/** The directives of a CSP header value, minus any frame-ancestors rule. */
|
||||||
|
function withoutFraming(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.split(';')
|
||||||
|
.map((directive) => directive.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((directive) => !/^frame-ancestors\b/i.test(directive));
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a request is for an embed-mode document. */
|
/** Whether a request is for an embed-mode document. */
|
||||||
export function isEmbedDocumentRequest(url) {
|
export function isEmbedDocumentRequest(url) {
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -43,8 +52,13 @@ export function embedFramingPlugin({
|
|||||||
res.setHeader = (name, value) => {
|
res.setHeader = (name, value) => {
|
||||||
const key = String(name).toLowerCase();
|
const key = String(name).toLowerCase();
|
||||||
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
|
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
|
||||||
if (key === 'content-security-policy' && !anywhere)
|
if (key === 'content-security-policy') {
|
||||||
return setHeader(name, policy);
|
// Keep every other directive of the server's policy (script-src,
|
||||||
|
// connect-src, ...) and only swap the framing rule.
|
||||||
|
const kept = withoutFraming(value);
|
||||||
|
const next = anywhere ? kept : [...kept, policy];
|
||||||
|
return next.length ? setHeader(name, next.join('; ')) : res;
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
if (!anywhere) setHeader('Content-Security-Policy', policy);
|
if (!anywhere) setHeader('Content-Security-Policy', policy);
|
||||||
|
|||||||
+42
-6
@@ -3,6 +3,40 @@ import cesium from 'vite-plugin-cesium';
|
|||||||
import { embedFramingPlugin } from './embed-framing.js';
|
import { embedFramingPlugin } from './embed-framing.js';
|
||||||
import { panelBuildPlugin } from './panel.js';
|
import { panelBuildPlugin } from './panel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-Security-Policy for every document the dev/preview server serves.
|
||||||
|
* No inline script and no foreign script origin is permitted. 'unsafe-eval' is
|
||||||
|
* required: Knockout (bundled inside @cesium/widgets) resolves the global
|
||||||
|
* object with `(0, eval)("this")` at module load, and without it the Cesium
|
||||||
|
* widget never initializes (verified in headless Chrome). It also covers
|
||||||
|
* Cesium's WASM decoders. `blob:` is required in the built app: Cesium's
|
||||||
|
* bundled workers bootstrap through `importScripts(blob:...)`, which a worker
|
||||||
|
* checks against script-src (the dev server loads them by URL instead).
|
||||||
|
* Widen any other directive only for a real violation.
|
||||||
|
*/
|
||||||
|
export const BROWSER_CSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-eval' blob:",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' data: https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"media-src 'self' blob: https:",
|
||||||
|
"connect-src 'self' blob: data: https: wss: ws:",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"child-src 'self' blob:",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
/** Response headers shared by the dev and preview servers. */
|
||||||
|
export const BROWSER_HEADERS = Object.freeze({
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'Content-Security-Policy': BROWSER_CSP,
|
||||||
|
});
|
||||||
|
|
||||||
/** Build browser assets with explicit inputs; never load environment or providers. */
|
/** Build browser assets with explicit inputs; never load environment or providers. */
|
||||||
export function createBrowserViteConfig({
|
export function createBrowserViteConfig({
|
||||||
plugins = [],
|
plugins = [],
|
||||||
@@ -46,13 +80,15 @@ export function createBrowserViteConfig({
|
|||||||
fs: {
|
fs: {
|
||||||
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
||||||
},
|
},
|
||||||
// These headers protect the document containing Provider Settings.
|
// These headers protect the document containing Provider Settings and
|
||||||
// Embed-mode documents are framable instead; see embed-framing.js.
|
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
|
||||||
headers: {
|
// Embed-mode documents are framable instead: embed-framing.js rewrites
|
||||||
'X-Frame-Options': 'DENY',
|
// only the frame-ancestors directive and keeps the rest of the policy.
|
||||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
headers: BROWSER_HEADERS,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
// The preview server serves the same documents, so it carries the same
|
||||||
|
// framing + CSP hardening (one constant, no drift).
|
||||||
|
preview: { headers: BROWSER_HEADERS },
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"server/providers/places/routes.js",
|
"server/providers/places/routes.js",
|
||||||
"src/data/placeProviderPayloads.js",
|
"src/data/placeProviderPayloads.js",
|
||||||
"src/tooling/placeProviders.test.mjs",
|
"src/tooling/placeProviders.test.mjs",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
"server/providers/space.js",
|
"server/providers/space.js",
|
||||||
"server/providers/space/celestrak.js",
|
"server/providers/space/celestrak.js",
|
||||||
"server/providers/space/launch-library.js",
|
"server/providers/space/launch-library.js",
|
||||||
|
|||||||
@@ -74,6 +74,8 @@
|
|||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"scripts/google-server-key.mjs",
|
"scripts/google-server-key.mjs",
|
||||||
"src/data/placeProviderPayloads.js",
|
"src/data/placeProviderPayloads.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/sources/rateLimit.js"
|
"src/sources/rateLimit.js"
|
||||||
@@ -287,6 +289,8 @@
|
|||||||
"src/loadingFeedback.js",
|
"src/loadingFeedback.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
"src/voice/actionSchemas.js",
|
"src/voice/actionSchemas.js",
|
||||||
"server/providers/openai/toolDescriptions.js",
|
"server/providers/openai/toolDescriptions.js",
|
||||||
"src/sources/rateLimit.js"
|
"src/sources/rateLimit.js"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-site request gate for cost-bearing and log endpoints. Feeds the
|
||||||
|
* request to the pure, unit-tested `admitSameSiteRequest` and answers 403
|
||||||
|
* itself when the request is refused. Returns true when it has already
|
||||||
|
* responded (the caller returns); false when the handler should continue.
|
||||||
|
* Refuses cross-site browser requests (foreign or opaque Origin, a
|
||||||
|
* Sec-Fetch-Site other than same-origin/none, or reverse-proxy headers) while
|
||||||
|
* keeping loopback non-browser tools and the HOST=0.0.0.0 LAN opt-in working.
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @param {import('node:http').ServerResponse} res
|
||||||
|
* @returns {boolean} True when a 403 was sent.
|
||||||
|
*/
|
||||||
|
export function admitSameSite(req, res) {
|
||||||
|
const verdict = admitSameSiteRequest({
|
||||||
|
hostHeader: req.headers?.host,
|
||||||
|
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||||
|
origin: req.headers?.origin,
|
||||||
|
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||||
|
proxyHeaders: req.headers || {},
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a middleware handler so cross-site requests are refused with 403
|
||||||
|
* before the handler runs. Loopback tools and same-origin browser requests
|
||||||
|
* pass straight through.
|
||||||
|
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown} handler
|
||||||
|
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown}
|
||||||
|
*/
|
||||||
|
export function sameSiteGated(handler) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (admitSameSite(req, res)) return undefined;
|
||||||
|
return handler(req, res, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { defaultSourceRoot } from './common/source-root.js';
|
|||||||
import { handleHudSummary } from './openai/hud-summary.js';
|
import { handleHudSummary } from './openai/hud-summary.js';
|
||||||
import { createDebugLogHandler } from './openai/debug-log.js';
|
import { createDebugLogHandler } from './openai/debug-log.js';
|
||||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||||
|
import { sameSiteGated } from './common/same-site.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||||
@@ -15,16 +16,20 @@ function openAiRealtimeProxy({
|
|||||||
realtime = {},
|
realtime = {},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
function install(middlewares) {
|
function install(middlewares) {
|
||||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
// Cost-bearing and log endpoints refuse cross-site browser requests
|
||||||
|
// (see server/providers/common/same-site.js and SECURITY.md).
|
||||||
|
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
|
||||||
|
|
||||||
middlewares.use(
|
middlewares.use(
|
||||||
'/api/realtime/debug-log',
|
'/api/realtime/debug-log',
|
||||||
createDebugLogHandler({ sourceRoot }),
|
sameSiteGated(createDebugLogHandler({ sourceRoot })),
|
||||||
);
|
);
|
||||||
|
|
||||||
middlewares.use(
|
middlewares.use(
|
||||||
'/api/realtime/token',
|
'/api/realtime/token',
|
||||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
sameSiteGated(
|
||||||
|
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
keylessGooglePlacesResponse,
|
keylessGooglePlacesResponse,
|
||||||
} from './google-key.js';
|
} from './google-key.js';
|
||||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||||
|
import { admitSameSite } from '../common/same-site.js';
|
||||||
import {
|
import {
|
||||||
projectNearbyPlaces,
|
projectNearbyPlaces,
|
||||||
projectTextSearchPlaces,
|
projectTextSearchPlaces,
|
||||||
@@ -50,6 +51,9 @@ export function googlePlacesContextProxy({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
function install(middlewares) {
|
function install(middlewares) {
|
||||||
middlewares.use('/api/google/nearby-places', async (req, res) => {
|
middlewares.use('/api/google/nearby-places', async (req, res) => {
|
||||||
|
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
|
||||||
|
// about this endpoint's method surface.
|
||||||
|
if (admitSameSite(req, res)) return;
|
||||||
if (req.method !== 'GET') {
|
if (req.method !== 'GET') {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Same-site request gate for the cost-bearing and log endpoints.
|
||||||
|
*
|
||||||
|
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
|
||||||
|
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
|
||||||
|
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
|
||||||
|
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||||
|
* spend), `/api/google/nearby-places` (Google spend), and
|
||||||
|
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
|
||||||
|
* ungated, so a hostile web page open in the same browser could reach them
|
||||||
|
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
|
||||||
|
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
|
||||||
|
*
|
||||||
|
* This module is the pure core that refuses those shapes while keeping the
|
||||||
|
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
|
||||||
|
* repo QA harnesses POST the token endpoint from Node with no Origin) working.
|
||||||
|
* It deliberately does NOT require a loopback remote address (that belongs only
|
||||||
|
* to the credential panel), the presence of an `Origin`, or a JSON
|
||||||
|
* Content-Type. It touches nothing but its arguments, so every refusal below is
|
||||||
|
* pinned by a unit assertion.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
|
||||||
|
* not originate on this machine, whatever its socket says. Kept as this
|
||||||
|
* module's own list (keySetupCore.mjs carries the same nine names for the
|
||||||
|
* credential panel) so the gate has no imports and can sit in any package
|
||||||
|
* boundary.
|
||||||
|
*/
|
||||||
|
export const PROXY_SIGNALS = Object.freeze([
|
||||||
|
'forwarded',
|
||||||
|
'via',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-forwarded-host',
|
||||||
|
'x-forwarded-port',
|
||||||
|
'x-forwarded-proto',
|
||||||
|
'x-real-ip',
|
||||||
|
'cf-connecting-ip',
|
||||||
|
'cf-ray',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a request's own authority (an origin string) from its protocol and
|
||||||
|
* Host header. Unlike the credential-panel gate's localhost-restricted
|
||||||
|
* `localAuthority`, this returns whatever Host the request actually carries —
|
||||||
|
* the LAN opt-in serves a non-loopback Host to LAN browsers, and a same-origin
|
||||||
|
* fetch from such a page must still match. Malformed/missing input yields null.
|
||||||
|
* @param {string} hostHeader e.g. `req.headers.host`
|
||||||
|
* @param {string} protocol `http:` or `https:`
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function requestAuthority(hostHeader, protocol) {
|
||||||
|
const raw = String(hostHeader || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const scheme = String(protocol || '').toLowerCase();
|
||||||
|
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return new URL(`${scheme}//${raw}`).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a request to a cost-bearing/log endpoint is same-site enough
|
||||||
|
* to admit. Pure: no I/O, no globals.
|
||||||
|
*
|
||||||
|
* Policy, in order:
|
||||||
|
* 1. any reverse-proxy / CDN signal header present (PROXY_SIGNALS) → 403;
|
||||||
|
* 2. `Sec-Fetch-Site` present and not `same-origin` / `none` → 403 (this is
|
||||||
|
* what blocks `<img src=...>` and cross-site navigation, which carry no
|
||||||
|
* Origin but do carry `Sec-Fetch-Site: cross-site`);
|
||||||
|
* 3. `Origin` present must exactly equal the request's own authority computed
|
||||||
|
* from protocol + Host — the same exact-origin comparison the credential
|
||||||
|
* gate uses (no userinfo, no path/search/hash). The literal string `null`
|
||||||
|
* is an opaque origin (sandboxed iframe / `data:` URL) and is refused;
|
||||||
|
* 4. otherwise ok. Non-browser loopback tools and the LAN opt-in (which may
|
||||||
|
* carry neither `Origin` nor `Sec-Fetch-Site`) pass here.
|
||||||
|
*
|
||||||
|
* @param {{hostHeader?: string, protocol?: string, origin?: string, secFetchSite?: string, proxyHeaders?: Record<string,string>}} req
|
||||||
|
* @returns {{ok: true} | {ok: false, status: 403, error: string}}
|
||||||
|
*/
|
||||||
|
export function admitSameSiteRequest({
|
||||||
|
hostHeader,
|
||||||
|
protocol = 'http:',
|
||||||
|
origin,
|
||||||
|
secFetchSite,
|
||||||
|
proxyHeaders = {},
|
||||||
|
} = {}) {
|
||||||
|
// (1) A request carrying reverse-proxy / CDN forwarding headers did not
|
||||||
|
// originate on this machine, whatever its socket says.
|
||||||
|
if (
|
||||||
|
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
error: 'Proxied requests are not accepted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// (2) The browser tells us when a request is cross-site. `none` is a typed
|
||||||
|
// URL / bookmark; `same-origin` is the app itself. Anything else (cross-site,
|
||||||
|
// same-site but cross-origin) is refused.
|
||||||
|
const site = String(secFetchSite || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (site !== '' && site !== 'same-origin' && site !== 'none') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
error: 'Cross-site requests are not accepted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// (3) If Origin is present it must exactly equal the request's own authority.
|
||||||
|
if (origin !== undefined && origin !== null && origin !== '') {
|
||||||
|
if (origin === 'null') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
error: 'Opaque origins are not accepted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const authority = requestAuthority(hostHeader, protocol);
|
||||||
|
let parsedOrigin;
|
||||||
|
try {
|
||||||
|
parsedOrigin = new URL(String(origin));
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||||
|
}
|
||||||
|
const exactOrigin =
|
||||||
|
parsedOrigin.username === '' &&
|
||||||
|
parsedOrigin.password === '' &&
|
||||||
|
parsedOrigin.pathname === '/' &&
|
||||||
|
parsedOrigin.search === '' &&
|
||||||
|
parsedOrigin.hash === '' &&
|
||||||
|
parsedOrigin.origin === authority;
|
||||||
|
if (!exactOrigin) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
error: 'Cross-origin requests are not accepted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { admitSameSiteRequest } from './localRequestGate.mjs';
|
||||||
|
|
||||||
|
const same = {
|
||||||
|
hostHeader: 'localhost:4173',
|
||||||
|
protocol: 'http:',
|
||||||
|
origin: 'http://localhost:4173',
|
||||||
|
secFetchSite: 'same-origin',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the honest same-origin browser request is admitted', () => {
|
||||||
|
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cross-site Origin is refused', () => {
|
||||||
|
const r = admitSameSiteRequest({ ...same, origin: 'https://evil.example' });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an opaque Origin ("null") is refused even when Host would match', () => {
|
||||||
|
const r = admitSameSiteRequest({ ...same, origin: 'null' });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sec-Fetch-Site cross-site without an Origin is refused (the <img> case)', () => {
|
||||||
|
const r = admitSameSiteRequest({
|
||||||
|
hostHeader: 'localhost:4173',
|
||||||
|
protocol: 'http:',
|
||||||
|
secFetchSite: 'cross-site',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sec-Fetch-Site same-site (cross-origin sibling) is refused', () => {
|
||||||
|
const r = admitSameSiteRequest({
|
||||||
|
...same,
|
||||||
|
origin: 'http://localhost:4173',
|
||||||
|
secFetchSite: 'same-site',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, false, 'same-site is not same-origin');
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reverse-proxy signal header is refused', () => {
|
||||||
|
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
|
||||||
|
const r = admitSameSiteRequest({ ...same, proxyHeaders: { [header]: 'anything' } });
|
||||||
|
assert.equal(r.ok, false, `${header} present → refused`);
|
||||||
|
}
|
||||||
|
// An empty forwarding header is not a proxy signal.
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-origin Origin with Sec-Fetch-Site same-origin is admitted', () => {
|
||||||
|
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Origin and no Sec-Fetch headers (curl / Node harness) is admitted', () => {
|
||||||
|
const r = admitSameSiteRequest({
|
||||||
|
hostHeader: 'localhost:4173',
|
||||||
|
protocol: 'http:',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true, 'non-browser loopback tools pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sec-Fetch-Site none (typed URL / bookmark) is admitted', () => {
|
||||||
|
const r = admitSameSiteRequest({
|
||||||
|
hostHeader: 'localhost:4173',
|
||||||
|
protocol: 'http:',
|
||||||
|
secFetchSite: 'none',
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a LAN remote address is irrelevant: the function takes no remoteAddress', () => {
|
||||||
|
// The credential-panel gate requires a loopback socket; this gate does not.
|
||||||
|
// Passing a LAN-shaped Host/Origin pair must still match (LAN opt-in works).
|
||||||
|
const lan = admitSameSiteRequest({
|
||||||
|
hostHeader: '192.168.1.5:4173',
|
||||||
|
protocol: 'http:',
|
||||||
|
origin: 'http://192.168.1.5:4173',
|
||||||
|
secFetchSite: 'same-origin',
|
||||||
|
});
|
||||||
|
assert.equal(lan.ok, true, 'a same-origin LAN browser request is admitted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cross-port or cross-scheme Origin against the same Host is refused', () => {
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://localhost:4174' }).ok, false, 'cross-port');
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme');
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unparseable Origin is refused', () => {
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, origin: 'not a url' }).ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing Host with a present Origin is refused (no authority to match)', () => {
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, hostHeader: '' }).ok, false, 'empty Host → null authority');
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, hostHeader: undefined }).ok, false, 'absent Host → null authority');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a foreign Host is refused when an Origin is present', () => {
|
||||||
|
assert.equal(admitSameSiteRequest({ ...same, hostHeader: 'evil.example:4173' }).ok, false, 'foreign Host does not match local Origin');
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { createBrowserViteConfig } from '../../build/vite.js';
|
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
|
||||||
import standaloneConfig, * as compatibility from '../../vite.config.js';
|
import standaloneConfig, * as compatibility from '../../vite.config.js';
|
||||||
import * as providers from '../../server/providers/local.js';
|
import * as providers from '../../server/providers/local.js';
|
||||||
|
|
||||||
@@ -23,10 +23,18 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
|||||||
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
|
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
|
||||||
assert.ok(config.server.fs.deny.includes('.env.*'));
|
assert.ok(config.server.fs.deny.includes('.env.*'));
|
||||||
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
|
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
|
||||||
assert.equal(
|
const csp = config.server.headers['Content-Security-Policy'];
|
||||||
config.server.headers['Content-Security-Policy'],
|
assert.equal(csp, BROWSER_CSP);
|
||||||
|
for (const directive of [
|
||||||
|
"script-src 'self' 'unsafe-eval' blob:",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
);
|
]) {
|
||||||
|
assert.ok(csp.includes(directive), directive);
|
||||||
|
}
|
||||||
|
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
|
||||||
|
assert.deepEqual(config.preview.headers, config.server.headers);
|
||||||
assert.deepEqual(config.define, {
|
assert.deepEqual(config.define, {
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||||
@@ -127,6 +135,22 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
|
|||||||
anywhere.setHeader('X-Frame-Options', 'DENY');
|
anywhere.setHeader('X-Frame-Options', 'DENY');
|
||||||
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
||||||
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
|
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
|
||||||
|
// A full policy keeps its other directives; only the framing rule changes.
|
||||||
|
const full = "default-src 'self'; script-src 'self'; frame-ancestors 'none'";
|
||||||
|
const embeddedFull = response();
|
||||||
|
middleware({ url: '/?embed=1' }, embeddedFull, () => {});
|
||||||
|
embeddedFull.setHeader('Content-Security-Policy', full);
|
||||||
|
assert.equal(
|
||||||
|
embeddedFull.headers.get('content-security-policy'),
|
||||||
|
"default-src 'self'; script-src 'self'; frame-ancestors https://a.example",
|
||||||
|
);
|
||||||
|
const anywhereFull = response();
|
||||||
|
open({ url: '/?embed=1' }, anywhereFull, () => {});
|
||||||
|
anywhereFull.setHeader('Content-Security-Policy', full);
|
||||||
|
assert.equal(
|
||||||
|
anywhereFull.headers.get('content-security-policy'),
|
||||||
|
"default-src 'self'; script-src 'self'",
|
||||||
|
);
|
||||||
const normal = response();
|
const normal = response();
|
||||||
middleware({ url: '/' }, normal, () => {});
|
middleware({ url: '/' }, normal, () => {});
|
||||||
normal.setHeader('X-Frame-Options', 'DENY');
|
normal.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
|||||||
Reference in new issue
Block a user