Skip to content

Commit f222c9d

Browse files
antonpirkerhasier
andauthored
Fix reading FastAPI request body twice. (#1724)
Starlette/FastAPI is internally caching the request body if read via request.json() or request.body() but NOT when using request.form(). This leads to a problem when our Sentry Starlette integration wants to read the body data and also the users code wants to read the same data. Solution: Force caching of request body for .form() calls too, to prevent error when body is read twice. The tests where mocking .stream() and thus hiding this problem. So the tests have been refactored to mock the underlying ._receive() function instead. Co-authored-by: hasier <hasier@users.noreply.github.com>
1 parent 0923d03 commit f222c9d

File tree

2 files changed

+159
-160
lines changed

2 files changed

+159
-160
lines changed

sentry_sdk/integrations/starlette.py

Lines changed: 45 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323

2424
if MYPY:
25-
from typing import Any, Awaitable, Callable, Dict, Optional, Union
25+
from typing import Any, Awaitable, Callable, Dict, Optional
2626

2727
from sentry_sdk._types import Event
2828

@@ -367,10 +367,10 @@ def _make_request_event_processor(req, integration):
367367
def event_processor(event, hint):
368368
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
369369

370-
# Extract information from request
370+
# Add info from request to event
371371
request_info = event.get("request", {})
372372
if info:
373-
if "cookies" in info and _should_send_default_pii():
373+
if "cookies" in info:
374374
request_info["cookies"] = info["cookies"]
375375
if "data" in info:
376376
request_info["data"] = info["data"]
@@ -473,30 +473,46 @@ async def extract_request_info(self):
473473
request_info = {} # type: Dict[str, Any]
474474

475475
with capture_internal_exceptions():
476+
# Add cookies
476477
if _should_send_default_pii():
477478
request_info["cookies"] = self.cookies()
478479

480+
# If there is no body, just return the cookies
479481
content_length = await self.content_length()
480-
481-
if content_length:
482-
data = None # type: Union[Dict[str, Any], AnnotatedValue, None]
483-
484-
if not request_body_within_bounds(client, content_length):
485-
data = AnnotatedValue.removed_because_over_size_limit()
486-
487-
else:
488-
parsed_body = await self.parsed_body()
489-
if parsed_body is not None:
490-
data = parsed_body
491-
elif await self.raw_data():
492-
data = AnnotatedValue.removed_because_raw_data()
493-
else:
494-
data = None
495-
496-
if data is not None:
497-
request_info["data"] = data
498-
499-
return request_info
482+
if not content_length:
483+
return request_info
484+
485+
# Add annotation if body is too big
486+
if content_length and not request_body_within_bounds(
487+
client, content_length
488+
):
489+
request_info["data"] = AnnotatedValue.removed_because_over_size_limit()
490+
return request_info
491+
492+
# Add JSON body, if it is a JSON request
493+
json = await self.json()
494+
if json:
495+
request_info["data"] = json
496+
return request_info
497+
498+
# Add form as key/value pairs, if request has form data
499+
form = await self.form()
500+
if form:
501+
form_data = {}
502+
for key, val in iteritems(form):
503+
is_file = isinstance(val, UploadFile)
504+
form_data[key] = (
505+
val
506+
if not is_file
507+
else AnnotatedValue.removed_because_raw_data()
508+
)
509+
510+
request_info["data"] = form_data
511+
return request_info
512+
513+
# Raw data, do not add body just an annotation
514+
request_info["data"] = AnnotatedValue.removed_because_raw_data()
515+
return request_info
500516

501517
async def content_length(self):
502518
# type: (StarletteRequestExtractor) -> Optional[int]
@@ -509,19 +525,17 @@ def cookies(self):
509525
# type: (StarletteRequestExtractor) -> Dict[str, Any]
510526
return self.request.cookies
511527

512-
async def raw_data(self):
513-
# type: (StarletteRequestExtractor) -> Any
514-
return await self.request.body()
515-
516528
async def form(self):
517529
# type: (StarletteRequestExtractor) -> Any
518-
"""
519-
curl -X POST http://localhost:8000/upload/somethign -H "Content-Type: application/x-www-form-urlencoded" -d "username=kevin&password=welcome123"
520-
curl -X POST http://localhost:8000/upload/somethign -F username=Julian -F password=hello123
521-
"""
522530
if multipart is None:
523531
return None
524532

533+
# Parse the body first to get it cached, as Starlette does not cache form() as it
534+
# does with body() and json() https://github.com/encode/starlette/discussions/1933
535+
# Calling `.form()` without calling `.body()` first will
536+
# potentially break the users project.
537+
await self.request.body()
538+
525539
return await self.request.form()
526540

527541
def is_json(self):
@@ -530,33 +544,11 @@ def is_json(self):
530544

531545
async def json(self):
532546
# type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]]
533-
"""
534-
curl -X POST localhost:8000/upload/something -H 'Content-Type: application/json' -d '{"login":"my_login","password":"my_password"}'
535-
"""
536547
if not self.is_json():
537548
return None
538549

539550
return await self.request.json()
540551

541-
async def parsed_body(self):
542-
# type: (StarletteRequestExtractor) -> Any
543-
"""
544-
curl -X POST http://localhost:8000/upload/somethign -F username=Julian -F password=hello123 -F photo=@photo.jpg
545-
"""
546-
form = await self.form()
547-
if form:
548-
data = {}
549-
for key, val in iteritems(form):
550-
if isinstance(val, UploadFile):
551-
data[key] = AnnotatedValue.removed_because_raw_data()
552-
else:
553-
data[key] = val
554-
555-
return data
556-
557-
json_data = await self.json()
558-
return json_data
559-
560552

561553
def _set_transaction_name_and_source(event, transaction_style, request):
562554
# type: (Event, str, Any) -> None

0 commit comments

Comments
 (0)