From b50a56e2e1a649fea38f92a9e01258f665f67c34 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Wed, 24 Jun 2026 17:30:40 +0200 Subject: [PATCH 1/3] release: bump VERSION to 2026.07.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a4f1abb..c6e784c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.06.0 +2026.07.0 From b42a977786fddf7b203686c9ff78e380fa8d6dcb Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Mon, 13 Jul 2026 10:25:00 +0200 Subject: [PATCH 2/3] assistant: authn, CORS and input hardening - CORS no longer combines a wildcard origin with credentials (M13). - /api/v1/chat honours an optional shared bearer token and caps concurrent generations (429 on overload) to prevent GPU/CPU exhaustion (M14). - Constant-time token comparisons (L10); chat errors no longer leak internal exception text (L12); history role/content is validated so a caller can't inject a system message or crash on malformed entries (L11). - Document that Chroma/Ollama 0.0.0.0 binds are container-confined (L13). --- app/config.py | 11 ++++++++ app/main.py | 70 ++++++++++++++++++++++++++++++++++++++++----------- app/rag.py | 12 ++++++++- entrypoint.sh | 4 +++ 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/app/config.py b/app/config.py index 9cb1be7..c4a6992 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,17 @@ class Settings(BaseSettings): # endpoint is disabled (fail closed). Startup auto-indexing is unaffected. admin_token: str = "" + # Shared bearer token required to call the chat endpoint (needtofix M14). + # When empty the endpoint is open (backwards compatible) but a warning is + # logged at startup; set it to require callers to send `Authorization: + # Bearer ` (the gateway/frontend injects it). + chat_token: str = "" + + # Max concurrent chat generations (needtofix M14). Each request drives an + # LLM generation up to ollama_timeout seconds; without a cap a flood + # exhausts GPU/CPU. Excess requests get 429. + chat_max_concurrency: int = 4 + model_config = {"env_prefix": "FORAIL_ASSISTANT_"} diff --git a/app/main.py b/app/main.py index 399d1de..abacf92 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ """Forail Assistant — FastAPI application.""" +import asyncio +import hmac import json import logging @@ -24,14 +26,43 @@ openapi_url="/api/v1/openapi.json", ) +# needtofix M13: never combine a wildcard origin with credentials — Starlette +# would reflect any Origin and return Access-Control-Allow-Credentials: true, +# letting any site make credentialed cross-origin requests. Only allow +# credentials when the origins are an explicit allow-list. +_cors_origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] +_wildcard_cors = "*" in _cors_origins app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins.split(","), - allow_credentials=True, + allow_origins=_cors_origins, + allow_credentials=not _wildcard_cors, allow_methods=["*"], allow_headers=["*"], ) +# needtofix M14: bound concurrent LLM generations to avoid GPU/CPU exhaustion. +_chat_semaphore = asyncio.Semaphore(max(1, settings.chat_max_concurrency)) + + +@app.on_event("startup") +async def _warn_open_chat(): + if not settings.chat_token: + logger.warning( + "chat endpoint is UNAUTHENTICATED — set FORAIL_ASSISTANT_CHAT_TOKEN " + "to require a bearer token (needtofix M14)." + ) + if _wildcard_cors: + logger.warning("CORS is a wildcard; credentials are disabled (needtofix M13).") + + +def _require_chat_auth(authorization: str | None): + """Enforce the shared chat bearer token when configured (constant-time).""" + if not settings.chat_token: + return # open mode (logged at startup) + expected = f"Bearer {settings.chat_token}" + if not authorization or not hmac.compare_digest(authorization, expected): + raise HTTPException(status_code=401, detail="Invalid or missing bearer token") + # --- Models --- @@ -67,7 +98,7 @@ async def health(): @app.post("/api/v1/chat") -async def chat(req: ChatRequest): +async def chat(req: ChatRequest, authorization: str | None = Header(default=None)): """ Chat endpoint with SSE streaming response. @@ -92,22 +123,30 @@ async def chat(req: ChatRequest): data: {"done": true} ``` """ + _require_chat_auth(authorization) + + # Reject a flood before starting an expensive generation (M14). + if _chat_semaphore.locked(): + raise HTTPException(status_code=429, detail="Assistant busy, retry shortly") + page_context = "" if req.context and req.context.get("page"): page_context = req.context["page"] async def event_generator(): - try: - async for token in stream_chat( - message=req.message, - page_context=page_context, - history=req.history, - ): - yield {"data": json.dumps({"token": token})} - yield {"data": json.dumps({"done": True})} - except Exception as e: - logger.exception("Error during chat streaming") - yield {"data": json.dumps({"error": str(e), "done": True})} + async with _chat_semaphore: + try: + async for token in stream_chat( + message=req.message, + page_context=page_context, + history=req.history, + ): + yield {"data": json.dumps({"token": token})} + yield {"data": json.dumps({"done": True})} + except Exception: + # needtofix L12: never stream internal exception text to clients. + logger.exception("Error during chat streaming") + yield {"data": json.dumps({"error": "internal error", "done": True})} return EventSourceResponse(event_generator()) @@ -128,7 +167,8 @@ async def trigger_index( status_code=503, detail="Indexing endpoint disabled: set FORAIL_ASSISTANT_ADMIN_TOKEN", ) - if x_admin_token != settings.admin_token: + # needtofix L10: constant-time compare to avoid a timing side-channel. + if not x_admin_token or not hmac.compare_digest(x_admin_token, settings.admin_token): raise HTTPException(status_code=401, detail="Invalid or missing X-Admin-Token") from app.indexer import index_documents diff --git a/app/rag.py b/app/rag.py index 4c172c3..74c6b18 100644 --- a/app/rag.py +++ b/app/rag.py @@ -71,7 +71,17 @@ async def stream_chat( if history: for entry in history[-6:]: # Last 3 exchanges - messages.append({"role": entry["role"], "content": entry["content"]}) + # needtofix L11: the role is client-controlled. Only accept + # user/assistant turns — a caller must not be able to inject a + # 'system' message (prompt injection) or crash us with a malformed + # entry missing role/content. + if not isinstance(entry, dict): + continue + role = entry.get("role") + content = entry.get("content") + if role not in ("user", "assistant") or not isinstance(content, str): + continue + messages.append({"role": role, "content": content}) messages.append({"role": "user", "content": message}) diff --git a/entrypoint.sh b/entrypoint.sh index 4fa03c9..7910430 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -24,6 +24,10 @@ if ! ollama list 2>/dev/null | grep -q "nomic-embed-text"; then fi echo "==> Starting ChromaDB..." +# needtofix L13: ChromaDB (and Ollama) bind 0.0.0.0 with no auth. This is safe +# only because they are confined to this pod/container and not exposed by a +# Service/port. Do NOT publish these ports; if a shared instance is ever +# needed, put an authenticating proxy in front. chroma run --host 0.0.0.0 --port 8000 --path /data/chroma > /dev/null 2>&1 & CHROMA_PID=$! From 2e6861d0009a393a24ed9845ab11ad183be55245 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Tue, 14 Jul 2026 11:15:00 +0200 Subject: [PATCH 3/3] docs: changelog for assistant authn/CORS hardening --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b12eb8b..bbaddc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to the Forail Assistant will be documented in this file. ## [Unreleased] +### Security +- **CORS** no longer combines a wildcard origin with credentials (a wildcard now + disables `allow_credentials`). +- **`/api/v1/chat` hardening**: honours an optional shared bearer token + (`FORAIL_ASSISTANT_CHAT_TOKEN`) and caps concurrent generations + (`FORAIL_ASSISTANT_CHAT_MAX_CONCURRENCY`, 429 on overload) to prevent GPU/CPU + exhaustion. +- Constant-time token comparisons; chat errors no longer leak internal exception + text; chat history `role`/`content` is validated (no system-prompt injection or + crashes on malformed entries). + ## [2026.06.0] - 2026-06-14 ### Changed