Artur Mukhamadiev 77768ba3ca credentials: offer the approved Confluence origin as a fixed choice
The origin check is an exact match including the context path, so users had
to type "https://collab.lge.com/main" precisely. GET /api/v1/config now
returns the approved origins in canonical form (non-secret: they are the only
destinations the backend will talk to), and the UI swaps the URL text field
for a select listing them, keeping the element id, focus handling and the
Test connection flow unchanged. The text field remains the fallback when the
fetch fails. Backend validation of the submitted URL is untouched. Mock server
serves the endpoint; contract, API and e2e tests cover it.
2026-09-15 15:24:17 +03:00

637 lines
24 KiB
Python

"""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.admission import AdmissionController
from backend.artifacts import ArtifactStore
from backend.confluence import ConfluenceClient
from backend.containers import (
ContainerManager,
DockerContainerManager,
FakeContainerManager,
)
from backend.errors import (
AppError,
ArtifactNotFoundError,
BusyError,
InvalidInputError,
OriginDeniedError,
ConnectivityFailedError,
sanitize_message,
RequestTooLargeError,
TicketNotFoundError,
)
from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter
from backend.runner import QueryRunner
from backend.settings import Settings, canonicalize_url, 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 'self'; media-src 'none'; font-src 'self'; object-src 'none'; "
"frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
)
FRONTEND_ASSET_DIRS = ("css", "js", "vendor", "assets")
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 QueueJoinRequest(BaseModel):
"""Body is `{}`. extra="forbid" rejects any field, including credentials/pat."""
model_config = ConfigDict(extra="forbid")
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, admission) -> 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()
admission.expire()
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")])
if not any(k.lower() == b"x-content-type-options" for k, _ in headers):
headers.append((b"x-content-type-options", b"nosniff"))
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,
admission_controller: Optional[AdmissionController] = 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,
timeout=app_settings.model_timeout_seconds,
)
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()
admission = admission_controller or AdmissionController(
reservation_seconds=app_settings.queue_reservation_seconds,
heartbeat_seconds=app_settings.queue_heartbeat_seconds,
max_queue_length=app_settings.queue_max_length,
)
@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, admission))
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.state.admission = admission
@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
client_kwargs: Dict[str, Any] = {}
if app_settings.confluence_proxy:
client_kwargs["proxy"] = app_settings.confluence_proxy
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,
**client_kwargs,
) 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.get("/api/v1/config")
async def public_config():
"""Non-secret deployment facts the UI needs before any credentials exist.
The approved Confluence origins are the destinations this backend will talk
to; the UI offers them as a fixed choice so users cannot mistype the origin or
its context path. Validation of the submitted URL is unchanged.
"""
origins: list[str] = []
for origin in app_settings.approved_confluence_origins:
try:
canonical = canonicalize_url(origin)
except Exception:
continue
if canonical not in origins:
origins.append(canonical)
return JSONResponse(content={"approved_origins": origins})
@app.post("/api/v1/queue/join")
async def queue_join(req: QueueJoinRequest, 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]
view = admission.join(session_id) # type: ignore[arg-type]
resp = JSONResponse(content=view)
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/queue/status")
async def queue_status(request: Request):
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_store.is_valid(session_id):
raise TicketNotFoundError("No active queue ticket for this session")
view = admission.status(session_id) # type: ignore[arg-type]
return JSONResponse(content=view)
@app.delete("/api/v1/queue/ticket")
async def queue_leave(request: Request):
check_same_origin(request)
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if session_store.is_valid(session_id):
admission.leave(session_id) # type: ignore[arg-type]
return Response(status_code=204)
@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]
# Admission: claim the reservation (or implicit-join while idle) before
# any work starts. The runner's own gate remains a last line of defence.
if not admission.claim(session_id):
raise BusyError("Another session holds the reservation")
run_started = admission.clock()
result: Optional[Dict[str, Any]] = None
cancelled = False
try:
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,
)
except asyncio.CancelledError:
cancelled = True
raise
finally:
# Run end (success, failure, timeout, cancellation, or disconnect):
# record duration and promote the next ticket after the runner's
# own cleanup budget above has already completed.
duration = result["duration_seconds"] if result is not None else (admission.clock() - run_started)
admission.run_end(session_id, duration_seconds=duration, cancelled=cancelled)
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") # identical to unknown-ID and no-session responses
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,
)
# Serve only the production frontend asset directories at the root origin.
# index.html references css/, js/, vendor/ and assets/ relatively; dev/, tests/ and
# documentation inside the frontend tree are never exposed.
if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists():
for asset_dir in FRONTEND_ASSET_DIRS:
directory = app_settings.frontend_dist_dir / asset_dir
if directory.is_dir():
app.mount(f"/{asset_dir}", StaticFiles(directory=str(directory)), name=f"frontend-{asset_dir}")
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)