security: bound and redact inbound requests (#327)
This commit is contained in:
1 parent
34af4ce903
commit
c9c5939f0a
4 files changed
+267
No files matched your search
+19
@@ -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."""
|
||||
|
||||
@@ -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})
|
||||
Reference in new issue
Block a user