mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(server): apply the Host check before the app's own routes
Vite installs its Host check after the middleware plugins add in configureServer, so the provider and /api routes answered any Host, including a DNS-rebinding name. hostCheckPlugin runs first on the dev and preview servers and applies Vite's rule with the same allowed hosts: IP addresses, localhost and *.localhost, the configured server host, and the listed names. A test drives a real Vite server with an unlisted Host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
81513f9aae
commit
e6f1dd70a1
6 files changed
+203
-5
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
## [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/realtime/debug-log`): a foreign or opaque
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ 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`) 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`). Vite's host check (`allowedHosts`) 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.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). 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:`: no inline script and no script from any other origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* 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']);
|
||||
|
||||
/**
|
||||
@@ -24,3 +27,68 @@ export function resolveAllowedHosts(configuredHosts) {
|
||||
.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));
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS } from './allowedHosts.js';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
@@ -53,6 +53,8 @@ export function createBrowserViteConfig({
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
...plugins,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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';
|
||||
|
||||
@@ -32,3 +36,118 @@ test('rejects suffix and wildcard entries so every LAN hostname is explicit', ()
|
||||
'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);
|
||||
});
|
||||
@@ -12,7 +12,10 @@ 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']);
|
||||
@@ -65,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 {
|
||||
@@ -79,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');
|
||||
|
||||
Reference in new issue
Block a user