Skip to content

Commit 8453814

Browse files
committed
fix(review): fail closed when the repo lookup errors in list_repo_events (#94)
`get_repo(...).await.ok().flatten()` collapsed a lookup Err into None, so a transient DB failure skipped the read-visibility gate and the handler served a private repo's gossip ref-update events. Propagate with `?` instead: an error now maps to AppError::Internal (500) and fails closed, while a genuine Ok(None) (repo not hosted locally) stays the intentional ungated pass-through. Add a regression test that forces a get_repo error (drop the column its SELECT reads) and asserts 500 with no secret in the body. The injection is self-verifying: it asserts the lookup succeeds pre-drop and errors post-drop, so the test can't pass vacuously if get_repo's projection changes later.
1 parent e2ba8a8 commit 8453814

2 files changed

Lines changed: 107 additions & 2 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ pub async fn list_repo_events(
6060
.unwrap_or(50)
6161
.min(200);
6262

63-
// Look up the repo record once so we can use the full owner DID
64-
let repo_record = state.db.get_repo(&owner, &repo_name).await.ok().flatten();
63+
// Look up the repo record once so we can use the full owner DID.
64+
// #113: propagate a lookup error (fail closed) instead of swallowing it with
65+
// `.ok().flatten()`. Collapsing Err into None would skip the visibility gate
66+
// below and serve a private repo's events. get_repo returns anyhow::Result, so
67+
// `?` maps an error to AppError::Internal (500). Only a genuine Ok(None) (the
68+
// repo is not hosted locally) is the intentional ungated pass-through.
69+
let repo_record = state.db.get_repo(&owner, &repo_name).await?;
6570

6671
// #94: if this node hosts the repo locally, gate on read visibility BEFORE
6772
// serving any events (cert OR gossip). A non-reader of a local private repo

crates/gitlawb-node/src/test_support.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,106 @@ mod tests {
14621462
);
14631463
}
14641464

1465+
/// #113 fail-closed: when the repo lookup ERRORS (not a clean Ok(None)), the
1466+
/// visibility gate must not be skipped. The buggy `.ok().flatten()` collapsed an
1467+
/// Err into None, so a transient DB failure during the lookup dropped the gate
1468+
/// and the handler served the private repo's gossip ref-updates via the
1469+
/// ungated None branch (slug taken from the URL owner segment). We force a
1470+
/// deterministic get_repo error by dropping the column its SELECT reads, then
1471+
/// require the handler to fail closed (500, no secret) instead of 200-with-secret.
1472+
#[sqlx::test]
1473+
async fn list_repo_events_fails_closed_when_repo_lookup_errors(pool: PgPool) {
1474+
use crate::db::ReceivedRefUpdate;
1475+
let owner = "did:key:zEVTERRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1476+
// Caller addresses the repo by the full key part (the slug gossip rows use),
1477+
// so the buggy None-branch fallback slug matches the seeded private update.
1478+
let keypart = owner.split(':').next_back().unwrap();
1479+
let state = test_state(pool.clone()).await;
1480+
1481+
let mut priv_repo = seed_repo(owner, "evt-priv");
1482+
priv_repo.is_public = false;
1483+
state
1484+
.db
1485+
.create_repo(&priv_repo)
1486+
.await
1487+
.expect("seed private repo");
1488+
1489+
state
1490+
.db
1491+
.insert_ref_update(&ReceivedRefUpdate {
1492+
id: uuid::Uuid::new_v4().to_string(),
1493+
node_did: owner.to_string(),
1494+
pusher_did: owner.to_string(),
1495+
repo: format!("{keypart}/evt-priv"),
1496+
ref_name: "refs/heads/embargo-gossip".to_string(),
1497+
old_sha: "0".repeat(40),
1498+
new_sha: "gossipSEKRET".to_string(),
1499+
timestamp: Utc::now().to_rfc3339(),
1500+
cert_id: None,
1501+
received_at: Utc::now().to_rfc3339(),
1502+
from_peer: "peer".to_string(),
1503+
})
1504+
.await
1505+
.expect("seed private gossip update");
1506+
1507+
// Force get_repo's SELECT (which reads machine_id, db/mod.rs) to error,
1508+
// simulating a transient DB failure during the visibility lookup. The repo
1509+
// row and the gossip update both remain present.
1510+
// Precondition: the lookup must succeed before we break it, otherwise the
1511+
// injection proves nothing.
1512+
state
1513+
.db
1514+
.get_repo(keypart, "evt-priv")
1515+
.await
1516+
.expect("pre-drop lookup must succeed")
1517+
.expect("private repo row must be present pre-drop");
1518+
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
1519+
.execute(&pool)
1520+
.await
1521+
.expect("drop column to force a get_repo error");
1522+
// Guard the injection: if a future refactor drops machine_id from get_repo's
1523+
// SELECT, this assertion fails loudly instead of letting the test pass
1524+
// vacuously (get_repo would return Ok and the gate, not the error path,
1525+
// would drive the response).
1526+
assert!(
1527+
state.db.get_repo(keypart, "evt-priv").await.is_err(),
1528+
"dropping machine_id must make get_repo error, else this test no longer exercises the Err path"
1529+
);
1530+
1531+
let router = Router::new()
1532+
.route(
1533+
"/api/v1/repos/{owner}/{repo}/events",
1534+
axum::routing::get(crate::api::events::list_repo_events),
1535+
)
1536+
.with_state(state.clone());
1537+
let resp = router
1538+
.oneshot(
1539+
Request::builder()
1540+
.method(Method::GET)
1541+
.uri(format!("/api/v1/repos/{keypart}/evt-priv/events"))
1542+
.body(Body::empty())
1543+
.unwrap(),
1544+
)
1545+
.await
1546+
.unwrap();
1547+
1548+
// Fail closed: a lookup error must surface as 500, never a 200 that serves
1549+
// the private repo's ref metadata through the ungated branch.
1550+
assert_eq!(
1551+
resp.status(),
1552+
StatusCode::INTERNAL_SERVER_ERROR,
1553+
"a repo-lookup error must fail closed, not skip the gate"
1554+
);
1555+
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1556+
.await
1557+
.unwrap();
1558+
let body = String::from_utf8_lossy(&bytes).to_string();
1559+
assert!(
1560+
!body.contains("gossipSEKRET"),
1561+
"fail-closed response must not leak the gossip secret"
1562+
);
1563+
}
1564+
14651565
/// #94 end-to-end seam: a REAL RFC-9421 signature produced exactly as the gl
14661566
/// client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
14671567
/// empty body) is accepted by the node's actual optional_signature middleware,

0 commit comments

Comments
 (0)