mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge pull request #900 from bilawalsidhu/fix/security-consolidation
Harden the local server: cross-site gate, CSP, default throttles, LAN host check
This commit is contained in:
33 files changed
+1405
-131
No files matched your search
+30
-18
@@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY=
|
||||
# single shared key keeps working — splitting is opt-in.
|
||||
# SERVER-SIDE ONLY.
|
||||
GOOGLE_MAPS_SERVER_API_KEY=
|
||||
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
|
||||
# (/api/google/nearby-places), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
|
||||
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
|
||||
# (plus per-API quotas under APIs & Services -> Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
|
||||
# Per-IP rate limit for the Google Places cost endpoints
|
||||
# (/api/google/nearby-places + /api/google/text-search), in requests per minute.
|
||||
# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it
|
||||
# with a positive integer; 60 is a sensible tightening whenever HOST is not
|
||||
# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a
|
||||
# typo, a stray unit) falls back to the default rather than to unlimited, so a
|
||||
# slip cannot quietly disarm the guard. A positive value below 1 counts as 1.
|
||||
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
|
||||
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
|
||||
# spend protection set provider-side budget alerts: Google Cloud Console ->
|
||||
# Billing -> Budgets & alerts (plus per-API quotas under APIs & Services ->
|
||||
# Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60 # uncomment to tighten from the default 120
|
||||
|
||||
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
|
||||
# imagery, and Cesium World Terrain. The free Community plan is for eligible
|
||||
@@ -65,15 +68,19 @@ OPENAI_REALTIME_REASONING_EFFORT=low
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS=3000
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
|
||||
# Per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP spend when self-hosting on a network.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
|
||||
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||
# use several times over: an open tab asks for a HUD summary every 15s (4/min)
|
||||
# and a voice session mints one token. Raise or lower it with a positive
|
||||
# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo,
|
||||
# a stray unit) falls back to the default rather than to unlimited, so a slip
|
||||
# cannot quietly disarm the guard. A positive value below 1 counts as 1.
|
||||
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
|
||||
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
|
||||
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
||||
# Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
||||
|
||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||
# Default: oauth — requires client credentials below
|
||||
@@ -101,6 +108,11 @@ PORT=4173
|
||||
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
|
||||
# HOST=localhost
|
||||
# HOST=0.0.0.0
|
||||
# Optional: comma-separated LAN hostnames to accept, in addition to localhost
|
||||
# and IP addresses, for example GEV_ALLOWED_HOSTS=globe.lan,globe.internal.
|
||||
# Binding HOST=0.0.0.0 alone does NOT trust every Host header; suffix and
|
||||
# wildcard entries are ignored.
|
||||
# GEV_ALLOWED_HOSTS=
|
||||
|
||||
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
|
||||
# browser — the client polls the /api/firms proxy). Free key:
|
||||
|
||||
+37
-1
@@ -2,10 +2,46 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Apply the Host check before the app's own routes. Vite installs its Host
|
||||
check after the middleware that plugins add, so the provider and `/api`
|
||||
routes used to answer any Host, including a DNS-rebinding name, in every
|
||||
mode. A first-running middleware now applies the same allowed hosts on the
|
||||
dev and preview servers.
|
||||
|
||||
- Refuse cross-site browser requests to the cost-bearing and log endpoints
|
||||
(`/api/realtime/token`, `/api/openai/hud-summary`,
|
||||
`/api/google/nearby-places`, `/api/google/text-search`,
|
||||
`/api/realtime/debug-log`): a foreign or opaque
|
||||
Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403,
|
||||
while loopback tools and LAN use keep working. The dev and preview servers
|
||||
send one shared Content-Security-Policy (James Sumpter, #242).
|
||||
|
||||
- The voice debug log records the server's own time: a record can no longer
|
||||
supply its `loggedAt`. The dev and preview servers also send
|
||||
`X-Content-Type-Options: nosniff` (findings by Sunil, #710).
|
||||
|
||||
- Keep the Host-header check when binding to all interfaces. `HOST=0.0.0.0`
|
||||
used to accept any Host; the dev and preview servers now accept IP
|
||||
addresses, `localhost` and the LAN hostnames listed in `GEV_ALLOWED_HOSTS`
|
||||
(suffix and wildcard entries are ignored), which also keeps DNS-rebinding
|
||||
names out in LAN mode. The `.local` suffix is no longer accepted by default;
|
||||
list such a name explicitly (Puspo Aditya, #97, fixes #21).
|
||||
|
||||
- Throttle the cost-bearing proxies by default instead of on request. The
|
||||
OpenAI endpoints (`/api/realtime/token`, `/api/openai/hud-summary`) now allow
|
||||
30 requests per minute per client IP and the Google Places endpoints
|
||||
(`/api/google/nearby-places`, `/api/google/text-search`) 120 — the caps the
|
||||
Pinokio build already ships, so the packaged app is unaffected and only an
|
||||
unconfigured server changes, from unlimited to what the product already runs
|
||||
with. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` still
|
||||
override the caps, and exactly `0` disables them; a value that cannot be read
|
||||
as a number now falls back to the default rather than to unlimited, so a typo
|
||||
cannot silently disarm the guard (daikaginza, #683).
|
||||
|
||||
- Tighten the local MCP server and embed mode. The panel's `panel_request`
|
||||
needs the key in its MCP server's panel page, and refuses Provider
|
||||
Settings, credential and model endpoints, `/mcp` and development server
|
||||
routes in any letter case or encoding; panels on one server share six
|
||||
routes in any letter case, encoding or dot suffix; panels on one server share six
|
||||
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
|
||||
requires a Host on the port it reached and an Origin from that host, and
|
||||
times out request bodies after 30 seconds; stdio honors
|
||||
|
||||
@@ -512,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
|
||||
|
||||
### 🔒 Sharing an instance
|
||||
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux). IP addresses work as they are; if you use a LAN hostname, add it with `GEV_ALLOWED_HOSTS=globe.lan`, since binding to `0.0.0.0` does not trust arbitrary Host headers. But know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Per-IP throttles on the cost-bearing endpoints are on by default (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example` to tune them), but before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
|
||||
Provider Settings is disabled when the server is shared, so remote users cannot
|
||||
access the key-entry panel.
|
||||
|
||||
+4
-2
@@ -71,14 +71,16 @@ 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`) 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.
|
||||
- **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, 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.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
|
||||
|
||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks).
|
||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||
- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
|
||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Host header allowlist for the Vite dev/preview server. The configuration
|
||||
* factory passes the configured names in; this module reads no environment.
|
||||
*
|
||||
* Binding to a wildcard interface only controls which network interfaces accept
|
||||
* connections. It must not turn into a wildcard Host-header policy: this server
|
||||
* brokers configured API keys through local proxy routes.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export const DEFAULT_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1']);
|
||||
|
||||
/**
|
||||
* Resolve Vite's allowedHosts setting from an optional comma-separated list.
|
||||
* Empty entries and suffix/wildcard entries are ignored; repeated names keep
|
||||
* their first occurrence. Vite treats leading-dot values as suffix wildcards,
|
||||
* which would defeat an explicit host policy.
|
||||
*
|
||||
* @param {string|undefined|null} configuredHosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function resolveAllowedHosts(configuredHosts) {
|
||||
const hosts = String(configuredHosts ?? '')
|
||||
.split(',')
|
||||
.map((host) => host.trim())
|
||||
.filter((host) => host && !host.startsWith('.') && !host.includes('*'));
|
||||
return [...new Set([...DEFAULT_ALLOWED_HOSTS, ...hosts])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Host header names a host the server answers, by Vite's own rule:
|
||||
* IP addresses, `localhost` and `*.localhost` always; then `additional` names
|
||||
* (the configured server host) and `allowedHosts` (a leading dot allows a
|
||||
* domain and its subdomains). `allowedHosts === true` allows every host.
|
||||
*
|
||||
* @param {string|undefined} hostHeader
|
||||
* @param {string[]|true} allowedHosts
|
||||
* @param {string[]} [additional]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowedHost(hostHeader, allowedHosts, additional = []) {
|
||||
if (allowedHosts === true) return true;
|
||||
const host = String(hostHeader ?? '').trim();
|
||||
if (!host) return false;
|
||||
if (host[0] === '[') {
|
||||
const end = host.indexOf(']');
|
||||
return end > 0 && isIP(host.slice(1, end)) === 6;
|
||||
}
|
||||
const colon = host.indexOf(':');
|
||||
const hostname = (colon === -1 ? host : host.slice(0, colon)).toLowerCase();
|
||||
if (isIP(hostname) === 4) return true;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (additional.includes(hostname)) return true;
|
||||
return (allowedHosts || []).some(
|
||||
(allowed) =>
|
||||
allowed === hostname ||
|
||||
(allowed[0] === '.' &&
|
||||
(allowed.slice(1) === hostname || hostname.endsWith(allowed))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that applies the Host check before every other middleware.
|
||||
* Vite installs its own check after the middleware plugins add in
|
||||
* `configureServer`, so without this the app's `/api` routes would answer any
|
||||
* Host, including a DNS-rebinding name. It runs first (`enforce: 'pre'`) and
|
||||
* uses the same allowed hosts as Vite, on the dev and preview servers.
|
||||
*/
|
||||
export function hostCheckPlugin() {
|
||||
const middleware = (config, preview) => {
|
||||
const allowed = preview
|
||||
? config.preview.allowedHosts
|
||||
: config.server.allowedHosts;
|
||||
const additional = [config.server.host, config.preview.host].filter(
|
||||
(value) => typeof value === 'string' && value,
|
||||
);
|
||||
return (req, res, next) => {
|
||||
if (isAllowedHost(req.headers.host, allowed, additional)) return next();
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Blocked request. This host is not allowed.');
|
||||
};
|
||||
};
|
||||
return {
|
||||
name: 'host-check',
|
||||
enforce: 'pre',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware(server.config, false));
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware(server.config, true));
|
||||
},
|
||||
};
|
||||
}
|
||||
+16
-2
@@ -11,6 +11,15 @@
|
||||
|
||||
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. */
|
||||
export function isEmbedDocumentRequest(url) {
|
||||
let parsed;
|
||||
@@ -43,8 +52,13 @@ export function embedFramingPlugin({
|
||||
res.setHeader = (name, value) => {
|
||||
const key = String(name).toLowerCase();
|
||||
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
|
||||
if (key === 'content-security-policy' && !anywhere)
|
||||
return setHeader(name, policy);
|
||||
if (key === 'content-security-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;
|
||||
};
|
||||
if (!anywhere) setHeader('Content-Security-Policy', policy);
|
||||
|
||||
+58
-10
@@ -1,8 +1,51 @@
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* No inline script is permitted, and the only foreign script origins are the
|
||||
* three that the Bhote Koshi event's embedded media needs (YouTube, Facebook
|
||||
* and X), which frame-src matches for their players and posts. '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'",
|
||||
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
|
||||
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
|
||||
"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:",
|
||||
// The same media's players and posts, which load in frames.
|
||||
"frame-src 'self' blob: https://www.youtube-nocookie.com https://www.youtube.com https://www.facebook.com https://platform.twitter.com",
|
||||
"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,
|
||||
// Responses are only ever used as the type the server declares.
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/** Build browser assets with explicit inputs; never load environment or providers. */
|
||||
export function createBrowserViteConfig({
|
||||
plugins = [],
|
||||
@@ -11,10 +54,13 @@ export function createBrowserViteConfig({
|
||||
cesiumToken,
|
||||
host = 'localhost',
|
||||
port = 4173,
|
||||
allowedHosts = DEFAULT_ALLOWED_HOSTS,
|
||||
command,
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
...plugins,
|
||||
@@ -39,20 +85,22 @@ export function createBrowserViteConfig({
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
port: parseInt(port, 10) || 4173,
|
||||
allowedHosts:
|
||||
host === '0.0.0.0' || host === '::'
|
||||
? true
|
||||
: ['localhost', '127.0.0.1', '.local'],
|
||||
// A wildcard bind only chooses interfaces; it never disables Vite's
|
||||
// Host-header check. LAN hostnames are added explicitly (see
|
||||
// build/allowedHosts.js); IP addresses are always accepted.
|
||||
allowedHosts: [...allowedHosts],
|
||||
fs: {
|
||||
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
||||
},
|
||||
// These headers protect the document containing Provider Settings.
|
||||
// Embed-mode documents are framable instead; see embed-framing.js.
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||
},
|
||||
// These headers protect the document containing Provider Settings and
|
||||
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
|
||||
// Embed-mode documents are framable instead: embed-framing.js rewrites
|
||||
// only the frame-ancestors directive and keeps the rest of the policy.
|
||||
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: {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||
|
||||
+12
-1
@@ -915,10 +915,21 @@ errors.
|
||||
## Places and CCTV request bounds
|
||||
|
||||
With a Google key configured, nearby and text search reject missing, blank,
|
||||
non-numeric and out-of-range coordinates before the opt-in limiter and upstream
|
||||
non-numeric and out-of-range coordinates before the per-IP limiter and upstream
|
||||
request. Text search also requires a nonblank query. Keyless requests retain
|
||||
their `configured: false` response.
|
||||
|
||||
The cost-bearing proxies are throttled per client IP without configuration:
|
||||
`/api/realtime/token` and `/api/openai/hud-summary` share 30 requests per
|
||||
minute per IP, `/api/google/nearby-places` and `/api/google/text-search` share
|
||||
120 — the caps the Pinokio build already sets, so the packaged app is
|
||||
unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` override
|
||||
those; exactly `0` disables the limiter, while a value that cannot be read as a
|
||||
number falls back to the default rather than to unlimited. Over-limit requests
|
||||
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
|
||||
The client key is the socket peer address only — a forwarded-for header is not
|
||||
trusted, so a proxied deployment shares one bucket per upstream hop.
|
||||
|
||||
CCTV media waits at most 15 seconds for upstream response headers and returns
|
||||
504 on timeout. Its timer stops when headers arrive, so live bodies can continue
|
||||
streaming; a separate 30-second idle deadline then bounds the gap between
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ map imagery, tiles and fonts load directly, from the providers the
|
||||
resource's `csp` lists. `panel_request` refuses Provider Settings
|
||||
(`/api/setup`), credential and model endpoints (`/api/realtime`,
|
||||
`/api/openai`), `/mcp` and the development server's internal routes, in any
|
||||
letter case or encoding.
|
||||
letter case, encoding or dot suffix.
|
||||
|
||||
The panel loads the app's panel build, which `npm run build:panel` writes to
|
||||
`dist/panel` and the servers serve at `/panel/`: one app script, one
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"server/standalone/vite.config.js",
|
||||
"vite.config.js",
|
||||
"src/tooling/viteBuild.test.mjs",
|
||||
"src/tooling/allowedHosts.test.mjs",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/aircraft/opensky.js",
|
||||
@@ -76,6 +77,7 @@
|
||||
"server/providers/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/tooling/placeProviders.test.mjs",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
"server/providers/space/launch-library.js",
|
||||
@@ -892,5 +894,6 @@
|
||||
"src/view/index.test.mjs",
|
||||
"src/tools/views.test.mjs",
|
||||
"src/app/embed.test.mjs",
|
||||
"src/tools/globePanel.test.mjs"
|
||||
"src/tools/globePanel.test.mjs",
|
||||
"src/costEndpointThrottle.test.mjs"
|
||||
]
|
||||
@@ -31,6 +31,7 @@
|
||||
"modules": [
|
||||
"build/vite.js",
|
||||
"build/application-html.js",
|
||||
"build/allowedHosts.js",
|
||||
"build/embed-framing.js",
|
||||
"build/panel.js"
|
||||
],
|
||||
@@ -74,6 +75,8 @@
|
||||
"server/providers/common/http.js",
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
@@ -287,6 +290,8 @@
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/sources/rateLimit.js"
|
||||
@@ -301,6 +306,7 @@
|
||||
"server/providers/common/source-root.js",
|
||||
"server/standalone/key-setup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/standalone/key-setup-hardening.mjs"
|
||||
],
|
||||
"external": []
|
||||
@@ -385,7 +391,8 @@
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/mapStackChips.js",
|
||||
"src/keySetupCore.mjs"
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -404,6 +411,7 @@
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
@@ -524,6 +532,7 @@
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/locationStatus.js",
|
||||
"src/mapStackChips.js",
|
||||
@@ -1261,6 +1270,7 @@
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1506,6 +1516,7 @@
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/keySetup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/alpr/index.js",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* rebinding) and, when a browser sends an Origin, come from that same host.
|
||||
*/
|
||||
|
||||
import { hasProxySignals, isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
|
||||
import { createLocalMcpServer } from './server.js';
|
||||
|
||||
|
||||
@@ -2,24 +2,52 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
/**
|
||||
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
|
||||
* returns `null` and the caller skips the check entirely — a runtime no-op that
|
||||
* preserves the original behavior. Only a positive integer N enables a fixed
|
||||
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
|
||||
* window state persists across requests). The global backstop is set to a
|
||||
* generous multiple of the per-IP cap so a single host can't starve the rest.
|
||||
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
|
||||
*
|
||||
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
|
||||
* The default is ON. An unset or blank value takes `defaultPerMin`; a positive
|
||||
* integer overrides it; an explicit `0` disables the limiter for deployments
|
||||
* that genuinely want no throttle.
|
||||
*
|
||||
* A value that cannot be read as a number — a typo, a stray unit, a negative —
|
||||
* also takes the default rather than disabling the guard. That asymmetry is the
|
||||
* point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed
|
||||
* server's API key to whoever asks. Removing the throttle stays possible, but
|
||||
* only by saying `0`, which cannot be typed by accident.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {number} Requests/min/IP; `0` means unlimited.
|
||||
*/
|
||||
export function resolvePerMinuteCap(envValue, defaultPerMin) {
|
||||
const raw = typeof envValue === 'string' ? envValue.trim() : envValue;
|
||||
if (raw === undefined || raw === null || raw === '') return defaultPerMin;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin;
|
||||
// Only an explicit 0 disables the guard: a positive fraction such as 0.5
|
||||
// would floor to 0, so it takes the strictest real cap instead.
|
||||
if (parsed > 0 && parsed < 1) return 1;
|
||||
return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
* Returns `null` only when the cap resolves to `0` (the explicit opt-out), in
|
||||
* which case the caller skips the check entirely. Otherwise a fixed 60s window
|
||||
* of N requests/IP, built lazily once and reused so its per-IP window state
|
||||
* persists across requests. The global backstop is a generous multiple of the
|
||||
* per-IP cap so a single host can't starve the rest.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
|
||||
*/
|
||||
export function makeOptInRateLimiter(envValue) {
|
||||
const max = Number(envValue);
|
||||
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
|
||||
export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
const max = resolvePerMinuteCap(envValue, defaultPerMin);
|
||||
if (max <= 0) return null; // explicit opt-out
|
||||
return makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: Math.floor(max),
|
||||
globalMax: Math.floor(max) * 20,
|
||||
max,
|
||||
globalMax: max * 20,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { createDebugLogHandler } from './openai/debug-log.js';
|
||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
import { sameSiteGated } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||
@@ -15,16 +16,20 @@ function openAiRealtimeProxy({
|
||||
realtime = {},
|
||||
} = {}) {
|
||||
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(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
sameSiteGated(createDebugLogHandler({ sourceRoot })),
|
||||
);
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
sameSiteGated(
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,11 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
// The server's own timestamp comes last, so a record cannot supply one.
|
||||
await append(
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
loggedAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
);
|
||||
res.statusCode = 204;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
||||
|
||||
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
||||
// fallback has no provider cost and resolves above without consuming a
|
||||
// paid-endpoint quota slot.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
// Keyless HUD fallback has no provider cost and resolves above without
|
||||
// consuming a paid-endpoint quota slot.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, 64 * 1024);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both
|
||||
* places that already name a value agree on it: `.env.example` recommends 30
|
||||
* and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app
|
||||
* is unaffected and only an unconfigured server changes. The HUD asks for a
|
||||
* summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and
|
||||
* a voice session adds one token mint; the cap clears ordinary use several
|
||||
* times over and bites only a caller working the endpoint far harder than the
|
||||
* app does.
|
||||
*/
|
||||
const OPENAI_DEFAULT_PER_MIN = 30;
|
||||
|
||||
// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later
|
||||
// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading
|
||||
// process.env here at import time would always see them unset and silently stay unlimited even when
|
||||
// process.env here at import time would always see them unset and fall back to the default even when
|
||||
// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env;
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default).
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out.
|
||||
let _openAiRateLimiter;
|
||||
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */
|
||||
function openAiRateLimiter() {
|
||||
if (_openAiRateLimiter === undefined)
|
||||
_openAiRateLimiter = makeOptInRateLimiter(
|
||||
_openAiRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _openAiRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null (unlimited, the default) this is a no-op returning
|
||||
* `true`, so the handler proceeds exactly as before.
|
||||
* Apply the limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
|
||||
* returning `true`, so the handler proceeds unthrottled.
|
||||
*
|
||||
* @param {((key:string)=>boolean)|null} limiter
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
|
||||
*/
|
||||
function enforceOptInRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // unlimited (default) — no behavior change
|
||||
function enforceRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // explicit 0 opt-out — unthrottled
|
||||
if (limiter(clientKey(req))) return true;
|
||||
res.statusCode = 429;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export { enforceOptInRateLimit, openAiRateLimiter };
|
||||
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import {
|
||||
resolveVoiceModel,
|
||||
isKnownVoiceTier,
|
||||
@@ -31,8 +31,8 @@ function createRealtimeTokenHandler({
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
export { installRouteMiddleware } from './places/routes.js';
|
||||
export {
|
||||
makeRateLimiter,
|
||||
makeOptInRateLimiter,
|
||||
makeCostRateLimiter,
|
||||
clientKey,
|
||||
} from './common/rate-limit.js';
|
||||
export { haversineKm } from './common/geo.js';
|
||||
@@ -2,21 +2,35 @@ import {
|
||||
googleServerApiKey,
|
||||
keylessGooglePlacesResponse,
|
||||
} from './google-key.js';
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import {
|
||||
projectNearbyPlaces,
|
||||
projectTextSearchPlaces,
|
||||
} from '../../../src/data/placeProviderPayloads.js';
|
||||
|
||||
// Construct lazily after the standalone environment has loaded.
|
||||
// undefined = not built yet; null = unlimited; fn = active limiter
|
||||
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
|
||||
let _googleRateLimiter;
|
||||
|
||||
/** Google cost endpoint (nearby-places). Null = unlimited (default). */
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
|
||||
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
|
||||
* packaged app keeps behaving exactly as it does today and only an
|
||||
* unconfigured server changes — from unlimited to what the product already
|
||||
* runs with. Places calls are user-driven (a search, a nearby lookup, an
|
||||
* installation probe) rather than polled, so this sits far above what the app
|
||||
* generates and bites only a caller enumerating the endpoint. `.env.example`
|
||||
* suggests a tighter 60 when the host is not localhost; that still applies.
|
||||
*/
|
||||
export const GOOGLE_DEFAULT_PER_MIN = 120;
|
||||
|
||||
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
|
||||
function googleRateLimiter() {
|
||||
if (_googleRateLimiter === undefined)
|
||||
_googleRateLimiter = makeOptInRateLimiter(
|
||||
_googleRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _googleRateLimiter;
|
||||
}
|
||||
@@ -50,6 +64,9 @@ export function googlePlacesContextProxy({
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
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') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -80,7 +97,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
|
||||
// `places: []` contract that the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
@@ -161,6 +178,8 @@ export function googlePlacesContextProxy({
|
||||
// a view-biased Text Search lands on the actual feature. Same key, field
|
||||
// mask, throttle, and `places: []` error contract as nearby-places above.
|
||||
middlewares.use('/api/google/text-search', async (req, res) => {
|
||||
// Paid like nearby-places, so gated the same way, first.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -200,7 +219,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
|
||||
// contract the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { resolveAllowedHosts } from '../../build/allowedHosts.js';
|
||||
import { createBrowserViteConfig } from '../../build/vite.js';
|
||||
import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
@@ -24,6 +25,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
|
||||
command,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import {
|
||||
makeCostRateLimiter,
|
||||
resolvePerMinuteCap,
|
||||
} from '../server/providers/common/rate-limit.js';
|
||||
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
|
||||
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
|
||||
import {
|
||||
googlePlacesContextProxy,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
} from '../server/providers/places/google.js';
|
||||
|
||||
// Both limiters are cached at module scope on first use, so the env value that
|
||||
// matters is the one in place when a route is first exercised — restoring the
|
||||
// variable afterwards does not rebuild the limiter. Each route is therefore
|
||||
// driven by exactly one test here; a second test wanting a different cap would
|
||||
// need its own process, not just a different env value.
|
||||
|
||||
/** Set an env var for one test and put the old value back afterwards. */
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
restart: async () => {},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from([]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({ status: this.statusCode, headers, body: String(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unset throttle resolves to the documented default, not to unlimited', () => {
|
||||
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
|
||||
// anyone who could reach it mint tokens on the operator's key without bound.
|
||||
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
|
||||
assert.equal(resolvePerMinuteCap('', 30), 30);
|
||||
assert.equal(resolvePerMinuteCap(' ', 30), 30);
|
||||
assert.ok(
|
||||
typeof makeCostRateLimiter(undefined, 30) === 'function',
|
||||
'an unconfigured cost endpoint must still be throttled',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit 0 is the documented way to run unthrottled', () => {
|
||||
assert.equal(resolvePerMinuteCap('0', 30), 0);
|
||||
assert.equal(
|
||||
makeCostRateLimiter('0', 30),
|
||||
null,
|
||||
'operators who mean "no throttle" must still be able to say so',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured value wins, and a fractional one floors', () => {
|
||||
assert.equal(resolvePerMinuteCap('45', 30), 45);
|
||||
assert.equal(resolvePerMinuteCap(45, 30), 45);
|
||||
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
|
||||
});
|
||||
|
||||
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
|
||||
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
|
||||
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
|
||||
assert.equal(
|
||||
typeof makeCostRateLimiter(fraction, 30),
|
||||
'function',
|
||||
`${fraction} must still throttle`,
|
||||
);
|
||||
}
|
||||
// Only an explicit zero turns the limiter off.
|
||||
assert.equal(makeCostRateLimiter('0', 30), null);
|
||||
assert.equal(makeCostRateLimiter('0.0', 30), null);
|
||||
});
|
||||
|
||||
test('an unreadable value keeps the guard instead of removing it', () => {
|
||||
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
|
||||
// Reading either as "unlimited" would disarm the spend guard on a typo —
|
||||
// the opposite of what the operator was reaching for when they typed it.
|
||||
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
|
||||
assert.equal(
|
||||
resolvePerMinuteCap(typo, 30),
|
||||
30,
|
||||
`${typo} must fall back to the default, not to unlimited`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default cap clears the app’s own demand with room to spare', () => {
|
||||
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
|
||||
// tab, plus one token mint per voice session. The cap has to sit well above
|
||||
// that or it would throttle the app rather than an abuser.
|
||||
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
|
||||
assert.ok(
|
||||
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
|
||||
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
|
||||
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
|
||||
statuses.push((await request(handler)).status);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
const overLimit = await request(handler);
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ output_text: 'five word hud summary here' });
|
||||
});
|
||||
|
||||
// Its own client address: the limiter is cached at module scope and shared
|
||||
// across the OpenAI routes, so the realtime case above has already spent
|
||||
// 127.0.0.1's budget inside this process.
|
||||
const from = '10.9.9.9';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
|
||||
statuses.push(
|
||||
(await request(handleHudSummary, { remoteAddress: from })).status,
|
||||
);
|
||||
assert.deepEqual(
|
||||
statuses,
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary HUD use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
|
||||
// The keyless HUD answer is deliberately NOT throttled: it is composed
|
||||
// locally, spends nothing upstream, and capping it would only take the free
|
||||
// HUD away from an unconfigured install. Same exhausted client, no key.
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
const free = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(free.status, 200, 'the free path answers past a spent budget');
|
||||
assert.equal(JSON.parse(free.body).configured, false);
|
||||
assert.equal(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
true,
|
||||
'the free path reaches no provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
|
||||
let upstreamCalls = 0;
|
||||
const handler = install(
|
||||
googlePlacesContextProxy({
|
||||
resolveApiKey: () => 'fixture-google-server-key',
|
||||
fetchImpl: async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ places: [] });
|
||||
},
|
||||
}),
|
||||
).get('/api/google/nearby-places');
|
||||
|
||||
const url = '/?lat=37.77493&lon=-122.41942';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
|
||||
statuses.push((await request(handler, { url, method: 'GET' })).status);
|
||||
}
|
||||
assert.ok(
|
||||
statuses.every((s) => s === 200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handler, { url, method: 'GET' });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
// Every error response on this route carries places[]; the client reads it
|
||||
// unconditionally, so a throttled reply must not be the one shape that omits it.
|
||||
assert.deepEqual(JSON.parse(overLimit.body).places, []);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches Google, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the code defaults match the caps the Pinokio build ships', () => {
|
||||
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
|
||||
// this repo suggested elsewhere had already drifted away from them. Now that
|
||||
// they are the server's defaults, the packaged app and an unconfigured
|
||||
// checkout must agree, or a Pinokio user's throttle silently stops matching
|
||||
// the one everybody else gets. If you deliberately want them to differ, this
|
||||
// assertion is the place to say so.
|
||||
const shipped = readPinokioEnvironment(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'pinokio',
|
||||
'_ENVIRONMENT',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
});
|
||||
+4
-25
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Key setup ("POWER UP") — the pure core.
|
||||
*
|
||||
* One registry, three pure functions, zero dependencies. The dev server's
|
||||
* One registry and pure functions; its only import is the proxy-header check
|
||||
* shared with the other local gates (localRequestGate.mjs). The dev server's
|
||||
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
|
||||
* are both thin shells over this module, so what a key is called, what it
|
||||
* unlocks, and how a .env line is written each live in exactly one place.
|
||||
@@ -11,6 +12,8 @@
|
||||
* behavior below unit-testable.
|
||||
*/
|
||||
|
||||
import { hasProxySignals } from './localRequestGate.mjs';
|
||||
|
||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||
export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||
|
||||
@@ -184,30 +187,6 @@ export function parseWindowsUserSid(stdout) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Headers that reverse proxies, tunnels and CDNs add to a request. */
|
||||
export const PROXY_SIGNAL_HEADERS = Object.freeze([
|
||||
'forwarded',
|
||||
'via',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-port',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'cf-connecting-ip',
|
||||
'cf-ray',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a request carries reverse-proxy or CDN forwarding headers, so it
|
||||
* did not originate on this machine whatever its socket says. `headers` is
|
||||
* keyed by lower-case header name.
|
||||
*/
|
||||
export function hasProxySignals(headers = {}) {
|
||||
return PROXY_SIGNAL_HEADERS.some(
|
||||
(name) => String(headers[name] || '').trim() !== '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the launcher has sharing on. Every sharing signal the launcher
|
||||
* recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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. This module owns
|
||||
* the list for every local gate (the credential panel and /mcp import
|
||||
* `hasProxySignals`), and has no imports so it 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',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a request carries reverse-proxy or CDN forwarding headers. `headers`
|
||||
* is keyed by lower-case header name.
|
||||
*/
|
||||
export function hasProxySignals(headers = {}) {
|
||||
return PROXY_SIGNALS.some(
|
||||
(name) => String(headers[name] || '').trim() !== '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (hasProxySignals(proxyHeaders)) {
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
DEFAULT_ALLOWED_HOSTS,
|
||||
hostCheckPlugin,
|
||||
isAllowedHost,
|
||||
resolveAllowedHosts,
|
||||
} from '../../build/allowedHosts.js';
|
||||
|
||||
test('uses the restricted local host allowlist by default', () => {
|
||||
assert.deepEqual(resolveAllowedHosts(), DEFAULT_ALLOWED_HOSTS);
|
||||
assert.equal(resolveAllowedHosts().includes(true), false);
|
||||
});
|
||||
|
||||
test('adds explicitly configured LAN hostnames without widening the allowlist', () => {
|
||||
assert.deepEqual(
|
||||
resolveAllowedHosts('globe.lan, globe.internal ,globe.lan'),
|
||||
['localhost', '127.0.0.1', 'globe.lan', 'globe.internal'],
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores empty configured host entries', () => {
|
||||
assert.deepEqual(resolveAllowedHosts(' , , globe.lan, '), [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'globe.lan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects suffix and wildcard entries so every LAN hostname is explicit', () => {
|
||||
assert.deepEqual(resolveAllowedHosts('.local,*.example,globe.lan'), [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'globe.lan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a Host is allowed by the same rule Vite applies', () => {
|
||||
const allowed = ['localhost', '127.0.0.1', 'globe.lan', '.corp.example'];
|
||||
for (const host of [
|
||||
'localhost:4173',
|
||||
'LOCALHOST:4173',
|
||||
'app.localhost',
|
||||
'127.0.0.1:4173',
|
||||
'192.168.1.20:4173',
|
||||
'[::1]:4173',
|
||||
'globe.lan:4173',
|
||||
'corp.example',
|
||||
'a.corp.example:8080',
|
||||
'office:4173',
|
||||
])
|
||||
assert.equal(isAllowedHost(host, allowed, ['office']), true, host);
|
||||
for (const host of [
|
||||
undefined,
|
||||
'',
|
||||
'evil.example:4173',
|
||||
'globe.lan.evil.example',
|
||||
'localhost.evil.example',
|
||||
'mybox.local:4173',
|
||||
'[not-an-ip]:4173',
|
||||
])
|
||||
assert.equal(isAllowedHost(host, allowed, ['office']), false, String(host));
|
||||
assert.equal(isAllowedHost('anything.example', true), true);
|
||||
});
|
||||
|
||||
test('the Host check is installed first on the dev and preview servers', () => {
|
||||
const plugin = hostCheckPlugin();
|
||||
assert.equal(plugin.enforce, 'pre');
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
let middleware;
|
||||
plugin[hook]({
|
||||
config: {
|
||||
server: { allowedHosts: ['localhost'], host: 'localhost' },
|
||||
preview: { allowedHosts: ['localhost'], host: undefined },
|
||||
},
|
||||
middlewares: { use: (fn) => (middleware = fn) },
|
||||
});
|
||||
const respond = (host) => {
|
||||
const res = {
|
||||
status: null,
|
||||
writeHead(status) {
|
||||
this.status = status;
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
let passed = false;
|
||||
middleware({ headers: { host } }, res, () => (passed = true));
|
||||
return passed ? 'next' : res.status;
|
||||
};
|
||||
assert.equal(respond('localhost:4173'), 'next', hook);
|
||||
assert.equal(respond('evil.example:4173'), 403, hook);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unlisted Host cannot reach a route a plugin mounts', async (t) => {
|
||||
const { createServer } = await import('vite');
|
||||
const { createBrowserViteConfig } = await import('../../build/vite.js');
|
||||
const provider = {
|
||||
name: 'fixture-provider',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/fixture', (req, res) => {
|
||||
res.end('provider answered');
|
||||
});
|
||||
},
|
||||
};
|
||||
const base = createBrowserViteConfig({ plugins: [provider] });
|
||||
const vite = await createServer({
|
||||
...base,
|
||||
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
// Only this test's plugins: the Host check and the fixture provider.
|
||||
plugins: base.plugins.filter((plugin) =>
|
||||
['host-check', 'fixture-provider'].includes(plugin?.name),
|
||||
),
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: {
|
||||
...base.server,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
hmr: false,
|
||||
watch: null,
|
||||
},
|
||||
});
|
||||
await vite.listen();
|
||||
t.after(() => vite.close());
|
||||
const { port } = vite.httpServer.address();
|
||||
const get = (host) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/fixture',
|
||||
headers: { Host: host },
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
resolve(response.statusCode);
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
assert.equal(await get(`localhost:${port}`), 200);
|
||||
assert.equal(await get(`127.0.0.1:${port}`), 200);
|
||||
assert.equal(await get(`evil.example:${port}`), 403);
|
||||
assert.equal(await get(`mybox.local:${port}`), 403);
|
||||
});
|
||||
@@ -391,8 +391,8 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures',
|
||||
error: 'Failed to write Realtime debug log',
|
||||
});
|
||||
|
||||
// The limiter is always on, unlike the opt-in one the cost-bearing routes
|
||||
// share: 120/min per IP, far above what a voice session writes.
|
||||
// This limiter has no opt-out, unlike the one the cost-bearing routes share:
|
||||
// 120/min per IP, far above what a voice session writes.
|
||||
let limited = null;
|
||||
let accepted = 0;
|
||||
for (let n = 0; n < 130 && !limited; n += 1) {
|
||||
@@ -415,6 +415,24 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures',
|
||||
assert.ok(lines.every((line) => JSON.parse(line).loggedAt));
|
||||
});
|
||||
|
||||
test('a debug-log record cannot supply its own timestamp', async (t) => {
|
||||
const sourceRoot = root(t);
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
const before = Date.now();
|
||||
const response = await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ loggedAt: '1999-01-01T00:00:00.000Z', note: 'x' }),
|
||||
});
|
||||
assert.equal(response.status, 204);
|
||||
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
|
||||
const [line] = readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
||||
const record = JSON.parse(line);
|
||||
assert.equal(record.note, 'x');
|
||||
assert.ok(Date.parse(record.loggedAt) >= before - 1000);
|
||||
});
|
||||
|
||||
test('an oversized debug-log request receives the fixed error response', async (t) => {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get(
|
||||
'/api/realtime/debug-log',
|
||||
|
||||
@@ -482,3 +482,64 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
|
||||
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
|
||||
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
|
||||
});
|
||||
|
||||
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
|
||||
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
});
|
||||
let upstream = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstream += 1;
|
||||
return Response.json({ places: [] });
|
||||
});
|
||||
const call = async (route, url, headers) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status) {
|
||||
this.statusCode = status;
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
await routes.get(route)(
|
||||
{
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: { host: 'localhost:4173', ...headers },
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
},
|
||||
res,
|
||||
);
|
||||
return res.statusCode;
|
||||
};
|
||||
const crossSite = {
|
||||
origin: 'https://attacker.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
};
|
||||
for (const [route, url] of [
|
||||
['/api/google/nearby-places', '?lat=30&lon=-97'],
|
||||
['/api/google/text-search', '?q=museum&lat=30&lon=-97'],
|
||||
]) {
|
||||
assert.equal(await call(route, url, crossSite), 403, route);
|
||||
assert.equal(
|
||||
await call(route, url, { 'sec-fetch-site': 'cross-site' }),
|
||||
403,
|
||||
route,
|
||||
);
|
||||
}
|
||||
assert.equal(upstream, 0, 'a refused request never reaches Google');
|
||||
// The app's own page still gets answers.
|
||||
assert.equal(
|
||||
await call('/api/google/text-search', '?q=museum&lat=30&lon=-97', {
|
||||
origin: 'http://localhost:4173',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
assert.equal(upstream, 1);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 * as providers from '../../server/providers/local.js';
|
||||
|
||||
@@ -12,29 +12,45 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
googleApiKey: 'browser-fixture',
|
||||
cesiumToken: 'ion-fixture',
|
||||
});
|
||||
assert.equal(config.plugins[2], plugin);
|
||||
// The Host check runs before every other plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'host-check');
|
||||
assert.equal(config.plugins[0].enforce, 'pre');
|
||||
assert.equal(config.plugins[3], plugin);
|
||||
assert.equal(config.server.host, 'localhost');
|
||||
assert.equal(config.server.port, 4173);
|
||||
assert.deepEqual(config.server.allowedHosts, [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'.local',
|
||||
]);
|
||||
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
|
||||
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
|
||||
assert.ok(config.server.fs.deny.includes('.env.*'));
|
||||
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
|
||||
assert.equal(
|
||||
config.server.headers['Content-Security-Policy'],
|
||||
assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff');
|
||||
const csp = 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'",
|
||||
);
|
||||
]) {
|
||||
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, {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||
});
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server
|
||||
.allowedHosts,
|
||||
true,
|
||||
// A wildcard bind keeps the Host-header check; LAN names are explicit.
|
||||
for (const host of ['0.0.0.0', '::'])
|
||||
assert.deepEqual(
|
||||
createBrowserViteConfig({ host, port: '4800' }).server.allowedHosts,
|
||||
['localhost', '127.0.0.1'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
createBrowserViteConfig({
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['localhost', '127.0.0.1', 'globe.lan'],
|
||||
}).server.allowedHosts,
|
||||
['localhost', '127.0.0.1', 'globe.lan'],
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ host: '::', port: '4800' }).server.port,
|
||||
@@ -52,7 +68,7 @@ test('build helper does not discover environment values or construct local provi
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(2).map((plugin) => plugin.name),
|
||||
config.plugins.slice(3).map((plugin) => plugin.name),
|
||||
['embed-framing', 'panel-build'],
|
||||
);
|
||||
} finally {
|
||||
@@ -66,7 +82,7 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(2, -4).map((plugin) => plugin.name),
|
||||
config.plugins.slice(3, -4).map((plugin) => plugin.name),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
|
||||
@@ -127,6 +143,22 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
|
||||
anywhere.setHeader('X-Frame-Options', 'DENY');
|
||||
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
||||
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();
|
||||
middleware({ url: '/' }, normal, () => {});
|
||||
normal.setHeader('X-Frame-Options', 'DENY');
|
||||
@@ -143,3 +175,48 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
|
||||
assert.equal(plugin.configurePreviewServer, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('the CSP admits the event media embeds, and no other script origin', async () => {
|
||||
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
|
||||
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
|
||||
const directive = (name) =>
|
||||
BROWSER_CSP.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name} `))
|
||||
?.split(/\s+/)
|
||||
.slice(1) ?? [];
|
||||
const scripts = directive('script-src');
|
||||
const frames = directive('frame-src');
|
||||
// The player API, the Facebook SDK and X's widget script.
|
||||
for (const origin of [
|
||||
'https://www.youtube.com',
|
||||
'https://connect.facebook.net',
|
||||
'https://platform.twitter.com',
|
||||
])
|
||||
assert.ok(scripts.includes(origin), origin);
|
||||
// Only those three, and never a wildcard or a scheme.
|
||||
assert.deepEqual(
|
||||
scripts.filter(
|
||||
(source) => source.startsWith('http') || source === 'https:',
|
||||
),
|
||||
[
|
||||
'https://www.youtube.com',
|
||||
'https://connect.facebook.net',
|
||||
'https://platform.twitter.com',
|
||||
],
|
||||
);
|
||||
assert.ok(!scripts.some((source) => source.includes('*')));
|
||||
// Every frame the event builds for a YouTube or Facebook source is allowed.
|
||||
for (const link of [
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtu.be/dQw4w9WgXcQ',
|
||||
'https://www.facebook.com/facebook/videos/10153231379946729/',
|
||||
]) {
|
||||
const source = resolveEmbeddedMediaSource(link);
|
||||
assert.ok(source, link);
|
||||
const origin = new URL(embeddedMediaFrameUrl(source, {})).origin;
|
||||
assert.ok(frames.includes(origin), `${link} -> ${origin}`);
|
||||
}
|
||||
// X posts load in X's own frames.
|
||||
assert.ok(frames.includes('https://platform.twitter.com'));
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
|
||||
|
||||
test('only direct loopback connections to this server are local', () => {
|
||||
@@ -236,3 +237,96 @@ test('a client that stops sending its request body is answered with a timeout',
|
||||
});
|
||||
assert.equal(status, 408);
|
||||
});
|
||||
|
||||
test('panel requests cannot reach /mcp through a suffix the server routes to it', async (t) => {
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
const vite = await createViteServer({
|
||||
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [localMcpPlugin()],
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false, watch: null },
|
||||
});
|
||||
await vite.listen();
|
||||
t.after(() => vite.close());
|
||||
const { port } = vite.httpServer.address();
|
||||
const rpc = (path, message) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify(message);
|
||||
const request = httpRequest(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: `localhost:${port}`,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let text = '';
|
||||
response.on('data', (chunk) => (text += chunk));
|
||||
response.on('end', () =>
|
||||
resolve({ status: response.statusCode, text }),
|
||||
);
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
|
||||
// The hazard is real: the server routes /mcp.json to the MCP endpoint.
|
||||
const direct = await rpc('/mcp.json', {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'ping',
|
||||
});
|
||||
assert.equal(direct.status, 200);
|
||||
assert.deepEqual(JSON.parse(direct.text), {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {},
|
||||
});
|
||||
|
||||
const page = JSON.parse(
|
||||
(
|
||||
await rpc('/mcp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://gods-eye-view/globe' },
|
||||
})
|
||||
).text,
|
||||
);
|
||||
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'ping' }),
|
||||
).toString('base64');
|
||||
for (const path of ['/mcp', '/mcp.json', '/MCP.json', '/mcp.anything']) {
|
||||
const answer = JSON.parse(
|
||||
(
|
||||
await rpc('/mcp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'panel_request',
|
||||
arguments: {
|
||||
key,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
},
|
||||
},
|
||||
})
|
||||
).text,
|
||||
);
|
||||
assert.equal(answer.result.isError, true, path);
|
||||
assert.match(answer.result.content[0].text, /is not available/, path);
|
||||
}
|
||||
});
|
||||
@@ -25,13 +25,14 @@ const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
|
||||
export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 60 * 1000;
|
||||
const METHODS = new Set(['GET', 'HEAD', 'POST']);
|
||||
// Routes the panel never loads, matched without regard to case as the
|
||||
// server routes them: Provider Settings, which only the app's own page may
|
||||
// use; credential and model endpoints; the MCP server itself; and the
|
||||
// development server's internal routes.
|
||||
// Routes the panel never loads, matched as the server routes them: without
|
||||
// regard to case, and with a mounted route also matching when a dot follows
|
||||
// it (`/mcp.json` reaches `/mcp`). Provider Settings, which only the app's own
|
||||
// page may use; credential and model endpoints; the MCP server itself; and
|
||||
// the development server's internal routes.
|
||||
const REFUSED_PATHS = [
|
||||
/^\/api\/(?:setup|realtime|openai)(?:\/|$)/i,
|
||||
/^\/mcp(?:\/|$)/i,
|
||||
/^\/api\/(?:setup|realtime|openai)(?:[/.]|$)/i,
|
||||
/^\/mcp(?:[/.]|$)/i,
|
||||
/^\/(?:@|__)/,
|
||||
];
|
||||
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
|
||||
|
||||
@@ -234,6 +234,12 @@ test('routes the panel never loads are refused in any case or encoding', async (
|
||||
'/api/%4fpenai/hud-summary',
|
||||
'/mcp',
|
||||
'/MCP',
|
||||
// A dot after a mounted route still reaches it.
|
||||
'/mcp.json',
|
||||
'/MCP.json',
|
||||
'/api/setup.json',
|
||||
'/api/realtime.token',
|
||||
'/api/OpenAI.x/hud-summary',
|
||||
'/@fs/etc/passwd',
|
||||
'/@vite/client',
|
||||
'/%40fs/x',
|
||||
|
||||
Reference in new issue
Block a user