fix: cap and type-check the unauthenticated consent POST body (SEC-01)
This commit is contained in:
1 parent
5baf73f38b
commit
d5ca530db3
4 files changed
+210
-4
No files matched your search
@@ -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"],
|
||||
),
|
||||
]
|
||||
Reference in new issue
Block a user