feat(security): bind services to loopback with Host + CORS allowlists

The Synapse backend and memory service bound 0.0.0.0 with wildcard CORS and no
auth, exposing the full unauthenticated admin/data API to the LAN. Default the
uvicorn bind to 127.0.0.1 (NEXUS_BIND_HOST override), scope CORS to known local
origins instead of "*", and add TrustedHostMiddleware to reject foreign Host
headers (which defeats DNS-rebinding, something same-origin CORS cannot stop).

NEXUS_ALLOWED_HOSTS / NEXUS_ALLOWED_ORIGINS allow opt-in LAN exposure, intended
to be paired with real authentication.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 09:40:12 -05:00
co-authored by Cursor
parent e8379786d3
commit fe12eb1821
4 changed files with 54 additions and 9 deletions
+7 -1
View File
@@ -112,6 +112,12 @@ class Service:
return self._argv() if callable(self._argv) else self._argv
# Bind loopback by default: the backend/memory REST APIs are unauthenticated, so
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
def _uvicorn(app: str, port: int):
# No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor
# that spawns the real server as a CHILD, so every service became two
@@ -120,7 +126,7 @@ def _uvicorn(app: str, port: int):
# terminals for what should have been a silent start. launch_nexus.sh still
# passes --reload for the Linux dev loop, where a visible console is the
# point; this launcher is the one users run.
return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0",
return [str(PYTHON), "-m", "uvicorn", app, "--host", BIND_HOST,
"--port", str(port)]
+14 -6
View File
@@ -14,9 +14,10 @@ import platform as _platform
from pathlib import Path
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from .nexus_config import settings, VERSION, DEFAULT_CHAT_MODEL
from .nexus_config import settings, VERSION, DEFAULT_CHAT_MODEL, ALLOWED_HOSTS, ALLOWED_ORIGINS
from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from . import chat as _chat
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
@@ -179,13 +180,20 @@ app = FastAPI(title="Synapse Backend", version=VERSION)
# Alias for startup scripts
sio_app = app
# --- CORS ---
# allow_credentials=True is incompatible with allow_origins=["*"] — browsers
# reject such responses. Since this is a local-only service with no cookies/auth,
# wildcard origins without credentials is correct.
# --- Local-access guard ---
# These APIs are unauthenticated and meant for this machine only. Two layers:
# 1. TrustedHostMiddleware rejects a foreign Host header, which is what defeats
# DNS-rebinding — the browser treats a rebound attacker domain as
# same-origin, so CORS alone can't stop it.
# 2. CORS is scoped to known local origins (not "*"), so a malicious page can't
# read responses cross-origin from the victim's browser.
# NEXUS_ALLOWED_HOSTS=* / a custom NEXUS_ALLOWED_ORIGINS opts into LAN exposure,
# and should only be used once real authentication is in front of these apps.
if "*" not in ALLOWED_HOSTS:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=ALLOWED_ORIGINS,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
+8 -1
View File
@@ -19,17 +19,24 @@ from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
app = FastAPI(title="Nexus Memory Service", version="1.0")
# Same unauthenticated-local-only posture as the main backend: reject foreign
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
if "*" not in ALLOWED_HOSTS:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=ALLOWED_ORIGINS,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
+25 -1
View File
@@ -143,6 +143,29 @@ class Settings:
"ollama_timeout": self.ollama_timeout,
}
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
# The REST APIs are unauthenticated, so they are meant to be reached only from
# this machine. Two independent browser-side defenses depend on these lists:
# * ALLOWED_ORIGINS drives CORS — blocks a malicious page from *reading*
# responses cross-origin (was previously "*", which let any site read them).
# * ALLOWED_HOSTS drives TrustedHostMiddleware — rejects a foreign Host header,
# which is what stops DNS-rebinding (same-origin from the browser's view, so
# CORS can't help there).
# Both accept a comma-separated env override for the intentional-LAN case, to be
# paired with real auth. NEXUS_ALLOWED_HOSTS=* disables the Host check.
def _csv_env(name: str, default: list) -> list:
raw = os.getenv(name, "").strip()
return [p.strip() for p in raw.split(",") if p.strip()] if raw else list(default)
_LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [
f"http://{h}:{p}"
for h in ("localhost", "127.0.0.1")
for p in (8000, 8001, 5173)
]
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
# exported instance
settings = Settings()
@@ -152,7 +175,8 @@ __all__ = ["Settings", "settings", "path", "VERSION",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG"]
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
"ALLOWED_HOSTS", "ALLOWED_ORIGINS"]
# --- quick runtime sanity check when run directly (no side effects on import) ---
if __name__ == "__main__":