Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions .cspell/general-technical.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ redhat
redirections
redisenterprise
redtiger
redoc
reengineer
reengineered
reengineering
Expand Down Expand Up @@ -1216,6 +1217,7 @@ slas
sles
sloc
slos
slowapi
slsa
smartctl
smes
Expand Down
12 changes: 12 additions & 0 deletions data-management/viewer/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ HMI_DATA_PATH=../../../datasets
# Port for the backend API server.
# BACKEND_PORT=8000

# ─────────────────────────────────────────────────────────────────────────────
# Security middleware
# ─────────────────────────────────────────────────────────────────────────────

# Maximum request body size in bytes.
# Requests exceeding this limit receive HTTP 413. Default: 10 MB.
# MAX_REQUEST_BODY_BYTES=10485760

# Rate limits for detection endpoints (slowapi format, e.g. "10/minute", "100/hour").
# RATE_LIMIT_DETECT=10/minute
# RATE_LIMIT_DETECTIONS=120/minute

# ─────────────────────────────────────────────────────────────────────────────
# CORS (set for container / cloud deployments)
# ─────────────────────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions data-management/viewer/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"pydantic==2.12.5",
"python-multipart==0.0.22",
"python-dotenv==1.2.1",
"slowapi==0.1.9",
"aiofiles==25.1.0",
"numpy==2.4.2",
"pyarrow==23.0.1",
Expand Down
62 changes: 57 additions & 5 deletions data-management/viewer/backend/src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@

from dotenv import load_dotenv
from fastapi import Depends, FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from .auth import require_auth
from .csrf import CSRF_COOKIE_NAME, generate_csrf_token
from .middleware import ContentSizeLimitMiddleware, SecurityHeadersMiddleware
from .routers import analysis, annotations, datasets, detection, export, joint_config, labels
from .routes import ai_analysis

Expand Down Expand Up @@ -81,13 +86,39 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
},
)

# Configure CORS — origins are read from CORS_ORIGINS env var (comma-separated)
# Rate limiter shared state
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


@app.exception_handler(Exception)
async def unhandled_exception_handler(request, exc: Exception) -> JSONResponse:
"""Log full traceback server-side, return generic error to client."""
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError) -> JSONResponse:
"""Return validation errors without internal paths."""
errors = [{"loc": error.get("loc"), "msg": error.get("msg"), "type": error.get("type")} for error in exc.errors()]
return JSONResponse(status_code=422, content={"detail": errors})


# Middleware stack (last added = outermost = first to execute)
# Order: SecurityHeaders → ContentSizeLimit → CORS → FastAPI App
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(
ContentSizeLimitMiddleware,
max_content_length=int(os.environ.get("MAX_REQUEST_BODY_BYTES", str(10 * 1024 * 1024))),
)
app.add_middleware(
CORSMiddleware,
allow_origins=_config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-API-Key", "X-Request-ID"],
)

# All /api/* routes require authentication (health and csrf-token are on app directly)
Expand All @@ -105,8 +136,28 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:

@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
"""Health check verifying API and storage connectivity."""
checks: dict[str, str] = {"api": "healthy"}

try:
from .services.dataset_service import get_dataset_service

service = get_dataset_service()
if hasattr(service, "base_path"):
from pathlib import Path as _Path

if _Path(service.base_path).exists():
checks["storage"] = "healthy"
else:
checks["storage"] = "unhealthy"
else:
checks["storage"] = "healthy"
except Exception:
checks["storage"] = "unhealthy"

overall = "healthy" if all(v == "healthy" for v in checks.values()) else "degraded"
status_code = 200 if overall == "healthy" else 503
return JSONResponse(content={"status": overall, "checks": checks}, status_code=status_code)


@app.get("/api/csrf-token", tags=["auth"])
Expand All @@ -130,5 +181,6 @@ async def get_csrf_token() -> JSONResponse:
httponly=False,
samesite="strict",
secure=secure,
path="/",
)
return response
114 changes: 114 additions & 0 deletions data-management/viewer/backend/src/api/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""OWASP security headers and request body size enforcement middleware."""

from __future__ import annotations

from typing import ClassVar

from starlette.responses import JSONResponse as _StarletteJSONResponse


class SecurityHeadersMiddleware:
"""Inject OWASP security headers on all HTTP responses.

CSP is only applied to non-API paths to avoid interfering with
frontend dev servers that proxy API responses and inherit headers.
"""

HEADERS: ClassVar[list[tuple[bytes, bytes]]] = [
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"referrer-policy", b"strict-origin-when-cross-origin"),
(b"permissions-policy", b"geolocation=(), microphone=(), camera=()"),
(b"cross-origin-opener-policy", b"same-origin"),
]

CSP_HEADER: ClassVar[tuple[bytes, bytes]] = (
b"content-security-policy",
b"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
b"img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; object-src 'none'",
)

_SKIP_PATHS: ClassVar[set[str]] = {"/docs", "/redoc", "/openapi.json"}

def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return

path = scope.get("path", "")
if path in self._SKIP_PATHS:
await self.app(scope, receive, send)
return

is_api = path.startswith("/api") or path == "/health"
extra_headers = self.HEADERS if is_api else [*self.HEADERS, self.CSP_HEADER]

async def send_with_headers(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.extend(extra_headers)
message = {**message, "headers": headers}
await send(message)

await self.app(scope, receive, send_with_headers)


class ContentSizeLimitMiddleware:
"""Reject requests exceeding the configured body size limit.

Checks Content-Length header upfront and tracks actual bytes
received to catch chunked transfer-encoding requests.
"""

def __init__(self, app, max_content_length: int = 10 * 1024 * 1024):
self.app = app
self.max_content_length = max_content_length

async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return

headers = dict(scope.get("headers", []))
content_length = headers.get(b"content-length")

if content_length is not None:
try:
if int(content_length) > self.max_content_length:
response = _StarletteJSONResponse(
{"detail": "Request body too large"},
status_code=413,
)
await response(scope, receive, send)
return
except ValueError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass # Malformed Content-Length header; fall through to streaming byte check

bytes_received = 0
limit = self.max_content_length

async def receive_with_limit():
nonlocal bytes_received
message = await receive()
if message.get("type") == "http.request":
body = message.get("body", b"")
bytes_received += len(body)
if bytes_received > limit:
raise _BodyTooLargeError
return message

try:
await self.app(scope, receive_with_limit, send)
except _BodyTooLargeError:
response = _StarletteJSONResponse(
{"detail": "Request body too large"},
status_code=413,
)
await response(scope, receive, send)


class _BodyTooLargeError(Exception):
"""Internal signal for body size limit exceeded."""
56 changes: 36 additions & 20 deletions data-management/viewer/backend/src/api/routers/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
"""

import logging
import sys
import os

from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from slowapi import Limiter
from slowapi.util import get_remote_address

from ..csrf import require_csrf_token
from ..models.detection import DetectionRequest, EpisodeDetectionSummary
Expand All @@ -17,18 +19,30 @@
from ..validation import SAFE_DATASET_ID_PATTERN, path_int_param, path_string_param

router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)


def _sanitize_for_log(value: object) -> str:
"""Sanitize user-controlled values before writing to logs to prevent log-forging via CR/LF injection."""
return str(value).replace("\r", "\\r").replace("\n", "\\n")


RATE_LIMIT_DETECT = os.environ.get("RATE_LIMIT_DETECT", "10/minute")
RATE_LIMIT_DETECTIONS = os.environ.get("RATE_LIMIT_DETECTIONS", "120/minute")


@router.post(
"/{dataset_id}/episodes/{episode_idx}/detect",
response_model=EpisodeDetectionSummary,
dependencies=[Depends(require_csrf_token)],
)
@limiter.limit(RATE_LIMIT_DETECT)
async def run_detection(
request: Request,
episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")),
dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")),
request: DetectionRequest = DetectionRequest(),
request_body: DetectionRequest = DetectionRequest(),
detection_service: DetectionService = Depends(get_detection_service),
dataset_service: DatasetService = Depends(get_dataset_service),
) -> EpisodeDetectionSummary:
Expand All @@ -39,30 +53,25 @@ async def run_detection(
returns detection results with bounding boxes and class labels.
Results are cached for subsequent retrieval.
"""
print(f"\n{'=' * 60}", file=sys.stderr, flush=True)
print(
f"[API] POST /detect called: dataset={dataset_id}, episode={episode_idx}",
file=sys.stderr,
flush=True,
logger.info(
"POST /detect: dataset=%s, episode=%d, model=%s, confidence=%s",
_sanitize_for_log(dataset_id),
int(episode_idx),
_sanitize_for_log(request_body.model),
float(request_body.confidence),
)
print(
f"[API] Request: model={request.model}, confidence={request.confidence}",
file=sys.stderr,
flush=True,
)
print(f"{'=' * 60}", file=sys.stderr, flush=True)

# Validate episode exists
episode = await dataset_service.get_episode(dataset_id, episode_idx)
if episode is None:
print("[API] ERROR: Episode not found", file=sys.stderr, flush=True)
logger.warning("Episode %d not found in dataset %s", int(episode_idx), _sanitize_for_log(dataset_id))
raise HTTPException(
status_code=404,
detail=f"Episode {episode_idx} not found in dataset '{dataset_id}'",
)

total_frames = episode.meta.length
print(f"[API] Episode has {total_frames} frames", file=sys.stderr, flush=True)
logger.info("Episode has %d frames", total_frames)

# Create frame image getter
async def get_frame_image(frame_idx: int) -> bytes | None:
Expand All @@ -72,7 +81,7 @@ async def get_frame_image(frame_idx: int) -> bytes | None:
summary = await detection_service.detect_episode(
dataset_id,
episode_idx,
request,
request_body,
get_frame_image,
total_frames,
)
Expand All @@ -82,19 +91,21 @@ async def get_frame_image(frame_idx: int) -> bytes | None:
status_code=503,
detail="YOLO dependencies not installed. Run: uv sync --extra yolo",
)
except Exception as e:
except Exception:
logger.exception("Detection failed")
raise HTTPException(
status_code=500,
detail=f"Detection failed: {e!s}",
detail="Detection failed",
)


@router.get(
"/{dataset_id}/episodes/{episode_idx}/detections",
response_model=EpisodeDetectionSummary | None,
)
@limiter.limit(RATE_LIMIT_DETECTIONS)
async def get_detections(
request: Request,
episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")),
dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")),
detection_service: DetectionService = Depends(get_detection_service),
Expand All @@ -107,8 +118,13 @@ async def get_detections(
return detection_service.get_cached(dataset_id, episode_idx)


@router.delete("/{dataset_id}/episodes/{episode_idx}/detections")
@router.delete(
"/{dataset_id}/episodes/{episode_idx}/detections",
dependencies=[Depends(require_csrf_token)],
)
@limiter.limit(RATE_LIMIT_DETECTIONS)
async def clear_detections(
request: Request,
episode_idx: int = Depends(path_int_param("episode_idx", ge=0, description="Episode index")),
dataset_id: str = Depends(path_string_param("dataset_id", pattern=SAFE_DATASET_ID_PATTERN, label="dataset_id")),
detection_service: DetectionService = Depends(get_detection_service),
Expand Down
Loading
Loading