fix(server): accept same-origin requests from a non-loopback UI bind (#84)
When the operator binds the server to a non-loopback address (T3MP3ST_HOST=0.0.0.0 or a LAN IP) they've explicitly opted into network access — the same decision that already stands the anti-rebinding Host guard down. But the CSRF origin guard and the SSE guard were NOT updated to match: they still only trust loopback origins, so the operator's own LAN-served UI (e.g. http://192.168.x.x:3333/ui) had every state-changing request and the event stream rejected with "Cross-origin request rejected". That makes hunts fail on any target from a LAN-accessed War Room (issue #84). Fix: add isSameOriginAsHost() and, ONLY on a non-loopback bind, also trust a request whose Origin/Referer host:port equals this server's own Host header — i.e. a genuine same-origin request from the operator's own UI. A foreign origin (evil.com) still mismatches the Host and is rejected, so the CSRF/drive-by protection is preserved. On a loopback bind the branch is skipped entirely: the strict loopback-only gate plus the Host guard remain fully in force, so there is no new CSRF or DNS-rebinding surface for the default configuration. Verified against a 0.0.0.0-bound instance: - same-origin LAN request (Origin==Host, non-loopback) → passes the guard ✅ - foreign origin (evil.com) → still 403 ✅ - no Origin (curl/CLI) → allowed ✅ Typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae32cf5051
commit
7127fe8220
1 file changed
+25
-1
+25
-1
@@ -184,6 +184,25 @@ function isLoopbackOrigin(originHeader: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #84: a SAME-ORIGIN request — the caller's Origin (or Referer) host:port equals THIS server's
|
||||||
|
// own Host header — is by definition not a foreign-website drive-by. It is trusted ONLY when the
|
||||||
|
// operator has bound to a non-loopback address (see HOST_IS_LOOPBACK), i.e. they have explicitly
|
||||||
|
// opted into network access — the same decision that stands the anti-rebinding Host guard down
|
||||||
|
// below. That lets the operator's own LAN/custom-host UI (e.g. http://192.168.x.x:3333/ui) talk to
|
||||||
|
// its backend instead of being rejected as cross-origin, while a foreign origin (evil.com) still
|
||||||
|
// mismatches the Host and is refused. On a loopback bind this is never consulted: the strict
|
||||||
|
// loopback-only origin gate plus the Host guard remain fully in force, so no CSRF/rebinding gap.
|
||||||
|
// URL.host is "hostname:port" with the default port (80/443) omitted — exactly how browsers set
|
||||||
|
// BOTH the Origin and the Host header, so a genuine same-origin request compares equal.
|
||||||
|
function isSameOriginAsHost(source: string | undefined, hostHeader: string | undefined): boolean {
|
||||||
|
if (!source || !hostHeader) return false;
|
||||||
|
try {
|
||||||
|
return new URL(source).host.toLowerCase() === hostHeader.trim().toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CORS locked to the localhost UI origins ONLY. A same-origin fetch from the UI
|
// CORS locked to the localhost UI origins ONLY. A same-origin fetch from the UI
|
||||||
// (or a tool with no Origin like curl/CLI) is allowed; any other website's
|
// (or a tool with no Origin like curl/CLI) is allowed; any other website's
|
||||||
// Origin is rejected so the browser blocks it from reading our responses.
|
// Origin is rejected so the browser blocks it from reading our responses.
|
||||||
@@ -218,6 +237,8 @@ app.use((req: Request, res: Response, next: NextFunction) => {
|
|||||||
// No Origin AND no Referer → trusted local caller (curl/CLI/MCP). Allow.
|
// No Origin AND no Referer → trusted local caller (curl/CLI/MCP). Allow.
|
||||||
if (!source) return next();
|
if (!source) return next();
|
||||||
if (isLoopbackOrigin(source)) return next();
|
if (isLoopbackOrigin(source)) return next();
|
||||||
|
// #84: on a non-loopback (opted-in network) bind, also trust the operator's own same-origin UI.
|
||||||
|
if (!HOST_IS_LOOPBACK && isSameOriginAsHost(source, req.headers.host)) return next();
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'Cross-origin request rejected',
|
error: 'Cross-origin request rejected',
|
||||||
detail: 'This local API only accepts requests from the localhost UI, curl, or the CLI. A cross-origin (foreign-website) Origin/Referer was detected and blocked to prevent CSRF/drive-by command execution.',
|
detail: 'This local API only accepts requests from the localhost UI, curl, or the CLI. A cross-origin (foreign-website) Origin/Referer was detected and blocked to prevent CSRF/drive-by command execution.',
|
||||||
@@ -4639,7 +4660,10 @@ export function broadcastEvent(event: string, data: Record<string, unknown>): vo
|
|||||||
const MAX_SSE_CLIENTS = 64;
|
const MAX_SSE_CLIENTS = 64;
|
||||||
app.get('/api/events', (_req: Request, res: Response) => {
|
app.get('/api/events', (_req: Request, res: Response) => {
|
||||||
const origin = _req.get('origin');
|
const origin = _req.get('origin');
|
||||||
if (origin && !isLoopbackOrigin(origin)) {
|
// #84: allow the operator's own same-origin UI on a non-loopback (opted-in) bind; foreign
|
||||||
|
// origins still fail the loopback check AND the Host match, so they remain rejected.
|
||||||
|
const sameOriginNetworkBind = !HOST_IS_LOOPBACK && isSameOriginAsHost(origin, _req.headers.host);
|
||||||
|
if (origin && !isLoopbackOrigin(origin) && !sameOriginNetworkBind) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: 'Cross-origin event stream rejected',
|
error: 'Cross-origin event stream rejected',
|
||||||
detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.',
|
detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.',
|
||||||
|
|||||||
Reference in new issue
Block a user