Files
legal-ai/mcp-server/src/legal_mcp/chat_service/server.py
Chaim d3c6baf9e2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
security(chat): bind chat service to docker bridge + require Bearer auth
Address security-review finding: the host-side legal-chat-service was
binding 0.0.0.0:8770 with no authentication. The service spawns the
claude CLI, whose tool set includes Bash + Edit — so an unauthenticated
/chat/start is effectively RCE. Oracle Cloud's security list closes the
port externally, but defense-in-depth requires two independent layers:

1. Bind defaults to 10.0.1.1 (docker0 bridge gateway). Reachable from
   containers on docker bridges (the legal-ai container has a route via
   the coolify network), invisible to anything outside the host. The
   --host flag is still configurable for local-dev (127.0.0.1) or
   special-case deployments, but 0.0.0.0 is explicitly discouraged in
   the docstring.
2. /chat/start requires Authorization: Bearer <LEGAL_CHAT_SHARED_SECRET>.
   The secret is loaded from /home/chaim/.legal-chat-service.env (chmod
   600, off-repo) by the pm2 ecosystem and mirrored as a Coolify env
   var so the FastAPI chat_proxy sends a matching header. hmac.compare_digest
   prevents timing oracles. /health stays unauthenticated (static OK,
   no subprocess) so the FastAPI proxy can probe liveness without the
   secret.

The service refuses to start if LEGAL_CHAT_SHARED_SECRET is empty or
shorter than 24 chars — no silent fallback to an open mode.

When the Infisical MCP comes back, migrate the secret into the vault
at /_GUIDELINES per the project secrets policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:22:14 +00:00

211 lines
7.5 KiB
Python

"""HTTP+SSE bridge from FastAPI (in container) to local claude CLI.
Endpoints:
POST /chat/start — body: {prompt, system?, resume_session_id?}
returns SSE stream of events from
``claude_session.query_streaming``.
REQUIRES Authorization: Bearer <secret>.
GET /health — liveness probe (no auth — used by FastAPI for status).
Run with pm2:
pm2 start scripts/legal-chat-service.config.cjs
Standalone for dev:
cd ~/legal-ai/mcp-server
LEGAL_CHAT_SHARED_SECRET=... .venv/bin/python -m legal_mcp.chat_service.server \
--port 8770 --host 10.0.1.1
Security posture
----------------
1. Bind defaults to ``10.0.1.1`` — the host's docker0 bridge gateway.
Containers on docker bridges (including the legal-ai container, which
sits on the ``coolify`` network but routes to docker0 at the host)
can reach this address; processes outside the host cannot. Binding to
``0.0.0.0`` is permitted but discouraged (relies on the cloud-level
firewall as the sole perimeter).
2. ``/chat/start`` requires a ``Authorization: Bearer <LEGAL_CHAT_SHARED_SECRET>``
header. The secret is loaded from the environment; without it set,
the server refuses to start (no fallback to "open" mode, by design —
the claude CLI it spawns can run arbitrary tool calls, so an
unauthenticated /chat/start is RCE-equivalent).
3. ``/health`` is intentionally unauthenticated so the FastAPI proxy
can probe liveness with no token. It returns only a static OK and
never spawns subprocesses, so it can't be abused.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from typing import Any
from aiohttp import web
# Run-via-CLI bootstrap so ``python -m legal_mcp.chat_service.server``
# works even when the package isn't installed (it is in the venv, but
# this safeguard keeps the entrypoint robust).
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
if _pkg_root not in sys.path:
sys.path.insert(0, _pkg_root)
from legal_mcp.services import claude_session # noqa: E402
logger = logging.getLogger("legal_chat_service")
# Loaded once at startup. Validated to be non-empty in main(); the handler
# uses a constant-time compare to avoid timing oracles on a short input.
_SHARED_SECRET: str = ""
async def health(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "service": "legal-chat-service"})
def _check_bearer(request: web.Request) -> web.Response | None:
"""Validate ``Authorization: Bearer <secret>``. Returns 401 response on failure."""
auth = request.headers.get("Authorization", "")
expected = "Bearer " + _SHARED_SECRET
# ``compare_digest`` defends against timing attacks. Strings of different
# length still leak length, but for a 43-char urlsafe token that's
# uninteresting and the auth scheme prefix anchors it anyway.
import hmac
if not auth or not hmac.compare_digest(auth, expected):
return web.json_response(
{"error": "unauthorized: missing or invalid Bearer token"},
status=401,
)
return None
async def chat_start(request: web.Request) -> web.StreamResponse:
"""Drive ``claude_session.query_streaming`` and forward events as SSE.
Request body (JSON):
prompt: str — required, user message
system: str | None — system instructions (ignored if resuming)
resume_session_id: str | None — continue a prior CLI session
timeout: int = 3600 — hard timeout for the subprocess
"""
unauth = _check_bearer(request)
if unauth is not None:
return unauth
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "invalid JSON body"}, status=400)
prompt = body.get("prompt") or ""
if not prompt.strip():
return web.json_response({"error": "prompt is required"}, status=400)
system = body.get("system")
resume_session_id = body.get("resume_session_id")
timeout = int(body.get("timeout") or 3600)
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# X-Accel-Buffering=no defeats nginx/traefik buffering — the
# FastAPI container proxies via httpx and forwards bytes as
# they arrive, but the inner header is harmless and makes
# browser-direct testing easier.
"X-Accel-Buffering": "no",
},
)
await response.prepare(request)
async def send_event(payload: dict[str, Any]) -> None:
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await response.write(line.encode("utf-8"))
try:
async for event in claude_session.query_streaming(
prompt,
system=system,
resume_session_id=resume_session_id,
timeout=timeout,
):
await send_event(event)
if event.get("type") == "done" or event.get("type") == "error":
break
except asyncio.CancelledError:
# Client disconnected — bail cleanly.
logger.info("chat_start: client disconnected")
except Exception as e:
logger.exception("chat_start: streaming failed")
try:
await send_event({"type": "error", "message": str(e)})
except ConnectionResetError:
pass
try:
await response.write_eof()
except ConnectionResetError:
pass
return response
def build_app() -> web.Application:
app = web.Application()
app.router.add_get("/health", health)
app.router.add_post("/chat/start", chat_start)
return app
def main() -> int:
parser = argparse.ArgumentParser(description="legal-chat-service")
parser.add_argument("--port", type=int, default=8770)
parser.add_argument(
"--host", default="10.0.1.1",
help=(
"bind address. Default 10.0.1.1 = docker0 bridge gateway — "
"reachable from containers, invisible to non-host networks. "
"Use 127.0.0.1 for host-local dev; do not bind 0.0.0.0 "
"without a separate perimeter firewall."
),
)
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
logging.basicConfig(
level=args.log_level.upper(),
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
secret = os.environ.get("LEGAL_CHAT_SHARED_SECRET", "").strip()
if not secret:
logger.error(
"LEGAL_CHAT_SHARED_SECRET is empty; refusing to start. "
"Set it in /home/chaim/.legal-chat-service.env (loaded by "
"pm2) and mirror it as a Coolify env var on the legal-ai app."
)
return 2
if len(secret) < 24:
logger.error(
"LEGAL_CHAT_SHARED_SECRET is too short (got %d chars); "
"refusing to start. Use >=32 chars (e.g. python3 -c "
"'import secrets; print(secrets.token_urlsafe(32))').",
len(secret),
)
return 2
global _SHARED_SECRET
_SHARED_SECRET = secret
app = build_app()
logger.info("legal-chat-service listening on %s:%d", args.host, args.port)
web.run_app(app, host=args.host, port=args.port, print=lambda _msg: None)
return 0
if __name__ == "__main__":
sys.exit(main())