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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2026.06.0
2026.07.0
11 changes: 11 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` (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_"}


Expand Down
70 changes: 55 additions & 15 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Forail Assistant — FastAPI application."""

import asyncio
import hmac
import json
import logging

Expand All @@ -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 ---

Expand Down Expand Up @@ -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.

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

Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion app/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down
4 changes: 4 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$!

Expand Down
Loading