fix: cap and type-check the unauthenticated consent POST body (SEC-01)

This commit is contained in:
Latte committed 2026-10-14 17:07:06 +02:00
1 parent 5baf73f38b
commit d5ca530db3
4 files changed
+210 -4

No files matched your search

+44 -4
View File
@@ -16,14 +16,21 @@ import html
from typing import Any
from urllib.parse import urlsplit
from mcp.server.transport_security import RequestBodyLimitMiddleware
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
from starlette.routing import Route
from starlette.routing import Route, request_response
from starlette.types import ASGIApp, Receive, Scope, Send
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH
COOKIE_TTL_SECONDS = 600
#: Largest request body the consent form may send (three short fields).
MAX_CONSENT_BODY_BYTES = 16 * 1024
_MAX_FORM_FIELDS = 8
_MAX_FORM_PART_BYTES = 4096
_INVALID_MESSAGE = (
"This sign-in request is invalid or has expired. Start the sign-in again from your MCP client."
)
@@ -100,6 +107,28 @@ def _text(value: Any, limit: int = 200) -> str:
return value[:limit] if isinstance(value, str) else ""
class _UrlencodedFormOnly:
"""ASGI guard for the consent POST, applied before any body is read.
The route is reachable without authentication, so it must not parse
arbitrary bodies: anything but ``application/x-www-form-urlencoded`` gets
415 without reading a byte, and the body is capped at
:data:`MAX_CONSENT_BODY_BYTES` (413 beyond it).
"""
def __init__(self, app: ASGIApp) -> None:
self._limited = RequestBodyLimitMiddleware(app, MAX_CONSENT_BODY_BYTES)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
content_type = Headers(scope=scope).get("content-type", "")
if content_type.split(";")[0].strip().lower() != "application/x-www-form-urlencoded":
response = Response("Unsupported media type", status_code=415)
await response(scope, receive, send)
return
await self._limited(scope, receive, send)
def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
"""Return the consent and callback routes for ``provider``."""
cookies = _Cookies(provider.base_url)
@@ -138,7 +167,12 @@ def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
origin = request.headers.get("origin")
if origin is not None and origin != expected_origin:
return _invalid()
form = await request.form()
try:
form = await request.form(
max_files=0, max_fields=_MAX_FORM_FIELDS, max_part_size=_MAX_FORM_PART_BYTES
)
except Exception:
return _invalid()
decision = provider.finish_consent(
_text(form.get("request")),
_text(form.get("csrf")),
@@ -168,6 +202,12 @@ def build_browser_routes(provider: GiteaOAuthProvider) -> list[Route]:
return [
Route(CONSENT_PATH, consent_page, methods=["GET"]),
Route(CONSENT_PATH, consent_submit, methods=["POST"]),
Route(CALLBACK_PATH, callback, methods=["GET"]),
Route(
CONSENT_PATH, _UrlencodedFormOnly(request_response(consent_submit)), methods=["POST"]
),
Route(
CALLBACK_PATH,
RequestBodyLimitMiddleware(request_response(callback), MAX_CONSENT_BODY_BYTES),
methods=["GET"],
),
]