From 567b0e88b357e5f3917b6572946e6d64ebc37430 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 14 Aug 2026 16:31:47 -0400 Subject: [PATCH] feat(wsgi): Gate request body collection on data_collection option Gate incoming request body attachment to the data_collection["http_bodies"] config when data collection is enabled. Prior to data collection being implemented, request bodies were unconditionally attached; default to True to maintain backward compatibility when the feature is not in use. Refs PY-2419 Refs #6283 --- sentry_sdk/integrations/_wsgi_common.py | 51 +++++---- tests/integrations/flask/test_flask.py | 142 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 50db021a62..3ccf029964 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -89,6 +89,10 @@ def extract_into_event(self, event: "Event") -> None: content_length = self.content_length() request_info = event.get("request", {}) + # Prior to data collection being implemented we unconditionally attached + # the request body, which is why we default to True here. + attach_request_body = True + if has_data_collection_enabled(client.options): cookies = _apply_key_value_collection_filtering( items=dict(self.cookies()), @@ -96,31 +100,36 @@ def extract_into_event(self, event: "Event") -> None: ) if cookies: request_info["cookies"] = cookies + + attach_request_body = ( + "incoming_request" in client.options["data_collection"]["http_bodies"] + ) elif should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) - if not request_body_within_bounds(client, content_length): - data = AnnotatedValue.removed_because_over_size_limit() - else: - # First read the raw body data - # It is important to read this first because if it is Django - # it will cache the body and then we can read the cached version - # again in parsed_body() (or json() or wherever). - raw_data = None - try: - raw_data = self.raw_data() - except _RAW_DATA_EXCEPTIONS: - # If DjangoRestFramework is used it already read the body for us - # so reading it here will fail. We can ignore this. - pass - - parsed_body = self.parsed_body() - if parsed_body is not None: - data = parsed_body - elif raw_data: - data = AnnotatedValue.removed_because_raw_data() + if attach_request_body: + if not request_body_within_bounds(client, content_length): + data = AnnotatedValue.removed_because_over_size_limit() else: - data = None + # First read the raw body data + # It is important to read this first because if it is Django + # it will cache the body and then we can read the cached version + # again in parsed_body() (or json() or wherever). + raw_data = None + try: + raw_data = self.raw_data() + except _RAW_DATA_EXCEPTIONS: + # If DjangoRestFramework is used it already read the body for us + # so reading it here will fail. We can ignore this. + pass + + parsed_body = self.parsed_body() + if parsed_body is not None: + data = parsed_body + elif raw_data: + data = AnnotatedValue.removed_because_raw_data() + else: + data = None if data is not None: request_info["data"] = data diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 1252673b34..5586f9f276 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -1594,3 +1594,145 @@ def login(): assert "user.id" not in segment.get("attributes", {}) assert "user.email" not in segment.get("attributes", {}) assert "user.name" not in segment.get("attributes", {}) + + +@pytest.mark.parametrize( + "data_collection, expect_body", + [ + pytest.param({}, True, id="data_collection_http_bodies_default"), + pytest.param( + {"http_bodies": ["incoming_request"]}, + True, + id="data_collection_http_bodies_incoming_request", + ), + pytest.param( + {"http_bodies": ["outgoing_request"]}, + False, + id="data_collection_http_bodies_outgoing_request_only", + ), + pytest.param( + {"http_bodies": []}, False, id="data_collection_http_bodies_empty" + ), + ], +) +def test_flask_request_body_data_collection( + sentry_init, capture_events, app, monkeypatch, data_collection, expect_body +): + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + _experiments={"data_collection": data_collection}, + ) + # This test is about request body gating, not user data. + monkeypatch.setattr(flask_sentry, "flask_login", None) + + data = {"foo": "bar"} + + @app.route("/", methods=["POST"]) + def index(): + capture_message("hi") + return "ok" + + events = capture_events() + + client = app.test_client() + response = client.post("/", content_type="application/json", data=json.dumps(data)) + assert response.status_code == 200 + + (event,) = events + if expect_body: + assert event["request"]["data"] == data + else: + assert "data" not in event["request"] + + +def test_flask_request_body_dropped_with_form_and_files_data_collection( + sentry_init, capture_events, app, monkeypatch +): + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + max_request_body_size="always", + _experiments={"data_collection": {"http_bodies": []}}, + ) + monkeypatch.setattr(flask_sentry, "flask_login", None) + + data = { + "foo": "bar", + "file": (BytesIO(b"hello"), "hello.txt"), + } + + @app.route("/", methods=["POST"]) + def index(): + assert list(request.form) == ["foo"] + assert list(request.files) == ["file"] + capture_message("hi") + return "ok" + + events = capture_events() + + client = app.test_client() + response = client.post("/", data=data) + assert response.status_code == 200 + + (event,) = events + assert "data" not in event["request"] + assert "data" not in event.get("_meta", {}).get("request", {}) + + +def test_flask_transaction_request_body_data_collection( + sentry_init, capture_events, app, monkeypatch +): + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + traces_sample_rate=1.0, + _experiments={"data_collection": {"http_bodies": []}}, + ) + monkeypatch.setattr(flask_sentry, "flask_login", None) + + data = {"username": "sentry-user", "age": "26"} + + @app.route("/", methods=["POST"]) + def index(): + capture_message("hi") + return "ok" + + events = capture_events() + + client = app.test_client() + response = client.post("/", content_type="application/json", data=data) + assert response.status_code == 200 + + event, transaction_event = events + assert "data" not in event["request"] + assert "data" not in transaction_event["request"] + + +def test_flask_oversized_request_body_not_annotated_data_collection( + sentry_init, capture_events, app, monkeypatch +): + """ + The gating happens before the size check, so an oversized body is dropped + outright instead of being reported as removed because of the size limit. + """ + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + max_request_body_size="small", + _experiments={"data_collection": {"http_bodies": []}}, + ) + monkeypatch.setattr(flask_sentry, "flask_login", None) + + data = "a" * 2000 + + @app.route("/", methods=["POST"]) + def index(): + capture_message("hi") + return "ok" + + events = capture_events() + + client = app.test_client() + response = client.post("/", data=data) + assert response.status_code == 200 + + (event,) = events + assert "data" not in event["request"] + assert "data" not in event.get("_meta", {}).get("request", {})