diff --git a/management/ncp.py b/management/ncp.py index 6be5bc5..f27eed2 100644 --- a/management/ncp.py +++ b/management/ncp.py @@ -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)] diff --git a/synapse/main.py b/synapse/main.py index 9472a19..839e4d0 100644 --- a/synapse/main.py +++ b/synapse/main.py @@ -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=["*"], diff --git a/synapse/memory/service.py b/synapse/memory/service.py index d73da9d..6a45aa3 100644 --- a/synapse/memory/service.py +++ b/synapse/memory/service.py @@ -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=["*"], diff --git a/synapse/nexus_config.py b/synapse/nexus_config.py index fa32013..13c6cda 100644 --- a/synapse/nexus_config.py +++ b/synapse/nexus_config.py @@ -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__":