security: bound and redact inbound requests (#327)
CI / lint (pull_request) Successful in 33s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-08 18:34:48 +00:00
1 parent 34af4ce903
commit c9c5939f0a
4 files changed
+267

No files matched your search

+19
View File
@@ -11,6 +11,7 @@ from typing import Any, Literal
from urllib.parse import urlencode
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
@@ -31,6 +32,7 @@ from src.gitea_proxy import (
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -60,6 +62,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
@@ -601,6 +604,22 @@ CONTENT_SECURITY_POLICY = "; ".join(
)
@app.exception_handler(RequestValidationError)
async def redact_request_validation_error(
_request: Request, error: RequestValidationError
) -> JSONResponse:
"""Return useful validation locations without reflecting submitted values."""
details = [
{key: item[key] for key in ("type", "loc", "msg") if key in item}
for item in error.errors()
]
return JSONResponse(
{"detail": details},
status_code=422,
headers={"Cache-Control": "no-store"},
)
@app.middleware("http")
async def enforce_browser_security_boundary(request: Request, call_next):
"""Apply one browser trust boundary, including to auth short-circuits."""
+93
View File
@@ -0,0 +1,93 @@
from collections.abc import Callable
from starlette.types import ASGIApp, Message, Receive, Scope, Send
SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
def request_body_limit(method: str, path: str) -> int | None:
"""Return the admission limit for request bodies that FastAPI will parse."""
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
return API_MUTATION_BODY_LIMIT
return None
class RequestBodyLimitMiddleware:
"""Reject oversized API bodies before framework parsing or endpoint work."""
def __init__(
self,
app: ASGIApp,
limit_for: Callable[[str, str], int | None] = request_body_limit,
) -> None:
self.app = app
self.limit_for = limit_for
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
root_path = scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
path = path[len(root_path) :] or "/"
limit = self.limit_for(scope.get("method", "GET"), path)
if limit is None:
await self.app(scope, receive, send)
return
declared_length = self._content_length(scope)
if declared_length is not None and declared_length > limit:
await self._reject(send)
return
messages: list[Message] = []
received = 0
while True:
message = await receive()
messages.append(message)
if message["type"] != "http.request":
break
received += len(message.get("body", b""))
if received > limit:
await self._reject(send)
return
if not message.get("more_body", False):
break
async def replay() -> Message:
if messages:
return messages.pop(0)
return {"type": "http.request", "body": b"", "more_body": False}
await self.app(scope, replay, send)
@staticmethod
def _content_length(scope: Scope) -> int | None:
for name, value in scope.get("headers", []):
if name.lower() != b"content-length":
continue
try:
parsed = int(value)
except ValueError:
return None
return max(0, parsed)
return None
@staticmethod
async def _reject(send: Send) -> None:
body = b'{"detail":"Request body too large"}'
headers = [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
(b"cache-control", b"no-store"),
]
await send({"type": "http.response.start", "status": 413, "headers": headers})
await send({"type": "http.response.body", "body": body})