Skip to content

Commit b7b872c

Browse files
committed
Reject a request body over the configured size before parsing it
1 parent b50385c commit b7b872c

8 files changed

Lines changed: 61 additions & 0 deletions

File tree

‎app.conf.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
[app]
22
# Loglevel can be one of: debug, info, warning, error, critical
33
loglevel=debug
4+
# Maximum size of a request body in bytes, larger requests are rejected before they are parsed
5+
max_request_body_bytes = 1048576
46

57
[logging]
68
# All keys are optional. When syslog_path is omitted, logs only go to stdout.

‎app/application.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from app.logging.context import CLAIM_FIELDS
2424
from app.logging.events import Log
2525
from app.models.fhir.resources.localization_list.request import COUNT_PARAM
26+
from app.request_body_limit import RequestBodyLimitMiddleware
2627
from app.routers.default import router as default_router
2728
from app.routers.fhir.base import router as fhir_base_router
2829
from app.routers.fhir.localization_list import router as fhir_list_router
@@ -199,6 +200,7 @@ def setup_fastapi() -> FastAPI:
199200
register_exceptions(fastapi)
200201
_document_page_sizes(fastapi, config.pagination.default_page_size, config.pagination.max_page_size)
201202

203+
fastapi.add_middleware(RequestBodyLimitMiddleware, max_bytes=config.app.max_request_body_bytes)
202204
fastapi.add_middleware(
203205
RequestContextMiddleware,
204206
correlation_id_expected=config.logging.correlation_id_expected,

‎app/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class LogLevel(str, Enum):
2828

2929
class ConfigApp(BaseModel):
3030
loglevel: LogLevel = Field(default=LogLevel.info)
31+
max_request_body_bytes: int = Field(default=1048576, ge=1)
3132

3233

3334
class ConfigDatabase(BaseModel):

‎app/errors/mapping.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel, ConfigDict
44

55
from app.models.fhir.resources.operation_outcome.resource import IssueType
6+
from app.request_body_limit import RequestBodyTooLargeError
67
from app.services.exceptions import (
78
BundleTooLargeError,
89
ConflictError,
@@ -45,6 +46,9 @@ class ErrorSpec(BaseModel):
4546
PseudonymExchangeError: ErrorSpec(severity="error", fhir_code="invalid", http_status=400, log_as="failure"),
4647
CryptoServiceError: ErrorSpec(severity="error", fhir_code="transient", http_status=502, log_as="failure"),
4748
BundleTooLargeError: ErrorSpec(severity="error", fhir_code="too-costly", http_status=413, log_as="invalid_request"),
49+
RequestBodyTooLargeError: ErrorSpec(
50+
severity="error", fhir_code="too-long", http_status=413, log_as="invalid_request"
51+
),
4852
}
4953

5054
# Applied to any exception without a mapped type (rendered as HTTP 500). Unmapped

‎app/request_body_limit.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from starlette.exceptions import HTTPException
2+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
3+
4+
5+
class RequestBodyTooLargeError(HTTPException):
6+
def __init__(self, max_bytes: int) -> None:
7+
super().__init__(status_code=413, detail=f"Request body exceeds the maximum of {max_bytes} bytes")
8+
9+
def __str__(self) -> str:
10+
return self.detail
11+
12+
13+
class RequestBodyLimitMiddleware:
14+
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
15+
self.app = app
16+
self.max_bytes = max_bytes
17+
18+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
19+
if scope["type"] != "http":
20+
await self.app(scope, receive, send)
21+
return
22+
23+
received = 0
24+
25+
async def limited_receive() -> Message:
26+
nonlocal received
27+
message = await receive()
28+
received += len(message.get("body", b""))
29+
if received > self.max_bytes:
30+
raise RequestBodyTooLargeError(self.max_bytes)
31+
return message
32+
33+
await self.app(scope, limited_receive, send)

‎tests/errors/test_mapping.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from app.errors.mapping import DEFAULT_SPEC, ErrorSpec, LogAs, should_log_failure, spec_for
44
from app.models.auth.data import AuthorizationScope
55
from app.models.fhir.resources.operation_outcome.resource import IssueType
6+
from app.request_body_limit import RequestBodyTooLargeError
67
from app.services.exceptions import (
78
BundleTooLargeError,
89
ConflictError,
@@ -45,6 +46,9 @@ def _scope_error() -> UnauthorizedScopeError:
4546
pytest.param(
4647
BundleTooLargeError("too many"), _spec("too-costly", 413, "invalid_request"), id="bundle-too-large"
4748
),
49+
pytest.param(
50+
RequestBodyTooLargeError(10), _spec("too-long", 413, "invalid_request"), id="request-body-too-large"
51+
),
4852
pytest.param(InvalidModelError("bad"), _spec("structure", 400, "invalid_request"), id="invalid-model"),
4953
pytest.param(_scope_error(), _spec("security", 403), id="unauthorized-scope-via-mro"),
5054
pytest.param(UnauthorizedManagingRequestError(), _spec("security", 403), id="unauthorized-managing-via-mro"),

‎tests/routers/test_fhir_base.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ def test_rejects_a_bundle_over_the_entry_limit_without_processing_it(self, clien
5858
assert len(records.for_event(Log.INVALID_REQUEST)) == 1
5959
assert records.for_event(Log.REGISTERED_REFERRAL) == []
6060

61+
@pytest.mark.parametrize("chunked", [False, True], ids=["content-length", "chunked"])
62+
def test_rejects_a_body_over_the_size_limit_before_parsing_it(self, client: TestClient, chunked: bool) -> None:
63+
body = b"x" * (get_test_config().app.max_request_body_bytes + 1)
64+
content = iter([body]) if chunked else body
65+
with capture_records("app") as records:
66+
response = client.post("/fhir", content=content, headers=FHIR_JSON)
67+
68+
assert response.status_code == 413
69+
assert response.json()["issue"][0]["code"] == "too-long"
70+
assert_event_emitted(records, Log.INVALID_REQUEST, http_status=413)
71+
6172
def test_rejects_an_empty_bundle(self, client: TestClient) -> None:
6273
response = client.post("/fhir", json=_bundle(), headers=FHIR_JSON)
6374

‎tests/test_config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,7 @@ def test_bundle_defaults_when_the_section_is_missing() -> None:
7676
def test_bundle_rejects_a_maximum_below_one() -> None:
7777
with pytest.raises(ValidationError):
7878
ConfigBundle(max_entries=0)
79+
80+
81+
def test_request_body_limit_defaults_to_one_mebibyte() -> None:
82+
assert get_test_config().app.max_request_body_bytes == 1048576

0 commit comments

Comments
 (0)