Skip to content

fix(controllers): stop echoing raw error details to clients in 5xx bodies#140

Merged
CryptoJones merged 1 commit into
masterfrom
fix/dont-leak-error-details-in-5xx
May 19, 2026
Merged

fix(controllers): stop echoing raw error details to clients in 5xx bodies#140
CryptoJones merged 1 commit into
masterfrom
fix/dont-leak-error-details-in-5xx

Conversation

@CryptoJones
Copy link
Copy Markdown
Owner

Closes #139.

Summary

Every controller's try/catch returned res.status(500).json({ message: "Error!", error: String(error) }). The error field surfaced raw Sequelize / pg-driver text to clients — internal column names + lengths from "value too long for type character varying(255)", internal hostnames + IPs from connection errors, table/constraint names from unique-violations, etc. The global error-handler in app/middleware/error-handler.js was already written to suppress this detail, but the per-controller catches intercepted the error before it could reach that path, silently bypassing the policy across all 17 controllers (137 occurrences).

Strip , error: String(error) from every 5xx return. Controllers still log the full error via log.error({ err: error }, …) so operator-side signal is unchanged; only the client-facing body loses the leak. One static-string variant in customercontroller.js (error: "Sequelize Op not available") removed for the same reason.

New tests/unit/controller-error-shape.test.js does a source-level regression pin via test.each() over every controller file. Future copy-paste of the old shape fails CI immediately.

Test plan

  • npm run lint — clean
  • npm test — 515 passed (was 495 + 20 new structural assertions: 1 sanity-floor + 19 per-controller), 15 skipped
  • No public test changed expectations — existing 5xx assertions only matched on status + message, never the removed error field

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

…dies

Every controller's try/catch was returning
`res.status(500).json({ message: "Error!", error: String(error) })`.
The `error` field surfaced raw Sequelize / pg-driver text to clients:
"value too long for type character varying(255)", "connection to
server at \"db\" (10.0.0.5) failed", "duplicate key value violates
unique constraint \"…\"", etc. That's exactly the kind of detail the
global error-handler in app/middleware/error-handler.js was already
written to NOT surface — but the per-controller catches intercepted
the error before it could reach the global path, so the policy was
silently bypassed across all 17 controllers (137 occurrences total).

Strip `, error: String(error)` from every 5xx return. Controllers
still log the full error via `log.error({ err: error }, …)` so the
operator-side signal is unchanged; only the client-facing body
loses the leak. One static-string variant in customercontroller.js
(`error: "Sequelize Op not available"`) is also removed since it
violated the same policy.

New `tests/unit/controller-error-shape.test.js` does a source-level
regression pin via test.each() over every controller, asserting that
no file contains `error: String(error)`, `error: err.message`, or
the static-string variant in a 5xx body. Future copy-paste of the
old pattern fails CI immediately.

No public test changed expectations — the existing tests' 5xx
assertions match only on status code + `message`, never on the
removed `error` field, so the suite passes unchanged at 495 then
+20 new structural assertions = 515.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CryptoJones CryptoJones merged commit ad4983f into master May 19, 2026
3 checks passed
@CryptoJones CryptoJones deleted the fix/dont-leak-error-details-in-5xx branch May 19, 2026 06:33
CryptoJones added a commit that referenced this pull request May 19, 2026
…lback (+ extend regression test to middleware/) (#154)

The `fmt(err)` helper in `app/middleware/validate.js` had the same
`error: String(err)` pattern I scrubbed from all 17 controllers in
#140 — but the regression test from that PR only walked
`app/controllers/`, so the middleware copy of the leak survived.

The fallback branch fires when `schema.safeParse` somehow surfaces
a non-ZodError (defensive — zod normally always wraps). When it
does, the raw stringified error landed in the 400 response body.
A future zod release or a quirky schema mutation could trip it,
leaking internal text the global error-handler policy explicitly
suppresses.

Two changes:

1. validate.js: drop `error: String(err)` from the fallback shape.
   The client gets a generic `{ message: 'Validation failed.' }`;
   the original error is whatever caller logging propagates.
2. tests/unit/controller-error-shape.test.js: scan
   `app/middleware/` too, not just `app/controllers/`. Future drift
   in either directory now fails CI. Also broadens the pattern set
   to catch `String(err)` (singular variable) and `error.message` /
   `err.message`, plus widens the status-code match from 5xx to
   4xx-or-5xx so this validation 400 case is in scope.

Net: 604 tests pass (was 595 + 9 new = 8 per-middleware-file
checks + 1 sanity floor for the middleware directory).

Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CryptoJones added a commit that referenced this pull request May 19, 2026
`tests/unit/controller-error-shape.test.js` scanned for variants of
`error: String(err) / err.message / error.message` to catch the
original 137-occurrence leak that #140 cleaned up. But the
runtime-shipped response body uses `message:` as its key — and a
future contributor who writes `res.status(500).json({ message:
err.message })` would route around the global error-handler's
policy without the test catching the regression.

Extend the regex set to cover the parallel `message:` patterns:
  - `message: String(error)` / `message: String(err)`
  - `message: err.message`
  - `message: error.message`

A grep of `app/` confirms zero existing occurrences — this is a
pure-tightening change. The global error-handler in
`app/middleware/error-handler.js` is the ONE place allowed to echo
err.message (under err.expose === true); the test specifically
scans `app/controllers` and `app/middleware` so the handler's own
file is covered too — its `message = err.message` assignment is
NOT a `message: err.message` object-literal shape, so the regex
doesn't false-positive there.

Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

security(controllers): raw Sequelize/pg error text leaked to clients in 5xx bodies

1 participant