Skip to content

fix(sentry): let an anonymous client error reach the tunnel, under a limit - #3149

Merged
DonKoko merged 4 commits into
mainfrom
fix/sentry-tunnel-public-path
Oct 6, 2026
Merged

DonKoko merged 4 commits into
mainfrom
fix/sentry-tunnel-public-path

Conversation

@DonKoko

@DonKoko DonKoko commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes detail.dev D053, the last of the parked findings that needed code rather
than a decision.

The bug

/api/sentry-tunnel was not in publicPaths, so a browser error that happened
before anyone signed in got answered with a 302 to /login. The browser discards
that response, so those reports were not delayed, they were lost, and nothing
anywhere reported the loss. Pre-login is exactly where an unreported error hurts
most: a failure on the login or SSO path is invisible to us by construction.

Why it is safe to expose now

This finding was parked on purpose. Its tracker entry said it must not ship before
D071, because making the path anonymous while the tunnel's destination was
client-controlled would have converted an authenticated SSRF into an
unauthenticated one. That hardening shipped in PR #2874.

I re-read the route rather than trusting the note. The endpoint chooses nothing:

  • the fetch URL comes from getConfiguredSentryTarget(), which reads server env
  • the envelope's own DSN is only ever compared against it, via envelopeDsnMatches
  • an unconfigured deployment answers 404 rather than falling back to the envelope
  • redirect: "manual", so a 3xx is surfaced rather than chased

So an anonymous caller gains no influence over where the request goes.

The residual, handled here

Public does make this an anonymous POST relay into our own Sentry project, so
sentryTunnelRateLimit bounds it per client address, scoped to that single path
and placed before session() so an over-limit caller does not cost a session
lookup it will not use.

The ceiling is deliberately generous (60/min/IP, matching the calendar feed).
An error storm on a legitimate page is the moment the reports matter most, so the
limiter must not become the thing that drops them. I have documented it as a speed
bump rather than a control: a caller with many addresses still gets through, and a
Cloudflare edge rule remains the hard ceiling, exactly as mobileIpRateLimit
already states for its own bucket.

One change beyond the finding

The path was named in three places, and a mismatch between them fails silently:
the browser posts, gets a redirect, discards it, and no report arrives. Nothing in
a type check or a green suite can see that. So the client's tunnel option, the
auth bypass and the limiter's scope now all read SENTRY_TUNNEL_PATH from
app/utils/constants.ts.

Tests

test/server/sentry-tunnel-exposure.test.ts, 8 cases.

Half Cases
The limiter accepts envelopes to the ceiling then refuses; buckets per address, so one office's error storm cannot silence reports from everywhere else; recovers once the window passes; leaves other routes alone
The contract the bypass exists; the limiter is scoped to the path it exposes; the client posts to the same constant; neither entry carries the literal

The contract half was run against the pre-fix server/index.ts and the right two
went red (the bypass and the limiter scope). The file-text approach follows
test/routes-tests/api+/mobile-auth-contract.test.ts, which pins the mobile
prefix's own publicPaths bypass the same way.

Verification

  • pnpm turbo typecheck --filter=@shelf/webapp, clean
  • eslint clean on the changed files
  • 97/97 across test/server/, server/rate-limit.test.ts,
    test/utils/rate-limit.test.ts and the mobile-auth contract test

After deploy

Worth a one-minute check that it works end to end, since no test can prove the
browser's side: open the app logged out, trigger a client error, and confirm the
event arrives in Sentry. Before this change it would not have.

Summary by CodeRabbit

  • New Features
    • Browser error reports can be sent through the app’s Sentry reporting endpoint without a signed-in session.
    • Reporting requests are limited to 300 per minute per IP address. Requests over the limit receive a rate-limit response, while other app routes remain unaffected.
  • Bug Fixes
    • Oversized reporting envelopes are rejected with an error response; envelopes up to 1 MiB are accepted.

…limit

The tunnel was not in publicPaths, so a browser error that happened before
anyone signed in was answered with a 302 to the login page. The browser
discards that, so those reports were not delayed, they were lost, and nothing
anywhere said so.

Safe to expose because the endpoint chooses nothing. Its destination comes from
the server's configured Sentry project, the envelope's own DSN is only ever
compared against it, an unconfigured deployment answers 404 rather than falling
back, and redirects are never followed. That hardening landed in #2874, which is
the work this finding had to wait for.

Public does make it an anonymous POST relay into our own Sentry project, so
sentryTunnelRateLimit bounds it per client address, scoped to that one path and
placed before session() so an over-limit caller costs nothing it will not use.
The ceiling is deliberately well above normal use: an error storm on a real page
is when the reports matter most, so the limit must not be the thing that drops
them. It is a speed bump rather than a control, since a caller with many
addresses still gets through, and a Cloudflare rule remains the hard ceiling.

The path was named in three places and a mismatch between them fails silently,
so the client's tunnel option, the auth bypass and the limiter's scope now all
read SENTRY_TUNNEL_PATH.

Closes detail.dev D053.
@DonKoko DonKoko added the fix label Oct 6, 2026
@parameterai

parameterai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Risk: No findings

This incremental diff adds two protective bounds to the now-public Sentry tunnel: a 1 MiB bodyLimit middleware (placed before the rate limiter, the correct order) and method-scoping the rate limiter to POST only so GET/HEAD/preflight requests cannot exhaust an address's bucket and get real error envelopes refused. The default ceiling rises from 60 to 300 with a detailed comment explaining the Cloudflare-edge coarseness that makes a tighter limit counterproductive. All three contract invariants—publicPaths bypass, POST-scoped middleware, and client-side constant—are pinned by the updated test file. No security regressions found.

Sentinel reviewed b8cf404 · Review settings

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T07:21:27.777500Z 310cf4d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

🩺 React Doctor — webapp

✅ No new findings on the files changed by this PR.

Run locally with pnpm webapp:doctor for a full scan, or cd apps/webapp && pnpm exec react-doctor . --diff for the same diff-only view.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 310cf4d743

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/webapp/server/index.ts
Comment thread apps/webapp/server/index.ts Outdated
Comment thread apps/webapp/server/rate-limit.ts
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 213d02cd-7811-4cd7-8761-44a8a373f8ae
📥 Commits

Reviewing files that changed from the base of the PR and between b8cf404 and 40b3d51.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 381d8702-8a88-4222-a13f-b37704b01ab6
📥 Commits

Reviewing files that changed from the base of the PR and between 310cf4d and b8cf404.

📒 Files selected for processing (4)
  • apps/webapp/app/utils/constants.ts
  • apps/webapp/server/index.ts
  • apps/webapp/server/rate-limit.ts
  • apps/webapp/test/server/sentry-tunnel-exposure.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.


Walkthrough

The client and server use a shared Sentry tunnel path. The server limits POST envelope size and applies a per-IP rate limit of 300 requests per 60 seconds. The tunnel path is included in the public routes.

Changes

Sentry tunnel request limits

Layer / File(s) Summary
Shared tunnel path and size limit
apps/webapp/app/utils/constants.ts, apps/webapp/app/entry.client.tsx
The constants module exports the tunnel path and a 1 MiB maximum envelope size. The client uses the exported path for Sentry’s tunnel setting.
Public route limits and validation
apps/webapp/server/rate-limit.ts, apps/webapp/server/index.ts, apps/webapp/test/server/sentry-tunnel-exposure.test.ts
The server applies a body-size limit and a per-IP rate limit to tunnel POST requests, then includes the path in its public routes. Tests cover size boundaries, rate limits, IP isolation, reset timing, method and route scope, and shared path usage.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant SentrySDK
  participant WebServer
  participant bodyLimit
  participant sentryTunnelRateLimit
  SentrySDK->>WebServer: POST SENTRY_TUNNEL_PATH with envelope
  WebServer->>bodyLimit: Check envelope size
  alt Envelope exceeds maximum
    bodyLimit-->>SentrySDK: Return 413
  else Envelope within maximum
    bodyLimit->>sentryTunnelRateLimit: Check client IP request count
    alt Rate limit exceeded
      sentryTunnelRateLimit-->>SentrySDK: Return 429 JSON
    else Within rate limit
      sentryTunnelRateLimit-->>WebServer: Allow request
    end
  end
Loading

Merge Risk: ⚪ Minimal · up to b8cf4

The change is mergeable after normal checks. Verify that a logged-out browser error reaches Sentry after deployment.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: it allows anonymous client errors to reach the Sentry tunnel while applying a limit.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 5 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

DonKoko and others added 3 commits October 6, 2026 11:38
…t sends one

Three review findings on exposing the tunnel, all of them real.

The route buffers the whole body, and the path is now reachable without a
session, so an unbounded envelope was an unauthenticated way to make the server
allocate. A per-minute request count cannot help, because one request can be
arbitrarily large. The body is refused at 1 MiB before anything reads it. That
ceiling is far above what this app sends: the browser SDK runs tracing only, no
Replay and no attachments, so an envelope is an error event or a sampled
transaction, measured in kilobytes.

The limiter was mounted with `use`, which counts every method. A GET, HEAD or
CORS preflight could therefore spend an address's budget and get that address's
real reports refused. It is now scoped to POST, the only method that carries an
envelope.

The bucket is also coarser than one caller, and that is load-bearing rather than
incidental. getClientIp trusts the edge header, which behind Cloudflare is the
Cloudflare address, so in production one bucket covers everyone arriving through
the same edge. client-ip.ts calls that acceptable because IP is only a fallback
behind a signed-session key, and this limiter has no session to fall back from.
A tight ceiling would throttle unrelated visitors, so the default moved to 300
and the reasoning is written down beside it: the ceiling says "clearly abnormal
for one edge address", the body bound protects memory, and clipping during a
genuine storm costs duplicates rather than the signal, since Sentry groups by
fingerprint.

The test that claimed one caller cannot silence another said more than the code
delivers, so it now pins what is true, that each trusted address gets its own
bucket, and records what that does not promise in production.
The only function the diff added without a docblock, which the review's docstring
coverage gate picked up.
@DonKoko
DonKoko merged commit e2154af into main Oct 6, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant