Skip to content

fix(webapp): catch loader/action throws before Remix serializes them#3664

Open
d-cs wants to merge 5 commits into
mainfrom
sanitize-loader-action-leaks
Open

fix(webapp): catch loader/action throws before Remix serializes them#3664
d-cs wants to merge 5 commits into
mainfrom
sanitize-loader-action-leaks

Conversation

@d-cs
Copy link
Copy Markdown
Collaborator

@d-cs d-cs commented May 18, 2026

Summary

Companion to #3536, which patched routes that already had a leaking catch (e) { return json({error: e.message}, 500) }. That pattern can't reach routes which have no catch in the first place — when those throw, Remix's default error path serializes error.message into the response body, and the SDK then wraps the leaked string as TriggerApiError.

Across 28 raw api.v1 loaders/actions plus one dashboard polling endpoint, each handler now:

  • Wraps its body in try { ... } catch (error) { ... }.
  • Re-throws Response instances so auth helpers' throw json(...) / throw redirect(...) pass through unchanged.
  • Logs non-Response errors via logger.error so server-side visibility is preserved.
  • Returns a generic body — {"error": "Internal Server Error"} 500 for raw API routes, or { changelogs: [] } 200 for the polling widget (degrade silently across transient blips; the consumer hook already coped with empty payloads).

For six routes where #3536 left an inner try/catch covering only a service call (alertChannels, batches.results, deployments.finalize, deployments.background-workers, deployments.promote, projects.background-workers): an outer try/catch is added so auth/parsing failures are also sanitized. Inner typed-error handling (ServiceValidationError → 422 with message, etc.) is preserved exactly.

For two routes whose existing catch returned 400 + error.message (api.v1.authorization-code, api.v1.orgs.\$orgParam.projects action): the body is sanitized to a generic per-route string. Status code stays 400 — clients that key on the 4xx/5xx distinction (and the SDK's no-retry-on-4xx behavior) are unaffected.

Test plan

  • `pnpm run typecheck --filter webapp`
  • Per-route synthetic-throw probe: inject `throw new Error("SYNTHETIC ...")` at the top of each catch'd try, curl the route with a dummy bearer, confirm the response body is the generic shape and that the synthetic message lands server-side via `logger.error`. 29 routes verified.
  • Real-P1001 probe on the envvars loader: `docker stop database` mid-flight, confirm response is generic 500 (not the leaked Prisma message).
  • Sampled legitimate 4xx/2xx paths across each pattern variant (naked-wrap, partial-expanded, 400-preserved) to confirm the wraps don't interfere with normal control flow.

d-cs added 3 commits May 18, 2026 18:39
Two webapp routes left their loader/action bodies uncaught. When the
underlying call (Prisma, etc.) threw, Remix's default error path
serialized `error.message` into the 500 response body, surfacing
implementation detail to API consumers — and via the SDK, to users.

This complements the earlier sweep over `catch (e) { return json({error:
e.message}, 500) }` shapes; that fix could not reach routes which had no
catch in the first place.

Each handler now wraps its body in try/catch, re-throws `Response`
instances so auth helpers' `throw json(...)` / `throw redirect(...)`
pass through unchanged, logs non-Response errors, and returns a generic
body. The polling changelogs widget returns `{ changelogs: [] }` 200
instead of a 500 — degrading silently across a transient blip is better
UX for a 60s-cadence widget, and the leak risk is identical (neither
shape carries the error message).

Covers:
- apps/webapp/app/routes/api.v1.projects.\$projectRef.envvars.\$slug.\$name.ts (loader + action)
- apps/webapp/app/routes/resources.platform-changelogs.tsx (loader)
…leaks

Earlier passes covered routes with leaking `catch (e) { return json({error:
e.message}, 500) }` shapes, and two specific naked routes. This sweep
covers the rest of the API surface that doesn't go through
`createLoaderApiRoute`/`createActionApiRoute` — 26 files across deploy,
projects, orgs, deployments, auth-jwt, artifacts, and alert-channel routes.

Each handler now wraps its body in try/catch, re-throws `Response`
instances so auth helpers' `throw json(...)` / `throw redirect(...)`
pass through unchanged, logs non-Response errors via `logger.error`, and
returns a generic `{ error: "Internal Server Error" }` 500.

Routes that already had an inner try/catch covering a service call but
with auth/parsing outside the try (alertChannels, batches.results,
deployments.finalize, several others) get an outer try/catch added so the
inner typed-error handling is preserved. The api.v1.authorization-code.ts
catch branch was returning `error.message` verbatim — switched to a generic
body.

Each route verified locally with the established synthetic-throw probe:
inject `throw new Error("SYNTHETIC ...")` at the top of the catch'd try,
curl with a dummy bearer, confirm the response body is the generic shape
and that the synthetic message lands server-side via `logger.error`.
Sampled legitimate 4xx paths (no-auth 401s, auth-helper Response throws,
happy 200 returns) across each pattern variant to confirm the wrap does
not interfere with normal control flow.
…e leak

The previous sweep collapsed two existing 400 branches to 500 along with
the leak-sanitization. Keep the 400 status — the inner branches were
catching errors that callers had been informed of via 400, and clients
may depend on that. Just replace the leaky `error.message` body with a
generic per-route message.

- api.v1.orgs.$orgParam.projects.ts (createProject failure): 500 → 400
  with `"Failed to create project"`.
- api.v1.authorization-code.ts (instanceof Error branch): 500 → 400 with
  `"Failed to create authorization code"`.

Both branches probed locally: synthetic failure forces the path and the
response is the documented 400 + generic body.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 18, 2026

⚠️ No Changeset found

Latest commit: dbca4f2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

Review Change Stack

Walkthrough

This PR applies a systematic error-handling pattern across approximately 30 API routes in the webapp. Each route's loader or action handler is wrapped in try/catch to prevent unexpected errors from propagating into Remix's default 500 serialization. Response errors are rethrown unchanged to preserve auth helper behavior (throw json(), throw redirect()), while other errors are logged server-side and replaced with generic { error: "Internal Server Error" } responses. The changes target deployment, project, organization, environment, background worker, and resource routes, plus targeted fixes such as stopping api.v1.authorization-code.ts from returning raw error messages. Documentation files describe the sweep strategy and affected endpoints.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding try/catch wrappers to loader/action routes to prevent Remix from serializing error messages.
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.
Description check ✅ Passed PR description is comprehensive and well-structured, covering objectives, implementation details, test plan, and edge cases across 29 routes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sanitize-loader-action-leaks

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 and usage tips.

@d-cs d-cs self-assigned this May 18, 2026
coderabbitai[bot]

This comment was marked as resolved.

@d-cs d-cs force-pushed the sanitize-loader-action-leaks branch from 6336713 to 7529ea8 Compare May 18, 2026 18:46
coderabbitai[bot]

This comment was marked as resolved.

@d-cs d-cs marked this pull request as ready for review May 18, 2026 19:19

Each handler now wraps its body in try/catch, re-throws `Response` instances so auth helpers' `throw json(...)` / `throw redirect(...)` pass through unchanged, logs non-Response errors, and returns `{ error: "Internal Server Error" }` 500. For routes that already had an inner try/catch covering a service call but with auth/parsing outside the try (alertChannels, batches.results, deployments.finalize, several others), an outer try/catch is added so the inner typed-error handling is preserved. The `api.v1.authorization-code.ts` catch branch was returning `error.message` verbatim — switched to a generic body.

Each route was verified locally with a synthetic-throw probe: inject `throw new Error("SYNTHETIC ...")` at the top of the catch'd try, curl the route with a dummy bearer token, confirm the response body is the generic shape and that the synthetic message is captured server-side via `logger.error`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The .server-changes files should be more like a high-level 1 or 2 sentence summary of the change. Imagine that it will eventually become a single item in the list of changes in a changelog. For example, this one should just be something like "Expands better API error messages across additional API endpoints" or something like that.

type: fix
---

Wrap two loaders/actions that previously let thrown errors propagate to Remix's default 500 serializer, which writes `error.message` into the response body. When the underlying call (Prisma, etc.) fails, the raw error string was reaching API consumers — including the SDK, which surfaces it back to users via `TriggerApiError`. Each handler now catches non-Response errors, logs server-side, and returns a generic 500 body. `throw json(...)` / `throw redirect(...)` from auth helpers is re-thrown unchanged.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Definitely no need for two .server-changes files in this pr

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.

2 participants