security(chat): bind chat service to docker bridge + require Bearer auth
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user