security(chat): bind chat service to docker bridge + require Bearer auth
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s

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>
This commit is contained in:
2026-05-27 10:22:14 +00:00
parent 5ad541e54c
commit d3c6baf9e2
3 changed files with 130 additions and 27 deletions

View File

@@ -50,6 +50,12 @@ CHAT_SERVICE_URL = os.environ.get(
)
CHAT_SERVICE_TIMEOUT_S = float(os.environ.get("CHAT_SERVICE_TIMEOUT_S", "3600"))
# Shared secret for ``Authorization: Bearer ...`` to the chat service.
# The host-side service refuses any /chat/start without a matching token,
# so the network bind + the bearer check are two layers of defense
# against an attacker who reaches the docker bridge.
_CHAT_SHARED_SECRET = os.environ.get("LEGAL_CHAT_SHARED_SECRET", "").strip()
_SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
@@ -92,6 +98,17 @@ async def stream_chat_message(
"resume_session_id": conv.get("claude_session_id"),
}
# Surface a clean error if the secret wasn't injected — better than a
# silent 401 dribbling out of the proxy.
if not _CHAT_SHARED_SECRET:
raise HTTPException(
500,
"LEGAL_CHAT_SHARED_SECRET is not set in the container env. "
"Add it as a Coolify env var matching the value in "
"/home/chaim/.legal-chat-service.env on the host.",
)
headers = {"Authorization": f"Bearer {_CHAT_SHARED_SECRET}"}
async def proxy_stream() -> AsyncIterator[bytes]:
accumulated_text: list[str] = []
events_log: list[dict] = []
@@ -108,6 +125,7 @@ async def stream_chat_message(
"POST",
f"{CHAT_SERVICE_URL}/chat/start",
json=payload,
headers=headers,
) as upstream:
if upstream.status_code != 200:
body = await upstream.aread()