fix(web): collapse backend/transport errors to a generic message #478

Merged
nrupard merged 1 commit from fix/BUNYIP-477-simpler-error-messages into main 2026-08-05 21:28:49 +02:00
Owner

What

ApiError::user_message() (bunyip-web) now returns a generic line for a transport / JSON-decode failure (status 0) and for any 5xx, instead of echoing the raw message. 4xx still passes through its API-authored, user-safe copy; 429 keeps its humanized retry line. All 62 call sites that render user_message() are covered by the single chokepoint, including the unauthenticated login page during an outage.

Why (BUNYIP-477)

The reported leak surfaced during a boot abort (BUNYIP-457, since fixed). Rather than chase the boot/proxy layer with no incident artifact, the directive was simpler error messages that do not reveal internal detail. A sweep of every user-facing error-message surface found the request path is almost entirely clean already; the one browser-facing exception was user_message() echoing a transport error's raw text (which carries the internal API URL http://bunyip-api:4401) and, defensively, any 5xx body.

Sweep (invariant: no user-facing error message reveals internal detail)

Surface Verdict Reason
dunite AppError::error_response() JSON body: Internal / Database / Upstream / NotFound compliant fixed generic client message; caller-supplied text discarded; meta.request_id present; locked by test_internal_error_hides_details
From<sqlx::Error> compliant collapses to "A database error occurred"; raw SQL only to tracing::error!
API AppError::{validation,conflict,bad_request} sites (echo caller text) compliant across bunyip they carry the caller's own input (an IP, an action name, a seed-template name) or a validation string, never schema / SQL / migration detail
OIDC OidcInvalid* descriptions N/A RFC 6749 §5.2 diagnostics returned to OAuth clients (developer/machine audience), not end-user browser pages
health / status / /e2e-bootstrapped compliant minimal hardcoded payloads
bunyip-web user_message() for transport (status 0) / 5xx violating -> fixed here echoed the internal API URL / raw backend text to the browser, incl. the unauthenticated login page
actix JsonConfig / Path / Query extractor defaults N/A (acceptable) field-level parse text to direct API callers only (not browser-facing: the BFF reads only error.message, absent in actix's default body); reveals API shape, not internal infrastructure. Flag if you want it hardened too - I will file + link an issue.

Test

server_and_transport_errors_never_echo_internals: a 500 body carrying relation "app.users" violates constraint ... and a status-0 transport error carrying http://bunyip-api:4401/... both collapse to generic text asserted to contain none of that detail. just check-container green (fmt + clippy + 183 web tests + full workspace).

#BUNYIP-477

## What `ApiError::user_message()` (bunyip-web) now returns a generic line for a transport / JSON-decode failure (`status 0`) and for any 5xx, instead of echoing the raw message. 4xx still passes through its API-authored, user-safe copy; 429 keeps its humanized retry line. All 62 call sites that render `user_message()` are covered by the single chokepoint, including the **unauthenticated login page** during an outage. ## Why (BUNYIP-477) The reported leak surfaced during a boot abort (BUNYIP-457, since fixed). Rather than chase the boot/proxy layer with no incident artifact, the directive was simpler error messages that do not reveal internal detail. A sweep of every user-facing error-message surface found the request path is almost entirely clean already; the one browser-facing exception was `user_message()` echoing a transport error's raw text (which carries the internal API URL `http://bunyip-api:4401`) and, defensively, any 5xx body. ## Sweep (invariant: no user-facing error message reveals internal detail) | Surface | Verdict | Reason | |---|---|---| | dunite `AppError::error_response()` JSON body: `Internal` / `Database` / `Upstream` / `NotFound` | compliant | fixed generic client message; caller-supplied text discarded; `meta.request_id` present; locked by `test_internal_error_hides_details` | | `From<sqlx::Error>` | compliant | collapses to `"A database error occurred"`; raw SQL only to `tracing::error!` | | API `AppError::{validation,conflict,bad_request}` sites (echo caller text) | compliant | across bunyip they carry the caller's own input (an IP, an action name, a seed-template name) or a validation string, never schema / SQL / migration detail | | OIDC `OidcInvalid*` descriptions | N/A | RFC 6749 §5.2 diagnostics returned to OAuth clients (developer/machine audience), not end-user browser pages | | health / status / `/e2e-bootstrapped` | compliant | minimal hardcoded payloads | | **bunyip-web `user_message()` for transport (status 0) / 5xx** | **violating -> fixed here** | echoed the internal API URL / raw backend text to the browser, incl. the unauthenticated login page | | actix `JsonConfig` / `Path` / `Query` extractor defaults | N/A (acceptable) | field-level parse text to **direct** API callers only (not browser-facing: the BFF reads only `error.message`, absent in actix's default body); reveals API shape, not internal infrastructure. Flag if you want it hardened too - I will file + link an issue. | ## Test `server_and_transport_errors_never_echo_internals`: a 500 body carrying `relation "app.users" violates constraint ...` and a status-0 transport error carrying `http://bunyip-api:4401/...` both collapse to generic text asserted to contain none of that detail. `just check-container` green (fmt + clippy + 183 web tests + full workspace). #BUNYIP-477
fix(web): collapse backend/transport errors to a generic message
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 14m40s
Create release / Create release from merged PR (pull_request) Has been skipped
190534fcd8
`ApiError::user_message()` now returns a generic line for a transport or JSON-decode failure (status 0) and for any 5xx, instead of echoing the raw message. A transport error's raw text carries the internal API URL (host + port) and a 5xx body can carry a raw backend string, and both were rendered verbatim by the 62 call sites that surface user_message() - including the unauthenticated login page during an outage. A 4xx still passes through its API-authored, user-safe copy, and a 429 keeps its humanized retry line.

Regression test: a 500 body carrying a fake `relation ... violates constraint ...` string and a status-0 transport error carrying the internal `http://bunyip-api:4401` URL both collapse to generic text asserted to contain no internal detail.

Scope: the API JSON error body was already generic. dunite-core's AppError discards the caller-supplied message for Internal / Database / Upstream / NotFound and only the validation / conflict / bad-request variants echo caller text, which across bunyip is the caller's own input (an IP, an action name), never schema or SQL. This change targets the one browser-facing surface that still echoed internals.

#BUNYIP-477
nrupard scheduled this pull request to auto merge when all checks succeed 2026-08-05 21:24:52 +02:00
nrupard deleted branch fix/BUNYIP-477-simpler-error-messages 2026-08-05 21:28:49 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!478
No description provided.