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:
Sameh Khamis authored and GitHub committed 2026-10-02 20:26:01 -07:00
commit 992412fc9a
33 files changed
+1405 -131

No files matched your search

+30 -18
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+94
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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"
]
+12 -1
View File
@@ -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",
+2 -1
View File
@@ -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';
+41 -13
View File
@@ -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,
});
}
+43
View File
@@ -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);
};
}
+8 -3
View File
@@ -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 }),
),
);
}
+2 -1
View File
@@ -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;
+5 -5
View File
@@ -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);
+24 -11
View File
@@ -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 };
+3 -3
View File
@@ -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) {
+1 -1
View File
@@ -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';
+25 -6
View File
@@ -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();
+2
View File
@@ -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,
});
});
+281
View File
@@ -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
View File
@@ -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
+154
View File
@@ -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 };
}
+107
View File
@@ -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');
});
+153
View File
@@ -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);
});
+20 -2
View File
@@ -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',
+61
View File
@@ -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);
});
+93 -16
View File
@@ -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'));
});
+94
View File
@@ -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);
}
});
+7 -6
View File
@@ -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'];
+6
View File
@@ -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',