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
2 changes: 2 additions & 0 deletions .cspell/general-technical.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,7 @@ redhat
redirections
redisenterprise
redtiger
redoc
reengineer
reengineered
reengineering
Expand Down Expand Up @@ -1217,6 +1218,7 @@ slas
sles
sloc
slos
slowapi
slsa
smartctl
smes
Expand Down
2 changes: 1 addition & 1 deletion data-management/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ uv venv --python 3.11
source .venv/bin/activate

# Install dependencies (include 'azure' extra for blob storage support)
uv pip install -e ".[dev,analysis,export,azure]"
uv pip install -e ".[dev,export,azure]"
```

### Frontend Setup
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.26",
"python-dotenv==1.2.2",
"slowapi==0.1.9",
"aiofiles==25.1.0",
"numpy==2.4.4",
"pyarrow==23.0.1",
Expand Down
60 changes: 55 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 _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded

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

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

# Configure CORS — origins are read from CORS_ORIGINS env var (comma-separated)
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 +134,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 +179,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."""
8 changes: 8 additions & 0 deletions data-management/viewer/backend/src/api/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Shared rate limiter instance for the entire API."""

from __future__ import annotations

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
Loading
Loading