Skip to content

fix(sentry): cap request bodies at 1KB and scrub Postgres DETAIL rows - #54

Open
blarghmatey wants to merge 3 commits into
mainfrom
tmacey/sentry-scrub-request-bodies
Open

fix(sentry): cap request bodies at 1KB and scrub Postgres DETAIL rows#54
blarghmatey wants to merge 3 commits into
mainfrom
tmacey/sentry-scrub-request-bodies

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Sep 8, 2026

Copy link
Copy Markdown
Member

This repo

This app never passed max_request_body_size, so it has been on the SDK default of "medium" — 10KB bodies — by omission rather than by decision. Sets it to "small" explicitly at the sentry_sdk.init call site, so the choice is findable there rather than in a dependency's defaults, and adds the Postgres DETAIL: scrub to the existing before_send.

This is a FastAPI app. The Starlette integration's extract_request_info does the capture and attaches no body when the content-length header is absent. Every route it registers is a GET.

What

Two independent ways learner data reaches Sentry, neither of them governed by send_default_pii. Part of a ten-repo change on branch tmacey/sentry-scrub-request-bodies.

1. HTTP request bodies are captured with no PII gate

request_info["data"] is set unconditionally in RequestExtractor.extract_into_event. The only control is max_request_body_size, checked by request_body_within_bounds:

value effect
never no bodies
small bodies <= 1,000 bytes
medium bodies <= 10,000 bytes — the SDK default
always no limit

Verified against sentry-sdk source: request_body_within_bounds does the bounds check, RequestExtractor.extract_into_event sets the body unconditionally, and "medium" is the max_request_body_size default. The repos in this change pin 1.9.0 to 2.68.0, and the behaviour is the same across them.

The bound is checked against the declared Content-Length, and a missing or malformed header counts as 0. Under WSGI, Django then reads 0 bytes, so nothing gets past it. Under ASGI, Django takes CONTENT_LENGTH from the header and reads the whole body, so on learn-ai and mit-learn a chunked request with no Content-Length is captured in full. The Starlette integration (ol-analytics-api) attaches no body when the header is absent.

Which endpoints that actually means, measured in Sentry over the 30 days to 2026-09-08 (dataset=errors, http.method:[POST,PUT,PATCH], grouped by transaction):

transaction project events
PATCH /scim/v2/Users/{uuid} mitxonline 17,610
POST /api/v1/webhooks/content_files/ mit-learn 5,039
POST /api/v1/enrollments/ mitxonline 1,436
POST .../xblock/{usage_id}/handler/{handler}/ openedx-mitxpro 553
POST /cms/pages/{page_id}/edit/ micromasters 273
POST /api/profile/details/ mitxonline 132
POST /api/checkout/result/ mitxonline 65
POST /api/checkout/redeem_discount/ mitxonline 46

Enrollment, checkout, profile edits, SCIM user attributes, and xblock handler submissions — governed by a knob nobody set.

2. Postgres DETAIL: lines leak rows through the exception text

A constraint violation carries a DETAIL: Failing row contains (...) line reproducing the whole offending row, and psycopg puts it in str(exc). It therefore ships inside the exception value, where no SDK privacy option reaches it — send_default_pii governs user/cookie/header capture and max_request_body_size governs bodies; neither touches exception text.

Measured on MITXONLINE-6PK: a SCIM PATCH IntegrityError on users_user whose DETAIL line reproduces a learner email address three times per event. First seen 2026-05-27, 46,764 occurrences, last seen 2026-09-08 — still firing.

before_send now walks the whole serialized event and truncates any string at its DETAIL: line, whether the newline arrives raw or as a literal \n (the SDK repr()s frame locals and non-string log params before before_send runs). It keeps the primary error that names the failure and drops the row echo plus any HINT/CONTEXT after it.

Testing

Unit tests next to the module use a real MITXONLINE-6PK exception value with the learner identifiers replaced. They assert the primary message survives, the email and external UUID do not, and HINT/CONTEXT are dropped, for both the raw DETAIL: line and the repr'd form the SDK produces for frame locals and log params. One test goes through a real SDK client, with this module's _before_send and a fake transport, and checks that no outgoing event carries the email. Against the pre-review module, 4 of the tests fail, that one included.

Not in scope

send_default_pii and the salted-user-hash work are deliberately untouched here — different knob, different failure mode, tracked separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX

The other nine PRs

Same branch name (tmacey/sentry-scrub-request-bodies) in each repo. Independent — no merge order required.

repo PR body size
open-edx-plugins mitodl/open-edx-plugins#866 never
mit-learn mitodl/mit-learn#3915 small
mitxonline mitodl/mitxonline#3936 small
mitxpro mitodl/mitxpro#4088 small
ocw-studio mitodl/ocw-studio#3198 small
odl-video-service mitodl/odl-video-service#1591 small
micromasters mitodl/micromasters#5561 small (as request_bodies, SDK 1.9.0)
open-discussions mitodl/open-discussions#4463 small
learn-ai mitodl/learn-ai#62 small
ol-analytics-api #54 small

Two ways learner data reaches Sentry, neither gated by send_default_pii.

Request bodies. The SDK sets request.data unconditionally at
sentry_sdk/integrations/_wsgi_common.py:123; max_request_body_size,
checked at :61, is the only control, and left unset it defaults to
"medium" -- 10,000-byte bodies. Nobody chose that. Measured over the
last 30 days, the write endpoints that actually raise are the sensitive
ones: SCIM user PATCH, /api/v1/enrollments/, /api/checkout/result/,
/api/checkout/redeem_discount/, /api/profile/details/, and CMS page
edits. Set to "small" explicitly, so the choice is findable at the call
site instead of in a dependency's defaults.

Postgres DETAIL lines. A constraint violation carries a DETAIL line that
echoes the whole offending row, and psycopg puts it in str(exc) -- so it
ships inside the exception value, which no SDK privacy option covers.
Measured on mitxonline MITXONLINE-6PK: a SCIM PATCH IntegrityError
reproducing a learner email address three times per event, 46,764
occurrences since 2026-05-27. before_send now truncates at the DETAIL
marker across exception values, logentry, and the legacy top-level
message, keeping the primary error that names the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX
blarghmatey and others added 2 commits September 8, 2026 11:54
…fields

Copilot review, verified against sentry-sdk 2.55.0 source. The first pass
enumerated three paths -- exception values, logentry.message/.formatted,
and the legacy top-level message -- and missed every other field that can
carry the same string:

  breadcrumbs[].message   LoggingIntegration records each log record as a
                          breadcrumb (integrations/logging.py:311). This is
                          exactly MITXONLINE-6PK's shape: mechanism=logging,
                          logger=django_scim.views.
  logentry.params         record.args verbatim (:274), so
                          logger.error("...: %s", exc) carries it.
  frames[].vars           include_local_variables defaults to True
                          (consts.py:1028, utils.py:616), so a catch block
                          holding the exception in a local carries it.

Confirmed the old implementation leaked on all three shapes before
changing it; the new tests fail against it and pass against the walk.

Replaced with a recursive walk of the event instead of a longer path
list -- it covers these without enumerating them and does not go stale
when the SDK grows another such field. The walk only rewrites str leaves
and preserves everything else, with a test pinning that.

Copilot also suggested normalizing exception-valued params. Not needed:
client._prepare_event serializes the event before calling before_send
(client.py:650 vs :658), so every leaf is already a JSON primitive by
then and there are no live exception objects left to coerce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX
The SDK repr()s frame locals and logging params before before_send, so the
DETAIL newline arrives there as a literal backslash-n and the old find() missed
it. Tests now use that shape, plus one that goes through the real SDK. The
body-size comment cited the WSGI extractor; this app uses the Starlette one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYc2F3cFvSCfVzqeRMshGy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant