Repository navigation
Stop public form submissions reaching Sentry as unhandled errors - #23
Conversation
Two ways a POST to /contact or /resources/newsletter could reach Sentry as an unhandled exception, both reachable by anyone who can send a request to a public form URL. request.formData() throws a TypeError from undici the moment the Content-Type is not multipart/form-data or application/x-www-form-urlencoded, and it throws it while reading the body, before any of the action's own code runs. That answered 500 for every request carrying a JSON body, no body at all, or an invented Content-Type -- and it threw ahead of checkHoneypot, so the 400 path that already exists for junk traffic never saw them. remix-utils' shouldCheckHoneypot returns false for a form carrying neither honeypot field, and check then returns without throwing. That is the right call for a route with no honeypot, but every form on these two routes renders one unconditionally, so a bot could skip the check entirely by not sending the fields. readFormData reads the media type off the header and throws a 400 otherwise, the same contract checkHoneypot already uses. checkHoneypot now requires name__confirm, and from__confirm when it is configured, before delegating. Omission returns the same 400 as a filled field, so neither reaches Sentry.
…a types CodeRabbit caught that the allowlist in readFormData was necessary but not sufficient: multipart/form-data carries its boundary as a header *parameter*, so a request declaring a media type we accept can still be unreadable. Verified against the runtime -- a boundary-less multipart body makes formData() throw a TypeError, which escaped as the same 500 this was meant to remove. Enumerating the headers that produce a parse failure would not be a fix, it would be a list of today's, and undici is free to refuse a body for a reason nobody enumerated. So the parse itself is wrapped and every TypeError it throws is answered 400. Only a TypeError is converted: that is how formData() reports an unreadable body, and anything else is a fault on this side of the wire that still belongs in Sentry. The boundary cases now live in tests/form-data.test.ts rather than the smoke test. /contact and /resources/newsletter are guarded by a single express-rate-limit instance keyed by IP, so both draw on one budget of 10 per minute rather than 10 each -- an end-to-end matrix spent it and started failing the honeypot and charset cases with 429s that had nothing to do with the code. The smoke test keeps one case per route, which is all it was ever proving: that each action is wired to the helper. All rejections now share one body, so a client cannot tell a refused body from a honeypot rejection and cannot map what the endpoint checks.
A three-axis review of the previous two commits found the fix did not actually achieve its stated goal. It said junk traffic is cheap and silent, and neither was true: a 400 thrown from an action is an ErrorResponse to React Router exactly as a crash is, and GeneralErrorBoundary calls captureException unconditionally. So every rejection this change added was reported from the browser -- with replaysOnErrorSampleRate 1.0, attaching a session replay to whoever submitted. The newsletter form is in the footer of every page, so one stray POST against it was enough. The 400 also replaced the whole page with a bare error, taking the user's typed message with it. isRejectedSubmission now recognises the guards' own answer, on the status and the shared body together: the body alone would silence any future 500 carrying the same text, and the status alone would re-report the 400 the router raises for a scanner probing a path, which sentry-event-filters.ts already drops server-side. Narrowed on both, and pinned from both sides in tests/error-boundary.test.ts. Also fixed, from the same review: The honeypot no longer hardcodes its field names. shouldCheckHoneypot is protected, which is the library marking it an extension point, so overriding it is the supported way to be strict -- verified that it gets the same SpamError through the library's own path. Reading the names off the base class instead of restating them also keeps the requirement true under randomizeNameFieldName, which a literal would have broken by rejecting every genuine submission. A body already read rejects with a TypeError too -- the spec's `consume body` step 1 says so, and it is what a double formData() produces. That is our bug, not the client's, and the catch was filing it as a rejected submission. request.bodyUsed is the spec'd way to tell them apart, and the test reproduces the double read for real rather than mocking it. The shared rejection text lived in three places and is now one constant in a module with no server-only imports, because the client boundary has to recognise it and could not import a .server file. The smoke test now counts its POSTs and fails in afterAll if it exceeds the shared 10/min budget, rather than leaving the next case to discover it as a 429 somewhere unrelated. It also deletes TESTING from the child env: a developer with it exported would render no from__confirm input and fail a test about a charset. Two comments the previous commits made confidently and wrongly: that TESTING disables the timestamp half the test suite depends on (it sets the empty string, which is falsy, so the crypto path is live), and that /contact and /resources/newsletter have 10/min each (one limiter, one IP key, 10/min shared).
The last strong-path POST bypassed postStrongPath, so the afterAll guard undercounted by one and would have let a future case push the suite over budget without saying so. The finding also suggested counting the GET requests scrapeHoneypotFields makes; those go to the separate generalRateLimit, so they are not on this budget, and the comment now records why rather than leaving the next reader to work it out.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (10)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughContact and newsletter actions now use shared form-data parsing. Contact honeypot validation rejects submissions that omit required fields. Rejected submissions use a shared response value, which the error boundary excludes from Sentry capture and console logging. ChangesForm submission validation
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant Browser
participant ContactAction
participant readFormData
participant checkHoneypot
participant GeneralErrorBoundary
Browser->>ContactAction: POST contact form
ContactAction->>readFormData: readFormData(request)
readFormData-->>ContactAction: FormData
ContactAction->>checkHoneypot: checkHoneypot(formData)
checkHoneypot-->>ContactAction: 400 REJECTED_SUBMISSION if fields are missing
ContactAction-->>GeneralErrorBoundary: route error response
Merge Risk: ⚪ Minimal · up to The change makes unreadable or incomplete public form submissions return a consistent 400 response and keeps them out of error reporting. No concrete merge-blocking risk was identified. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change tightens public form validation without expanding access to email or newsletter operations. Error-report suppression is narrowly matched to expected rejections. Remaining uncertainty concerns runtime error classification and existing external-service failure behavior, rather than a demonstrated new attack path. Retained concerns Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The issues
Four ways a
POSTto/contactor/resources/newslettercould reach Sentry as an unhandled error. All four are reachable by anyone who can send a request to a public form URL, and all four were found in production logs or by review of the code that caused them.1. An unreadable body threw before the action's own code ran
request.formData()is not a parser that can fail softly. undici throws aTypeErrorthe moment theContent-Typeis notmultipart/form-dataorapplication/x-www-form-urlencoded— and it throws it while reading the body, before line 33 of the action executes. This is the error in the report:Any
POSTcarrying a JSON body, no body at all, or an inventedContent-Typebecame an unhandled 500. A body is not required to carry aContent-Type— a barecurl -X POST /contactsends neither — so the simplest scanner request was enough. The timestamp in the report (60s after boot) is a scanner, not a user.Two things made this worse than a 500 should be. It answered 500, which claims the server broke when the request was merely not understood. And it threw ahead of
checkHoneypot, so the 400 path built for exactly this traffic (issue #14) never saw these requests.2. A media-type allowlist alone was not enough
The obvious fix — check
Content-Type, reject otherwise — is necessary but not sufficient.multipart/form-datacarries its boundary as a header parameter, andformData()rejects when it is missing. Verified against the runtime rather than assumed:So a request declaring a media type we accept can still be unreadable. Enumerating the headers that produce a parse failure is not a fix; it is a list of today's, and the Fetch spec only promises a
TypeErrorfor "fails for some reason".3. The honeypot could be skipped entirely
remix-utils'shouldCheckHoneypotreturns false for a form carrying neither honeypot field, andcheckthen returns without throwing. That is the right default for a library — a route with no honeypot should not be forced to carry one. But every form on these two routes rendersHoneypotInputsunconditionally, so a form without the fields cannot have come from our page, and silently accepting it meant a bot skipped the check entirely by leaving two fields out. No crypto, no forged token, no knowledge of what the fields are called.4. The fix for all of the above was itself broken
Worth calling out separately, because it is the reason this PR is more than two small guards. A
Responsethrown from an action becomes anErrorResponseto React Router exactly as a crash does, andGeneralErrorBoundarycalledcaptureExceptionunconditionally. So every rejection added by issues 1–3 was reported from the browser — withreplaysOnErrorSampleRate: 1.0, attaching a session replay to whoever submitted. The newsletter form is in the footer of every page, so one stray POST against it filed an issue and a replay for an innocent visitor. The rejections also replaced the whole page with a bare error, taking the user's typed message with it.The claim "junk traffic is cheap and silent" was false in both halves. The first two commits in this branch asserted it; review disproved it.
The solutions
app/utils/form-data.server.ts(new) — read the body, or answer 400Two rejections, because there are two ways a body can be unreadable:
TypeErrorfromformData()is answered 400 whatever the reason. This closes issue 2 without enumerating headers, and it holds for reasons nobody has thought of yet.Narrowed to
TypeErroron purpose. A body already read or locked rejects with aTypeErrortoo — the spec'sconsume bodystep 1 says so, and it is what a doubleformData()produces — but that is our bug, not the client's, and converting it would file a server fault as a rejected submission and hide it permanently.request.bodyUsedis the spec'd way to tell them apart. The test reproduces the double read for real rather than mocking it.The cross-realm
Symbol.toStringTagread matchesisMalformedHoneypotFieldErrorin the same change — two ways of asking the same question in one branch is how the wrong one gets copied.app/utils/honeypot.server.ts— stop letting a form be exemptshouldCheckHoneypotisprotected, which is the library marking it an extension point, so overriding it is the supported way to be strict. Verified that this gets the sameSpamErrorthrough the library's own "Missing honeypot input" branch:Field names are read off the base class rather than hardcoded.
getInputProps()is public and returns both, and the getters are reachable from a subclass. This also keeps the requirement true underrandomizeNameFieldName, which a hardcodedname__confirmwould have broken by rejecting every genuine submission.app/utils/rejected-submission.ts(new) — one answer, in one placeFour things can refuse a submission: an unreadable body, a filled honeypot, an undecryptable token, and omitted fields. All answer with the same 400, deliberately — reporting which check failed hands a scanner a map of what the endpoint validates. It is a separate module with no server-only imports because the client boundary has to recognise it, and a
.serverimport there would pullnode:cryptointo the browser bundle.app/components/error-boundary.tsx— stop reporting an answer as a faultisRejectedSubmissionrecognises the guards' own response socaptureExceptionis not called for it. Narrowed on the status and the shared body, because either alone is wrong:sentry-event-filters.tsalready drops server-sideThe test that pins the 500 case caught this: my first version matched on body only and failed it.
The honeypot is a speed bump, not authentication
Stating this plainly so nobody inherits the false impression that "the fields are now required" closes the spam story. A bot that GETs
/contact, copies both fields, and posts them back passes everything here — the token is in the HTML of every page, and it has no TTL. The compensating control is the 10/min/IP rate limit, which is real but per-IP and trivially distributed. The highest-value follow-up is a max-age onfrom__confirm; noted, not done here.Verification
npm run validate(lint, typecheck, format) and 232 tests pass. Beyond the suites, six behaviours checked against a running production build:Content-Typemultipart/form-datawithout boundary; charset=UTF-8Every rejected-submission path is silent server-side (no unhandled error reports), and
isRejectedSubmissionis unit-tested from both directions so a real 500 cannot be silenced.Notes for review
/contactand/resources/newslettershare oneexpress-rate-limitinstance keyed by IP, so the budget is 10/min total, not 10 each. Adding an end-to-end case there will 429 an unrelated test. That is why the boundary matrix lives intests/form-data.test.ts(no budget cost), and why the suite now counts its own POSTs and fails inafterAllwith a message about the budget — verified to actually fire.rethrows a non-DOMException unchangedpassednew FormData()and reached its mock only because of the bypass. It now posts a well-formed form, which is what it was always meant to test.TESTINGdisables the timestamp half the suite depends on (it sets the empty string, falsy, so the crypto path is live), and that the two paths have 10/min each.Summary by CodeRabbit