Skip to content

Commit a4da137

Browse files
committed
fix(node): gate id-keyed bounty mutations on repo read before status checks
submit, approve, cancel, and dispute on /api/v1/bounties/{id}/* looked the bounty up and ran status and participant checks without gating on the bounty's repo read visibility. A signed stranger holding an id got a distinguishable 400/403 on a private-repo bounty where an absent id gets 404, so the routes were an existence and lifecycle oracle. Insert the same authorize_repo_read block get_bounty and claim_bounty already carry, right after the lookup, so a denied caller sees the not-found shape either way. Participant checks are unchanged; on a public repo a stranger still reaches them. Closes #341
1 parent bfc44f9 commit a4da137

2 files changed

Lines changed: 250 additions & 20 deletions

File tree

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

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,22 @@ pub async fn submit_bounty(
285285
Path(id): Path<String>,
286286
Json(req): Json<SubmitBountyRequest>,
287287
) -> Result<Json<BountyRecord>> {
288-
let bounty = state
289-
.db
290-
.get_bounty(&id)
291-
.await?
292-
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
288+
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
289+
290+
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
291+
292+
if crate::api::authorize_repo_read(
293+
&state,
294+
&bounty.repo_owner,
295+
&bounty.repo_name,
296+
Some(auth.0.as_str()),
297+
"/",
298+
)
299+
.await
300+
.is_err()
301+
{
302+
return Err(not_found());
303+
}
293304

294305
if bounty.status != "claimed" {
295306
return Err(AppError::BadRequest(format!(
@@ -325,11 +336,22 @@ pub async fn approve_bounty(
325336
Path(id): Path<String>,
326337
Json(req): Json<ApproveBountyRequest>,
327338
) -> Result<Json<BountyRecord>> {
328-
let bounty = state
329-
.db
330-
.get_bounty(&id)
331-
.await?
332-
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
339+
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
340+
341+
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
342+
343+
if crate::api::authorize_repo_read(
344+
&state,
345+
&bounty.repo_owner,
346+
&bounty.repo_name,
347+
Some(auth.0.as_str()),
348+
"/",
349+
)
350+
.await
351+
.is_err()
352+
{
353+
return Err(not_found());
354+
}
333355

334356
if bounty.status != "submitted" {
335357
return Err(AppError::BadRequest(format!(
@@ -372,11 +394,22 @@ pub async fn cancel_bounty(
372394
Extension(auth): Extension<AuthenticatedDid>,
373395
Path(id): Path<String>,
374396
) -> Result<Json<BountyRecord>> {
375-
let bounty = state
376-
.db
377-
.get_bounty(&id)
378-
.await?
379-
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
397+
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
398+
399+
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
400+
401+
if crate::api::authorize_repo_read(
402+
&state,
403+
&bounty.repo_owner,
404+
&bounty.repo_name,
405+
Some(auth.0.as_str()),
406+
"/",
407+
)
408+
.await
409+
.is_err()
410+
{
411+
return Err(not_found());
412+
}
380413

381414
if bounty.status != "open" {
382415
return Err(AppError::BadRequest(format!(
@@ -407,11 +440,22 @@ pub async fn dispute_bounty(
407440
Extension(auth): Extension<AuthenticatedDid>,
408441
Path(id): Path<String>,
409442
) -> Result<Json<BountyRecord>> {
410-
let bounty = state
411-
.db
412-
.get_bounty(&id)
413-
.await?
414-
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
443+
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
444+
445+
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
446+
447+
if crate::api::authorize_repo_read(
448+
&state,
449+
&bounty.repo_owner,
450+
&bounty.repo_name,
451+
Some(auth.0.as_str()),
452+
"/",
453+
)
454+
.await
455+
.is_err()
456+
{
457+
return Err(not_found());
458+
}
415459

416460
if bounty.status != "claimed" && bounty.status != "submitted" {
417461
return Err(AppError::BadRequest(format!(

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

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14732,6 +14732,192 @@ mod tests {
1473214732
assert!(resp.status().is_success());
1473314733
}
1473414734

14735+
// ── #341: the id-keyed bounty mutations must gate on repo read before the
14736+
// status and participant checks, so a caller holding an opaque id cannot
14737+
// distinguish an existing private-repo bounty from an absent one. The deny
14738+
// shape is 404 with a "not found" body, identical to the absent-id case.
14739+
14740+
fn seed_bounty(
14741+
id: &str,
14742+
owner: &str,
14743+
repo: &str,
14744+
status: &str,
14745+
claimant: Option<&str>,
14746+
) -> crate::db::BountyRecord {
14747+
crate::db::BountyRecord {
14748+
id: id.into(),
14749+
repo_owner: owner.into(),
14750+
repo_name: repo.into(),
14751+
issue_id: None,
14752+
title: "Bounty".into(),
14753+
amount: 100,
14754+
creator_did: owner.into(),
14755+
claimant_did: claimant.map(str::to_string),
14756+
claimant_wallet: None,
14757+
pr_id: None,
14758+
status: status.into(),
14759+
created_at: "2026-01-01T00:00:00Z".into(),
14760+
claimed_at: claimant.map(|_| "2026-01-02T00:00:00Z".into()),
14761+
submitted_at: None,
14762+
completed_at: None,
14763+
deadline_secs: 86400,
14764+
tx_hash: None,
14765+
}
14766+
}
14767+
14768+
fn signed_bounty_post(kp: &Keypair, uri: &str, body: &[u8]) -> Request<Body> {
14769+
let signed = gitlawb_core::http_sig::sign_request(kp, "POST", uri, body);
14770+
Request::builder()
14771+
.method(Method::POST)
14772+
.uri(uri)
14773+
.header("content-type", "application/json")
14774+
.header("content-digest", signed.content_digest)
14775+
.header("signature-input", signed.signature_input)
14776+
.header("signature", signed.signature)
14777+
.body(Body::from(body.to_vec()))
14778+
.unwrap()
14779+
}
14780+
14781+
/// Drives one bounty mutation route as a stranger against a private repo and
14782+
/// asserts the denial is indistinguishable from the absent-id case: same
14783+
/// 404 status, same "not found" body shape, no status or participant detail.
14784+
/// `bounty_status` is whichever lifecycle state reaches the route's
14785+
/// participant check today, so the assertion exercises the deepest leak.
14786+
async fn assert_bounty_mutation_denies_stranger_on_private(
14787+
pool: PgPool,
14788+
route: &str,
14789+
bounty_status: &str,
14790+
body: &[u8],
14791+
) {
14792+
let state = test_state(pool).await;
14793+
let owner = "did:key:zB341PRIVOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
14794+
let claimant = "did:key:zB341PRIVCLAIMANTAAAAAAAAAAAAAAAAAAAAA";
14795+
state
14796+
.db
14797+
.create_repo(&seed_private_repo(owner, "secret-bounty-repo"))
14798+
.await
14799+
.unwrap();
14800+
state
14801+
.db
14802+
.create_bounty(&seed_bounty(
14803+
"b341-held-id",
14804+
owner,
14805+
"secret-bounty-repo",
14806+
bounty_status,
14807+
Some(claimant),
14808+
))
14809+
.await
14810+
.unwrap();
14811+
14812+
let router = crate::server::build_router(state);
14813+
let stranger = Keypair::generate();
14814+
14815+
// Held id: the stranger knows the bounty id but cannot read the repo.
14816+
let held_uri = format!("/api/v1/bounties/b341-held-id/{route}");
14817+
let resp = router
14818+
.clone()
14819+
.oneshot(signed_bounty_post(&stranger, &held_uri, body))
14820+
.await
14821+
.unwrap();
14822+
assert_eq!(
14823+
resp.status(),
14824+
StatusCode::NOT_FOUND,
14825+
"{route} on a held private-repo bounty id must deny as 404, not a \
14826+
distinguishable status/participant error"
14827+
);
14828+
let msg = json_body(resp).await;
14829+
assert!(
14830+
msg["message"].as_str().unwrap_or("").contains("not found"),
14831+
"{route} denial body must be the not-found shape, got {msg}"
14832+
);
14833+
14834+
// Absent id: the control case, already 404 today.
14835+
let absent_uri = format!("/api/v1/bounties/b341-no-such-id/{route}");
14836+
let resp = router
14837+
.oneshot(signed_bounty_post(&stranger, &absent_uri, body))
14838+
.await
14839+
.unwrap();
14840+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14841+
}
14842+
14843+
#[sqlx::test]
14844+
async fn submit_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14845+
assert_bounty_mutation_denies_stranger_on_private(
14846+
pool,
14847+
"submit",
14848+
"claimed",
14849+
br#"{"pr_id":"owner/repo#1"}"#,
14850+
)
14851+
.await;
14852+
}
14853+
14854+
#[sqlx::test]
14855+
async fn approve_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14856+
assert_bounty_mutation_denies_stranger_on_private(pool, "approve", "submitted", b"{}")
14857+
.await;
14858+
}
14859+
14860+
#[sqlx::test]
14861+
async fn cancel_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14862+
assert_bounty_mutation_denies_stranger_on_private(pool, "cancel", "open", b"").await;
14863+
}
14864+
14865+
#[sqlx::test]
14866+
async fn dispute_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14867+
assert_bounty_mutation_denies_stranger_on_private(pool, "dispute", "claimed", b"").await;
14868+
}
14869+
14870+
/// The read gate must admit readable repos: on a PUBLIC repo a stranger
14871+
/// still reaches the participant checks, so the four routes answer 403
14872+
/// rather than the gated 404.
14873+
#[sqlx::test]
14874+
async fn bounty_mutation_gates_still_reach_participant_check_on_public(pool: PgPool) {
14875+
let state = test_state(pool).await;
14876+
let owner = "did:key:zB341PUBOWNERAAAAAAAAAAAAAAAAAAAAAAAAA";
14877+
let claimant = "did:key:zB341PUBCLAIMANTAAAAAAAAAAAAAAAAAAAAAA";
14878+
let mut repo = seed_private_repo(owner, "public-bounty-repo");
14879+
repo.is_public = true;
14880+
state.db.create_repo(&repo).await.unwrap();
14881+
14882+
let cases: [(&str, &str, &[u8]); 4] = [
14883+
("submit", "claimed", br#"{"pr_id":"owner/repo#1"}"#),
14884+
("approve", "submitted", b"{}"),
14885+
("cancel", "open", b""),
14886+
("dispute", "claimed", b""),
14887+
];
14888+
for (i, (_, status, _)) in cases.iter().enumerate() {
14889+
let id = format!("b341-pub-{i}");
14890+
state
14891+
.db
14892+
.create_bounty(&seed_bounty(
14893+
&id,
14894+
owner,
14895+
"public-bounty-repo",
14896+
status,
14897+
Some(claimant),
14898+
))
14899+
.await
14900+
.unwrap();
14901+
}
14902+
14903+
let router = crate::server::build_router(state);
14904+
let stranger = Keypair::generate();
14905+
for (i, (route, _, body)) in cases.iter().enumerate() {
14906+
let id = format!("b341-pub-{i}");
14907+
let uri = format!("/api/v1/bounties/{id}/{route}");
14908+
let resp = router
14909+
.clone()
14910+
.oneshot(signed_bounty_post(&stranger, &uri, body))
14911+
.await
14912+
.unwrap();
14913+
assert_eq!(
14914+
resp.status(),
14915+
StatusCode::FORBIDDEN,
14916+
"{route} on a public repo must still reach the participant check"
14917+
);
14918+
}
14919+
}
14920+
1473514921
#[sqlx::test]
1473614922
async fn list_all_bounties_filters_private_repos_for_anon(pool: PgPool) {
1473714923
let state = test_state(pool).await;

0 commit comments

Comments
 (0)