@@ -85,6 +85,10 @@ impl From<anyhow::Error> for AppError {
8585 }
8686}
8787
88+ /// Generic client-facing message for `AppError::Internal`. The real error is
89+ /// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
90+ pub const INTERNAL_ERROR_MESSAGE : & str = "an internal error occurred" ;
91+
8892impl IntoResponse for AppError {
8993 fn into_response ( self ) -> Response {
9094 // iCaptcha challenges carry structured discovery so clients don't have to
@@ -148,11 +152,16 @@ impl IntoResponse for AppError {
148152 DB_UNAVAILABLE_MESSAGE . into ( ) ,
149153 ) ,
150154 AppError :: Db ( e) => ( StatusCode :: INTERNAL_SERVER_ERROR , "db_error" , e. to_string ( ) ) ,
151- AppError :: Internal ( e) => (
152- StatusCode :: INTERNAL_SERVER_ERROR ,
153- "internal_error" ,
154- e. to_string ( ) ,
155- ) ,
155+ // Opaque body: raw sqlx/anyhow text must not reach unauthenticated
156+ // callers (GET /ipfs/{cid} and siblings map DB failures here) (#226).
157+ AppError :: Internal ( e) => {
158+ tracing:: error!( error = %e, "internal error" ) ;
159+ (
160+ StatusCode :: INTERNAL_SERVER_ERROR ,
161+ "internal_error" ,
162+ INTERNAL_ERROR_MESSAGE . into ( ) ,
163+ )
164+ }
156165 } ;
157166
158167 let body = Json ( json ! ( {
@@ -169,6 +178,7 @@ pub type Result<T> = std::result::Result<T, AppError>;
169178#[ cfg( test) ]
170179mod tests {
171180 use super :: * ;
181+ use serde_json:: Value ;
172182
173183 #[ test]
174184 fn timeout_maps_to_504_distinct_from_git_500 ( ) {
@@ -182,4 +192,28 @@ mod tests {
182192 StatusCode :: INTERNAL_SERVER_ERROR
183193 ) ;
184194 }
195+
196+ /// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
197+ #[ tokio:: test]
198+ async fn internal_error_body_is_opaque ( ) {
199+ let leak = "error returned from database: relation \" repos\" does not exist" ;
200+ let resp = AppError :: Internal ( anyhow:: anyhow!( "{leak}" ) ) . into_response ( ) ;
201+ assert_eq ! ( resp. status( ) , StatusCode :: INTERNAL_SERVER_ERROR ) ;
202+
203+ let bytes = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX )
204+ . await
205+ . expect ( "read body" ) ;
206+ let v: Value = serde_json:: from_slice ( & bytes) . expect ( "json body" ) ;
207+ assert_eq ! ( v[ "error" ] , "internal_error" ) ;
208+ assert_eq ! ( v[ "message" ] , INTERNAL_ERROR_MESSAGE ) ;
209+ let rendered = String :: from_utf8_lossy ( & bytes) ;
210+ assert ! (
211+ !rendered. contains( "relation" ) ,
212+ "DB schema detail leaked into body: {rendered}"
213+ ) ;
214+ assert ! (
215+ !rendered. contains( leak) ,
216+ "raw internal error leaked into body: {rendered}"
217+ ) ;
218+ }
185219}
0 commit comments