diff --git a/README.md b/README.md index 517aa2a..1bc086d 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ Rationale in [ADR 0006](docs/adr/0006-http-embedder-embed-queue-isolation.md). T Set `MH_API_TOKEN` to require `Authorization: Bearer ` on `/v1/memory/*` endpoints (`/v1/health` stays public). Set a different `MH_ADMIN_TOKEN` to require a separate token on `/v1/admin/*`; when it is unset, admin endpoints fall back to `MH_API_TOKEN` for backward compatibility. Leave both unset for local dev. Rationale in [ADR 0007](docs/adr/0007-minimal-token-auth.md) and [ADR 0009](docs/adr/0009-admin-gate.md). +**Production guard (fail-closed):** if you bind to a non-loopback host (e.g. `0.0.0.0` or a public IP) **without** `MH_API_TOKEN` set, memory-hall **refuses to start** — otherwise the write API would be exposed unauthenticated. Bind to `localhost`, set `MH_API_TOKEN`, or set `MH_ALLOW_INSECURE=1` to explicitly override. Local `localhost` dev without a token is unaffected. + --- ## What v0.2 is / isn't (honest expectations) diff --git a/src/memory_hall/cli/main.py b/src/memory_hall/cli/main.py index 969ff17..241fa2b 100644 --- a/src/memory_hall/cli/main.py +++ b/src/memory_hall/cli/main.py @@ -17,7 +17,7 @@ from memory_hall.config import Settings from memory_hall.models import encode_cursor -from memory_hall.server.app import create_app +from memory_hall.server.app import ProductionAuthError, create_app from memory_hall.storage.sqlite_store import SqliteStore app = typer.Typer(no_args_is_help=True, add_completion=False) @@ -90,7 +90,12 @@ def serve( settings.database_path = database_path if vector_database_path is not None: settings.vector_database_path = vector_database_path - uvicorn.run(create_app(settings=settings), host=settings.host, port=settings.port) + try: + server_app = create_app(settings=settings) + except ProductionAuthError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + uvicorn.run(server_app, host=settings.host, port=settings.port) @app.command() diff --git a/src/memory_hall/server/app.py b/src/memory_hall/server/app.py index 9aae626..071baa4 100644 --- a/src/memory_hall/server/app.py +++ b/src/memory_hall/server/app.py @@ -2,9 +2,11 @@ import asyncio import hmac +import ipaddress import json import logging import math +import os import random import re from contextlib import asynccontextmanager, suppress @@ -62,6 +64,31 @@ logger = logging.getLogger(__name__) +class ProductionAuthError(RuntimeError): + """Raised when HTTP serving would expose unauthenticated write APIs.""" + + +def _is_loopback_bind_host(host: str) -> bool: + normalized = host.strip().lower().removeprefix("[").removesuffix("]") + if normalized == "localhost": + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def enforce_production_auth_guard(settings: Settings) -> None: + if _is_loopback_bind_host(settings.host) or settings.api_token: + return + if os.environ.get("MH_ALLOW_INSECURE") == "1": + return + raise ProductionAuthError( + f"Refusing to start: binding to {settings.host} without MH_API_TOKEN. " + "Set MH_API_TOKEN, bind to localhost, or set MH_ALLOW_INSECURE=1 to override." + ) + + @dataclass(slots=True) class WriteJob: tenant_id: str @@ -922,8 +949,10 @@ def create_app( vector_store: VectorStore | None = None, embedder: Embedder | None = None, ) -> FastAPI: + active_settings = settings or Settings() + enforce_production_auth_guard(active_settings) runtime = build_runtime( - settings=settings, + settings=active_settings, storage=storage, vector_store=vector_store, embedder=embedder, diff --git a/tests/test_auth.py b/tests/test_auth.py index f78b95b..4122db5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -4,7 +4,7 @@ import pytest -from memory_hall.server.app import create_app +from memory_hall.server.app import ProductionAuthError, create_app from tests.conftest import DeterministicEmbedder, build_settings, client_for_app @@ -17,6 +17,58 @@ def _write_payload() -> dict[str, object]: } +def test_production_auth_guard_rejects_non_loopback_without_token( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MH_ALLOW_INSECURE", raising=False) + settings = build_settings(tmp_path) + settings.host = "0.0.0.0" # noqa: S104 - intentional unsafe bind test input. + settings.api_token = None + + with pytest.raises( + ProductionAuthError, + match=r"Refusing to start: binding to 0\.0\.0\.0 without MH_API_TOKEN", + ): + create_app(settings=settings, embedder=DeterministicEmbedder(dim=settings.vector_dim)) + + +def test_production_auth_guard_allows_non_loopback_with_token( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MH_ALLOW_INSECURE", raising=False) + settings = build_settings(tmp_path) + settings.host = "0.0.0.0" # noqa: S104 - intentional unsafe bind test input. + settings.api_token = "secret-token-abc" + + create_app(settings=settings, embedder=DeterministicEmbedder(dim=settings.vector_dim)) + + +def test_production_auth_guard_allows_explicit_insecure_override( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("MH_ALLOW_INSECURE", "1") + settings = build_settings(tmp_path) + settings.host = "0.0.0.0" # noqa: S104 - intentional unsafe bind test input. + settings.api_token = None + + create_app(settings=settings, embedder=DeterministicEmbedder(dim=settings.vector_dim)) + + +def test_production_auth_guard_allows_localhost_without_token( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MH_ALLOW_INSECURE", raising=False) + settings = build_settings(tmp_path) + settings.host = "localhost" + settings.api_token = None + + create_app(settings=settings, embedder=DeterministicEmbedder(dim=settings.vector_dim)) + + @pytest.mark.asyncio async def test_auth_disabled_allows_unauthenticated_write(tmp_path: Path) -> None: settings = build_settings(tmp_path)