forked from enderofwings/NexusOS
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:
+7
-1
@@ -112,6 +112,12 @@ class Service:
|
|||||||
return self._argv() if callable(self._argv) else self._argv
|
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):
|
def _uvicorn(app: str, port: int):
|
||||||
# No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor
|
# 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
|
# 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
|
# 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
|
# passes --reload for the Linux dev loop, where a visible console is the
|
||||||
# point; this launcher is the one users run.
|
# 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)]
|
"--port", str(port)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -14,9 +14,10 @@ import platform as _platform
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, Body
|
from fastapi import FastAPI, HTTPException, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from fastapi.responses import StreamingResponse, FileResponse
|
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 .chat import generate_chat_response, stream_chat_response, _synapse_trace
|
||||||
from . import chat as _chat
|
from . import chat as _chat
|
||||||
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
|
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
|
# Alias for startup scripts
|
||||||
sio_app = app
|
sio_app = app
|
||||||
|
|
||||||
# --- CORS ---
|
# --- Local-access guard ---
|
||||||
# allow_credentials=True is incompatible with allow_origins=["*"] — browsers
|
# These APIs are unauthenticated and meant for this machine only. Two layers:
|
||||||
# reject such responses. Since this is a local-only service with no cookies/auth,
|
# 1. TrustedHostMiddleware rejects a foreign Host header, which is what defeats
|
||||||
# wildcard origins without credentials is correct.
|
# 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=ALLOWED_ORIGINS,
|
||||||
allow_credentials=False,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
@@ -19,17 +19,24 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Body
|
from fastapi import FastAPI, HTTPException, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .store import store, MemoryItem
|
from .store import store, MemoryItem
|
||||||
from .extractor import extract_memory
|
from .extractor import extract_memory
|
||||||
from ..ollama_manager import get_ollama_manager
|
from ..ollama_manager import get_ollama_manager
|
||||||
|
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
|
||||||
|
|
||||||
app = FastAPI(title="Nexus Memory Service", version="1.0")
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=ALLOWED_ORIGINS,
|
||||||
allow_credentials=False,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
+25
-1
@@ -143,6 +143,29 @@ class Settings:
|
|||||||
"ollama_timeout": self.ollama_timeout,
|
"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
|
# exported instance
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
@@ -152,7 +175,8 @@ __all__ = ["Settings", "settings", "path", "VERSION",
|
|||||||
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||||
"EXPORTS_DIR", "MEMORY_DB",
|
"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) ---
|
# --- quick runtime sanity check when run directly (no side effects on import) ---
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user