diff --git a/.env.example b/.env.example
index 071d42d3c..68fb3481a 100644
--- a/.env.example
+++ b/.env.example
@@ -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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07253f568..d39007b3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 13ff96215..85e66938d 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/SECURITY.md b/SECURITY.md
index e42e3113b..71e4ce922 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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 `
`/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 `