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

View File
Whitespace-only changes.
+77
View File
@@ -0,0 +1,77 @@
"""Helpers shared by the security tests."""
from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
from starlette.types import ASGIApp, Message, Scope
@dataclass
class AsgiResult:
"""What one raw ASGI call produced and how much of the request body it consumed."""
status: int = 0
body: bytes = b""
headers: dict[str, str] = field(default_factory=dict)
bytes_read: int = 0
receive_calls: int = 0
def json(self) -> Any:
return json.loads(self.body)
async def asgi_request(
app: ASGIApp,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
chunks: Iterable[bytes] = (),
host: str = "mcp.example.com",
scheme: str = "https",
client: tuple[str, int] = ("198.51.100.7", 4000),
) -> AsgiResult:
"""Drive ``app`` with a counting ``receive`` so tests can assert how much body was read."""
result = AsgiResult()
pending = list(chunks)
raw_headers = [(b"host", host.encode())]
for name, value in (headers or {}).items():
raw_headers.append((name.lower().encode(), value.encode()))
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": method,
"scheme": scheme,
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"root_path": "",
"headers": raw_headers,
"client": client,
"server": (host, 443),
}
async def receive() -> Message:
result.receive_calls += 1
if not pending:
return {"type": "http.disconnect"}
chunk = pending.pop(0)
result.bytes_read += len(chunk)
return {"type": "http.request", "body": chunk, "more_body": bool(pending)}
async def send(message: Message) -> None:
if message["type"] == "http.response.start":
result.status = message["status"]
result.headers = {
key.decode().lower(): value.decode() for key, value in message["headers"]
}
elif message["type"] == "http.response.body":
result.body += message.get("body", b"")
await app(scope, receive, send)
return result
+89
View File
@@ -0,0 +1,89 @@
"""Input bounds of the unauthenticated OAuth routes (SEC-01, SEC-09)."""
from __future__ import annotations
import pytest
from aegis_gitea_mcp.oauth.callback import MAX_CONSENT_BODY_BYTES
from tests.security.helpers import asgi_request
from tests.test_oauth_helpers import BASE, build_oauth_app
CHUNK = 64 * 1024
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and the rate limiter read the process-wide settings."""
def chunks_of(total: int) -> list[bytes]:
return [b"a" * CHUNK for _ in range(total // CHUNK)]
async def test_consent_post_over_the_cap_is_413_without_reading_the_body() -> None:
app = build_oauth_app().app
result = await asgi_request(
app,
"POST",
"/oauth/consent",
headers={
"content-type": "application/x-www-form-urlencoded",
"content-length": str(1024 * 1024),
"origin": BASE,
},
chunks=chunks_of(1024 * 1024),
)
assert result.status == 413
assert result.bytes_read == 0
async def test_consent_post_without_declared_length_stops_reading_at_the_cap() -> None:
app = build_oauth_app().app
result = await asgi_request(
app,
"POST",
"/oauth/consent",
headers={"content-type": "application/x-www-form-urlencoded", "origin": BASE},
chunks=chunks_of(1024 * 1024),
)
assert result.status == 413
assert result.bytes_read <= MAX_CONSENT_BODY_BYTES + CHUNK
async def test_consent_multipart_is_415_without_reading_any_body() -> None:
app = build_oauth_app().app
result = await asgi_request(
app,
"POST",
"/oauth/consent",
headers={"content-type": "multipart/form-data; boundary=x", "origin": BASE},
chunks=chunks_of(512 * 1024),
)
assert result.status == 415
assert result.bytes_read == 0
async def test_consent_json_body_is_415() -> None:
app = build_oauth_app().app
result = await asgi_request(
app,
"POST",
"/oauth/consent",
headers={"content-type": "application/json", "origin": BASE},
chunks=[b"{}"],
)
assert result.status == 415
async def test_small_urlencoded_consent_post_still_reaches_the_handler() -> None:
app = build_oauth_app().app
result = await asgi_request(
app,
"POST",
"/oauth/consent",
headers={"content-type": "application/x-www-form-urlencoded", "origin": BASE},
chunks=[b"request=unknown&csrf=x&decision=approve"],
)
# Reaches the handler, which answers its fixed 400 page for an unknown request.
assert result.status == 400
assert b"invalid or has expired" in result.body