Skip to content

Commit 241b366

Browse files
authored
Merge pull request #247 from Ayush7614/fix/226-opaque-internal-errors
fix(node): opaque AppError::Internal and AppError::Db HTTP bodies (#226)
2 parents 425ebf4 + af821e0 commit 241b366

3 files changed

Lines changed: 125 additions & 7 deletions

File tree

‎crates/gitlawb-node/src/api/events.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,8 @@ mod ref_updates_feed_tests {
14401440

14411441
// A DB error in the gate fails closed as 500, not swallowed into an empty 200 (the
14421442
// regression the old get_repo().ok().flatten() allowed). Inject by dropping a
1443-
// column get_repo selects so its query errors.
1443+
// column get_repo selects so its query errors. Also pin the anonymous body to
1444+
// the exact opaque object (#226).
14441445
#[sqlx::test]
14451446
async fn repo_events_db_error_fails_closed_500(pool: PgPool) {
14461447
let state = test_state(pool.clone()).await;
@@ -1463,6 +1464,18 @@ mod ref_updates_feed_tests {
14631464
StatusCode::INTERNAL_SERVER_ERROR,
14641465
"a DB error must fail closed (500), never serve an empty 200"
14651466
);
1467+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1468+
.await
1469+
.expect("read body");
1470+
let body = String::from_utf8_lossy(&body);
1471+
let v: serde_json::Value = serde_json::from_str(&body).expect("json body");
1472+
assert_eq!(
1473+
v,
1474+
serde_json::json!({
1475+
"error": "db_error",
1476+
"message": crate::error::DB_ERROR_MESSAGE,
1477+
})
1478+
);
14661479
}
14671480

14681481
// Symmetric to the gate DB-error test: a DB error in the CERT fetch (after the gate

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6945,6 +6945,8 @@ mod peer_authority_tests {
69456945
/// | `prune_non_public_peers` (db/mod.rs) | a delete keyed on a computed bad-DID array; cannot repoint; boot-only caller in main.rs |
69466946
/// | `seed_local_peer` (sync.rs) | excluded by test-module location: a deliberate `upsert_peer` bypass for `file://` fixtures, which the public-URL gate rejects |
69476947
/// | `a_legacy_row_can_still_refresh_its_liveness` (db/mod.rs) | test-only. Seeds a PRE-GATE row by raw SQL on purpose: `upsert_peer` cannot create one, since the gate it is testing refuses exactly that DID. The fixture models what a deployed table already holds |
6948+
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (main.rs) | test-only. Seeds a peer row by raw SQL so the gossip ping round can probe readiness hysteresis without going through `upsert_peer` |
6949+
/// | `manual_ping_uses_readiness_without_mutating_federation_gate` (api/peers.rs) | test-only. Seeds a peer row by raw SQL so the manual ping route can assert readiness probing without mutating federation gate state |
69486950
///
69496951
/// And the `upsert_peer` CALL-SITE authority table, which the ledger above
69506952
/// structurally cannot hold, because the bootstrap site issues no SQL of its own
@@ -7030,6 +7032,14 @@ mod peers_table_writer_guard {
70307032
/// listed function that no longer has one.
70317033
const LEDGER: &[(&str, usize)] = &[
70327034
("a_legacy_row_can_still_refresh_its_liveness", 1),
7035+
(
7036+
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
7037+
1,
7038+
),
7039+
(
7040+
"manual_ping_uses_readiness_without_mutating_federation_gate",
7041+
1,
7042+
),
70337043
("mark_peer_ping", 1),
70347044
("prune_non_public_peers", 1),
70357045
("prune_self_peers", 1),

‎crates/gitlawb-node/src/error.rs‎

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ pub enum AppError {
7272
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
7373
pub 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.
7785
fn 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

Comments
 (0)