"""FastAPI application factory, endpoints, session handling, and security headers.""" from __future__ import annotations import asyncio import contextlib import logging import secrets import time import urllib.parse from typing import Any, Callable, Dict, Optional, Tuple from fastapi import FastAPI, HTTPException, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict from starlette.exceptions import HTTPException as StarletteHTTPException from backend.artifacts import ArtifactStore from backend.confluence import ConfluenceClient from backend.containers import ( ContainerManager, DockerContainerManager, FakeContainerManager, ) from backend.errors import ( AppError, ArtifactNotFoundError, InvalidInputError, OriginDeniedError, ConnectivityFailedError, sanitize_message, RequestTooLargeError, ) from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter from backend.runner import QueryRunner from backend.settings import Settings, validate_confluence_url logger = logging.getLogger(__name__) SESSION_COOKIE_NAME = "cw_session" CSP_POLICY = ( "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; " "img-src 'none'; media-src 'none'; font-src 'self'; object-src 'none'; " "frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" ) MAX_VERIFY_BODY_BYTES = 128 * 1024 MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 1024 class AuthVerifyRequest(BaseModel): model_config = ConfigDict(extra="forbid") url: str pat: str class QueryCredentials(BaseModel): model_config = ConfigDict(extra="forbid") url: str pat: str class QueryRequest(BaseModel): model_config = ConfigDict(extra="forbid") prompt: str credentials: QueryCredentials class ServerSessionStore: """In-memory store of server-issued session IDs to prevent client-forged sessions.""" def __init__(self) -> None: self._sessions: Dict[str, float] = {} def is_valid(self, session_id: Optional[str]) -> bool: if not session_id or not isinstance(session_id, str): return False if len(session_id) != 64: # secrets.token_hex(32) is 64 hex characters return False return session_id in self._sessions def create_session(self) -> str: session_id = secrets.token_hex(32) self._sessions[session_id] = time.time() return session_id def touch(self, session_id: str) -> None: if session_id in self._sessions: self._sessions[session_id] = time.time() def prune_stale(self, max_age_seconds: float = 86400.0) -> None: now = time.time() stale = [s for s, ts in self._sessions.items() if now - ts > max_age_seconds] for s in stale: self._sessions.pop(s, None) def get_or_set_session_cookie( request: Request, response: Response, session_store: ServerSessionStore ) -> str: """Read server-issued cw_session cookie or issue a new cryptographically random session ID.""" session_id = request.cookies.get(SESSION_COOKIE_NAME) if session_store.is_valid(session_id): session_store.touch(session_id) # type: ignore[arg-type] else: session_id = session_store.create_session() secure = request.url.scheme == "https" response.set_cookie( key=SESSION_COOKIE_NAME, value=session_id, httponly=True, samesite="strict", path="/", secure=secure, ) return session_id def _normalize_endpoint(scheme: str, hostname: Optional[str], port: Optional[int]) -> Tuple[str, str, int]: s = scheme.lower() h = (hostname or "").lower() if port is not None: p = port else: p = 443 if s == "https" else (80 if s == "http" else 0) return (s, h, p) def check_same_origin(request: Request) -> None: """Require matching Origin for state-changing POST requests.""" origin = request.headers.get("origin") if not origin: raise OriginDeniedError("Missing Origin header") try: parsed_origin = urllib.parse.urlsplit(origin) except ValueError: raise OriginDeniedError("Malformed Origin header") if parsed_origin.scheme not in ("http", "https") or not parsed_origin.netloc or parsed_origin.username is not None or parsed_origin.password is not None or parsed_origin.path or parsed_origin.query or parsed_origin.fragment: raise OriginDeniedError("Malformed Origin header") try: origin_norm = _normalize_endpoint( parsed_origin.scheme, parsed_origin.hostname, parsed_origin.port, ) req_norm = _normalize_endpoint( request.url.scheme, request.url.hostname, request.url.port, ) except ValueError: raise OriginDeniedError("Invalid origin or destination") if origin_norm != req_norm: raise OriginDeniedError("Origin does not match request destination") async def _periodic_maintenance(runner, store, session_store) -> None: while True: await asyncio.sleep(60) if not runner._gate.locked(): try: await runner.reconcile() except Exception: logger.warning("Container reconciliation failed; query admission remains closed") try: await asyncio.to_thread(store.expire_artifacts) session_store.prune_stale() except Exception: logger.warning("Artifact or session maintenance failed") class SecurityMiddleware: """ASGI middleware bounds incoming bytes without stealing disconnect events.""" def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) request = Request(scope) limit = MAX_VERIFY_BODY_BYTES if scope["path"] == "/api/v1/auth/verify" else MAX_QUERY_BODY_BYTES total = 0 exceeded = False async def bounded_receive(): nonlocal total, exceeded message = await receive() if message["type"] == "http.request": total += len(message.get("body", b"")) if total > limit: exceeded = True raise RequestTooLargeError("Request body exceeds byte limit") return message async def secure_send(message): if message["type"] == "http.response.start": headers = list(message.get("headers", [])) if scope["path"].startswith("/api/v1/"): headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"] headers.append((b"cache-control", b"no-store")) else: headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")]) message = {**message, "headers": headers} await send(message) if scope["method"] == "POST": header = request.headers.get("content-length") if header is not None: try: length = int(header) if length < 0: raise ValueError except ValueError: return await JSONResponse(status_code=400, content=InvalidInputError("Invalid Content-Length").to_envelope())(scope, receive, secure_send) if length > limit: return await JSONResponse(status_code=413, content=RequestTooLargeError().to_envelope())(scope, receive, secure_send) # Bound before framework parsing: Starlette otherwise translates read exceptions to HTTP 400. chunks = bytearray() while True: try: message = await bounded_receive() except RequestTooLargeError as exc: return await JSONResponse(status_code=413, content=exc.to_envelope())(scope, receive, secure_send) if message["type"] == "http.disconnect": return chunks.extend(message.get("body", b"")) if not message.get("more_body", False): break delivered = False async def replay_receive(): nonlocal delivered if not delivered: delivered = True return {"type": "http.request", "body": bytes(chunks), "more_body": False} return await receive() return await self.app(scope, replay_receive, secure_send) return await self.app(scope, receive, secure_send) class OwnedFileResponse(FileResponse): def __init__(self, *args, store, artifact_id, **kwargs): super().__init__(*args, **kwargs) self.store, self.artifact_id = store, artifact_id async def __call__(self, scope, receive, send): try: return await super().__call__(scope, receive, send) finally: await asyncio.to_thread(self.store.release_reader, self.artifact_id) def create_app( settings: Optional[Settings] = None, container_manager: Optional[ContainerManager] = None, artifact_store: Optional[ArtifactStore] = None, model_adapter: Optional[ModelAdapter] = None, confluence_client_factory: Optional[Callable[..., ConfluenceClient]] = None, ) -> FastAPI: """Create and configure FastAPI application with dependency injection.""" app_settings = settings or Settings.from_env() store = artifact_store or ArtifactStore(app_settings.artifact_storage_dir) # Container manager selection if container_manager is not None: mgr = container_manager elif app_settings.dev_mode: mgr = FakeContainerManager() else: mgr = DockerContainerManager(app_settings) # Model adapter selection if model_adapter is not None: adapter = model_adapter elif app_settings.dev_mode or app_settings.model_provider == "fake": adapter = FakeModelAdapter() elif app_settings.model_provider == "openai": if not app_settings.model_api_key or not app_settings.model_api_key.strip(): raise RuntimeError("Missing model_api_key for OpenAI provider in production mode") adapter = OpenAIModelAdapter( api_key=app_settings.model_api_key, model_name=app_settings.model_name, endpoint=app_settings.model_endpoint, context_window_tokens=app_settings.model_context_window_tokens, max_output_tokens=app_settings.model_max_output_tokens, ) else: raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode") if confluence_client_factory is None and app_settings.dev_mode: from backend.dev.fake_confluence import create_client confluence_client_factory = create_client runner = QueryRunner( settings=app_settings, container_manager=mgr, artifact_store=store, model_adapter=adapter, confluence_client_factory=confluence_client_factory, ) session_store = ServerSessionStore() @contextlib.asynccontextmanager async def lifespan(fastapi_app: FastAPI): maintenance_task = None try: await asyncio.to_thread(store.purge_all) if isinstance(mgr, DockerContainerManager): await mgr.verify_rootless() await runner.reconcile() maintenance_task = asyncio.create_task(_periodic_maintenance(runner, store, session_store)) yield finally: if maintenance_task is not None: maintenance_task.cancel() with contextlib.suppress(asyncio.CancelledError): await maintenance_task await adapter.close() app = FastAPI(title="Confluence Web Backend", docs_url=None, redoc_url=None, lifespan=lifespan) # Bounded concurrency semaphore for auth verify verify_semaphore = asyncio.Semaphore(10) # Error envelope exception handlers @app.exception_handler(AppError) async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content=exc.to_envelope(), headers={"Cache-Control": "no-store"}, ) @app.exception_handler(RequestValidationError) async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse: # Sanitize framework errors: NEVER echo back request body or PAT return JSONResponse( status_code=400, content={"error": {"code": "invalid_input", "message": "Invalid request payload"}}, headers={"Cache-Control": "no-store"}, ) @app.exception_handler(StarletteHTTPException) @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: Any) -> JSONResponse: code_map = { 400: "invalid_input", 403: "origin_denied", 404: "artifact_not_found", 405: "invalid_input", 409: "busy", 413: "request_too_large", 502: "upstream_failed", 504: "query_timeout", 500: "execution_failed", } if exc.status_code == 404: if request.url.path.startswith("/api/v1/artifacts/"): code = "artifact_not_found" else: code = "invalid_input" elif exc.status_code == 405: code = "invalid_input" else: code = code_map.get(exc.status_code, "execution_failed") msg = str(exc.detail) if exc.detail else "HTTP error" return JSONResponse( status_code=exc.status_code, content={"error": {"code": code, "message": sanitize_message(msg)}}, headers={"Cache-Control": "no-store"}, ) @app.exception_handler(Exception) async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse: return JSONResponse( status_code=500, content={"error": {"code": "execution_failed", "message": "Internal execution failure"}}, headers={"Cache-Control": "no-store"}, ) app.add_middleware(SecurityMiddleware) app.state.runner = runner app.state.artifact_store = store @app.get("/") async def root_index(request: Request): if app_settings.frontend_dist_dir and (app_settings.frontend_dist_dir / "index.html").exists(): index_path = app_settings.frontend_dist_dir / "index.html" html_content = await asyncio.to_thread(index_path.read_text, encoding="utf-8") resp = HTMLResponse(content=html_content, status_code=200) else: resp = JSONResponse(content={"status": "ok", "service": "confluence-web-backend"}) get_or_set_session_cookie(request, resp, session_store) return resp @app.post("/api/v1/auth/verify") async def verify_auth(req: AuthVerifyRequest, request: Request): check_same_origin(request) if len(req.pat.encode("utf-8")) > 8192 or not req.pat.strip(): raise InvalidInputError("Invalid Confluence PAT") try: async with asyncio.timeout(15), verify_semaphore: # Validate Confluence URL against approved origins canonical_url = validate_confluence_url(req.url, app_settings.approved_confluence_origins) # Request-scoped verification client client_factory = confluence_client_factory or ConfluenceClient async with client_factory( base_url=canonical_url, pat=req.pat, approved_origins=app_settings.approved_confluence_origins, corporate_ca_path=app_settings.corporate_ca_path, timeout=15.0, ) as client: await client.verify_auth() except asyncio.TimeoutError as exc: raise ConnectivityFailedError("Confluence verification timed out") from exc resp = JSONResponse(content={"valid": True}) get_or_set_session_cookie(request, resp, session_store) return resp @app.post("/api/v1/query") async def execute_query(req: QueryRequest, request: Request): check_same_origin(request) session_id = request.cookies.get(SESSION_COOKIE_NAME) if not session_store.is_valid(session_id): session_id = session_store.create_session() else: session_store.touch(session_id) # type: ignore[arg-type] # Execute query via runner result = await runner.run( prompt=req.prompt, confluence_url=req.credentials.url, confluence_pat=req.credentials.pat, session_id=session_id, is_disconnected=request.is_disconnected, ) resp = JSONResponse(content=result) # Ensure session cookie is set secure = request.url.scheme == "https" resp.set_cookie( key=SESSION_COOKIE_NAME, value=session_id, httponly=True, samesite="strict", path="/", secure=secure, ) return resp @app.get("/api/v1/artifacts/{artifact_id}") async def download_artifact(artifact_id: str, request: Request): session_id = request.cookies.get(SESSION_COOKIE_NAME) if not session_store.is_valid(session_id): raise ArtifactNotFoundError("Artifact not found") lookup_task = asyncio.create_task(asyncio.to_thread(store.get_artifact_for_download, artifact_id, session_id=session_id)) cancelled = False while not lookup_task.done(): try: await asyncio.shield(lookup_task) except asyncio.CancelledError: cancelled = True res = lookup_task.result() if cancelled: if res: await asyncio.to_thread(store.release_reader, artifact_id) raise asyncio.CancelledError if not res: raise ArtifactNotFoundError("Artifact not found or expired") file_path, display_name, size_bytes = res # RFC 6266 filename encoding ascii_fallback = "".join(c if 0x20 <= ord(c) < 0x7F and c not in '"\\;' else '_' for c in display_name) if not ascii_fallback.strip("_ "): ascii_fallback = "artifact.bin" encoded_name = urllib.parse.quote(display_name, encoding="utf-8") content_disposition = f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded_name}' headers = { "Content-Disposition": content_disposition, "Content-Type": "application/octet-stream", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", } return OwnedFileResponse( store=store, artifact_id=artifact_id, path=str(file_path), headers=headers, ) # Mount static frontend assets if configured if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists(): app.mount("/static", StaticFiles(directory=str(app_settings.frontend_dist_dir)), name="static") return app _default_app: Optional[FastAPI] = None def get_app() -> FastAPI: """Lazy default application instance avoiding module-import side-effects.""" global _default_app if _default_app is None: _default_app = create_app() return _default_app async def app(scope: Any, receive: Any, send: Any) -> None: """ASGI 3.0 entrypoint for uvicorn backend.app:app.""" application = get_app() await application(scope, receive, send) if __name__ == "__main__": import uvicorn app_settings = Settings.from_env() uvicorn.run("backend.app:app", host=app_settings.bind_host, port=app_settings.bind_port)