@@ -72,6 +72,14 @@ pub enum AppError {
7272pub const DB_UNAVAILABLE_CODE : & str = "db_unavailable" ;
7373pub const DB_UNAVAILABLE_MESSAGE : & str = "database is temporarily unavailable" ;
7474
75+ /// Generic client-facing message for `AppError::Internal`. The real error is
76+ /// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
77+ pub const INTERNAL_ERROR_MESSAGE : & str = "an internal error occurred" ;
78+
79+ /// Generic client-facing message for non-unavailable `AppError::Db`. Query /
80+ /// schema errors stay in logs; the HTTP body must not leak them (#226).
81+ pub const DB_ERROR_MESSAGE : & str = "a database error occurred" ;
82+
7583/// Connection-level sqlx failures that mean the database is unreachable right
7684/// now (retryable, 503), as opposed to server-reported query errors.
7785fn db_unavailable ( e : & sqlx:: Error ) -> bool {
@@ -168,12 +176,29 @@ impl IntoResponse for AppError {
168176 AppError :: Overloaded ( msg) => {
169177 ( StatusCode :: SERVICE_UNAVAILABLE , "overloaded" , msg. clone ( ) )
170178 }
171- AppError :: Db ( e) => ( StatusCode :: INTERNAL_SERVER_ERROR , "db_error" , e. to_string ( ) ) ,
172- AppError :: Internal ( e) => (
173- StatusCode :: INTERNAL_SERVER_ERROR ,
174- "internal_error" ,
175- e. to_string ( ) ,
176- ) ,
179+ // Opaque body + server log: bare `?` on sqlx paths becomes `AppError::Db`
180+ // via `From`, so this arm (not `Internal`) is the common leak for open
181+ // routes like GET /api/v1/repos and GET /api/v1/peers (#226).
182+ AppError :: Db ( e) => {
183+ tracing:: error!( error = %e, "database error" ) ;
184+ (
185+ StatusCode :: INTERNAL_SERVER_ERROR ,
186+ "db_error" ,
187+ DB_ERROR_MESSAGE . into ( ) ,
188+ )
189+ }
190+ // Opaque body: handlers that map with `.map_err(AppError::Internal)`
191+ // (e.g. GET /ipfs/{cid}) land here; other DB failures usually hit `Db`.
192+ // Log `{e:#}` so context-wrapped anyhow chains keep the leaf cause
193+ // (Display alone is only the outermost layer; see api/repos.rs).
194+ AppError :: Internal ( e) => {
195+ tracing:: error!( error = %format!( "{e:#}" ) , "internal error" ) ;
196+ (
197+ StatusCode :: INTERNAL_SERVER_ERROR ,
198+ "internal_error" ,
199+ INTERNAL_ERROR_MESSAGE . into ( ) ,
200+ )
201+ }
177202 } ;
178203
179204 let body = Json ( json ! ( {
@@ -223,4 +248,74 @@ mod tests {
223248 "1"
224249 ) ;
225250 }
251+
252+ /// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
253+ #[ tokio:: test]
254+ async fn internal_error_body_is_opaque ( ) {
255+ use serde_json:: { json, Value } ;
256+
257+ let leak = "error returned from database: relation \" repos\" does not exist" ;
258+ let resp = AppError :: Internal ( anyhow:: anyhow!( "{leak}" ) ) . into_response ( ) ;
259+ assert_eq ! ( resp. status( ) , StatusCode :: INTERNAL_SERVER_ERROR ) ;
260+
261+ let bytes = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX )
262+ . await
263+ . expect ( "read body" ) ;
264+ let v: Value = serde_json:: from_slice ( & bytes) . expect ( "json body" ) ;
265+ // Exact object: a new `detail` field with different sensitive text must
266+ // also fail, not only a repeat of the original error string.
267+ assert_eq ! (
268+ v,
269+ json!( {
270+ "error" : "internal_error" ,
271+ "message" : INTERNAL_ERROR_MESSAGE ,
272+ } )
273+ ) ;
274+ }
275+
276+ /// #226: `AppError::Db` query errors (the common `?` path) must also be opaque.
277+ #[ tokio:: test]
278+ async fn db_error_body_is_opaque ( ) {
279+ use serde_json:: { json, Value } ;
280+
281+ let resp = AppError :: Db ( sqlx:: Error :: Protocol (
282+ "error returned from database: column \" is_public\" does not exist" . into ( ) ,
283+ ) )
284+ . into_response ( ) ;
285+ assert_eq ! ( resp. status( ) , StatusCode :: INTERNAL_SERVER_ERROR ) ;
286+
287+ let bytes = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX )
288+ . await
289+ . expect ( "read body" ) ;
290+ let v: Value = serde_json:: from_slice ( & bytes) . expect ( "json body" ) ;
291+ assert_eq ! (
292+ v,
293+ json!( {
294+ "error" : "db_error" ,
295+ "message" : DB_ERROR_MESSAGE ,
296+ } )
297+ ) ;
298+ }
299+
300+ /// Connection-level failures must stay 503 `db_unavailable`, not collapse
301+ /// into the opaque 500 `db_error` arm if `db_unavailable` loses a variant.
302+ #[ tokio:: test]
303+ async fn db_pool_timeout_stays_503_unavailable ( ) {
304+ use serde_json:: { json, Value } ;
305+
306+ let resp = AppError :: Db ( sqlx:: Error :: PoolTimedOut ) . into_response ( ) ;
307+ assert_eq ! ( resp. status( ) , StatusCode :: SERVICE_UNAVAILABLE ) ;
308+
309+ let bytes = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX )
310+ . await
311+ . expect ( "read body" ) ;
312+ let v: Value = serde_json:: from_slice ( & bytes) . expect ( "json body" ) ;
313+ assert_eq ! (
314+ v,
315+ json!( {
316+ "error" : DB_UNAVAILABLE_CODE ,
317+ "message" : DB_UNAVAILABLE_MESSAGE ,
318+ } )
319+ ) ;
320+ }
226321}
0 commit comments