Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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)
Expand Down
9 changes: 7 additions & 2 deletions src/memory_hall/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
31 changes: 30 additions & 1 deletion src/memory_hall/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
Expand Down
Loading