diff --git a/crates/gitlawb-node/src/api/encrypted.rs b/crates/gitlawb-node/src/api/encrypted.rs index d9fa52a45..5bc2da6f2 100644 --- a/crates/gitlawb-node/src/api/encrypted.rs +++ b/crates/gitlawb-node/src/api/encrypted.rs @@ -6,29 +6,24 @@ use axum::Json; use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::state::AppState; -use crate::visibility::{visibility_check, Decision}; /// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs /// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who /// can read the repo. Not recipient-scoped: recipient identities are not stored, /// so access control here is repo readability and decryption is gated by the /// envelope crypto (only a real recipient can open an envelope). +/// +/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] — +/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak +/// its encrypted blob index. pub async fn list_encrypted_blobs( State(state): State, auth: Option>, Path((owner, repo)): Path<(String, String)>, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; let caller = auth.as_ref().map(|e| e.0 .0.as_str()); - let rules = state.db.list_visibility_rules(&record.id).await?; - if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny - { - return Err(AppError::RepoNotFound(format!("{owner}/{repo}"))); - } + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?; let rows = state.db.list_all_encrypted_blobs(&record.id).await?; let blobs: Vec<_> = rows .into_iter() @@ -45,17 +40,9 @@ pub async fn get_encrypted_blob( auth: Option>, Path((owner, repo, oid)): Path<(String, String, String)>, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; let caller = auth.as_ref().map(|e| e.0 .0.as_str()); - let rules = state.db.list_visibility_rules(&record.id).await?; - if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny - { - return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}"))); - } + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?; let cid = state .db .encrypted_blob_cid(&record.id, &oid) @@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs( auth: Option>, Path((owner, repo)): Path<(String, String)>, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; let caller = auth.as_ref().map(|e| e.0 .0.as_str()); - let rules = state.db.list_visibility_rules(&record.id).await?; - if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny - { - return Err(AppError::RepoNotFound(format!("{owner}/{repo}"))); - } + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?; let rows = state.db.list_all_encrypted_blobs(&record.id).await?; let blobs: Vec<_> = rows .into_iter() diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index df10175a9..0b0338fb1 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -242,13 +242,16 @@ mod authz_guard { // PRE-GATED — already owner-gated, in-scope group; guard the gate itself (protect, "protect_branch", "did_matches("), (protect, "unprotect_branch", "did_matches("), + (visibility, "set_visibility", "authorize_repo_read("), (visibility, "set_visibility", "require_owner("), + (visibility, "remove_visibility", "authorize_repo_read("), (visibility, "remove_visibility", "require_owner("), + (visibility, "list_visibility", "authorize_repo_read("), (visibility, "list_visibility", "require_owner("), ]; - // The visibility rows prove require_owner is CALLED; this proves the helper - // itself does DID-safe matching, not a raw/trailing-segment compare. + // Rows above prove each visibility handler calls authorize_repo_read and + // require_owner; this proves the helper itself does DID-safe matching. assert!( fn_body(visibility, "require_owner").contains("did_matches("), "visibility::require_owner must use did_matches for DID-safe owner matching" diff --git a/crates/gitlawb-node/src/api/visibility.rs b/crates/gitlawb-node/src/api/visibility.rs index b00d769ba..b5a7cdda7 100644 --- a/crates/gitlawb-node/src/api/visibility.rs +++ b/crates/gitlawb-node/src/api/visibility.rs @@ -85,11 +85,10 @@ pub async fn set_visibility( Path((owner, repo)): Path<(String, String)>, Json(req): Json, ) -> Result<(StatusCode, Json)> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; + // Quarantine first (via authorize_repo_read), then owner — same posture as + // list_visibility so a quarantined repo cannot be mutated while reads 404. + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?; require_owner(&record, &auth.0)?; validate_path_glob(&req.path_glob)?; @@ -141,11 +140,8 @@ pub async fn remove_visibility( Path((owner, repo)): Path<(String, String)>, Json(req): Json, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?; require_owner(&record, &auth.0)?; state @@ -171,14 +167,12 @@ pub async fn list_visibility( Extension(auth): Extension, Path((owner, repo)): Path<(String, String)>, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; + // Quarantine first (via authorize_repo_read), then owner — a quarantined + // mirror must be opaque even to a caller matching owner_did. + let (record, rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?; require_owner(&record, &auth.0)?; - let rules = state.db.list_visibility_rules(&record.id).await?; let rules_json: Vec<_> = rules .into_iter() .map(|r| { @@ -205,28 +199,18 @@ pub async fn list_visibility( /// denied one (`reinclude`), so a clean-clone client can sparse-exclude the /// denied subtrees while re-including the allowed nested paths. Unlike /// `list_visibility` this is not owner-gated and never exposes reader_dids. +/// +/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] — +/// same posture as encrypted-blob discovery — so admission and private-subtree +/// layout are not disclosed to anon, owner, or peers. pub async fn withheld_paths( State(state): State, auth: Option>, Path((owner, repo)): Path<(String, String)>, ) -> Result> { - let record = state - .db - .get_repo(&owner, &repo) - .await? - .ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?; - - let rules = state.db.list_visibility_rules(&record.id).await?; let caller = auth.as_ref().map(|e| e.0 .0.as_str()); - - // Whole-repo read gate: a caller who cannot read "/" gets repo-not-found, - // matching the git read endpoints, so this never discloses a private repo's - // existence or its path layout to an unauthorized caller. - if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") - == crate::visibility::Decision::Deny - { - return Err(AppError::RepoNotFound(format!("{owner}/{repo}"))); - } + let (record, rules) = + crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?; let withheld = crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 430c06002..741d3e575 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -14612,6 +14612,310 @@ mod tests { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + /// Public-but-quarantined repos must not expose encrypted blob indexes + /// (discovery or replicate). Previously these handlers used + /// `visibility_check` alone and skipped the quarantine short-circuit in + /// `authorize_repo_read`. + #[sqlx::test] + async fn encrypted_blobs_quarantined_repo_opaque_404(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zENCQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let short = owner.split(':').next_back().unwrap(); + let mut repo = seed_private_repo(owner, "enc-quar"); + repo.is_public = true; + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + state + .db + .record_encrypted_blob(&repo_id, "deadbeef", "bafybeiquarantinedcid", "") + .await + .unwrap(); + state.db.set_repo_quarantine(&repo_id, true).await.unwrap(); + + let router = crate::server::build_router(state.clone()); + for suffix in [ + "encrypted-blobs", + "encrypted-blobs/replicate", + "encrypted-blob/deadbeef", + ] { + let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}"); + let resp = router.clone().oneshot(anon_get(&path)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "quarantined public repo must 404 on {path}" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + !text.contains("bafybeiquarantinedcid") && !text.contains("deadbeef"), + "blob index must not leak on quarantine 404 for {path}: {text}" + ); + } + + // Owner (full did:key and bare key) must also 404 — quarantine is not a + // visibility deny that the owner short-circuit can bypass. + for caller in [owner, short] { + for suffix in [ + "encrypted-blobs", + "encrypted-blobs/replicate", + "encrypted-blob/deadbeef", + ] { + let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}"); + let resp = router + .clone() + .oneshot(signed_request_as(caller, Method::GET, &path, Body::empty())) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "owner form {caller} must not read quarantined {path}" + ); + } + } + + // Control: clear quarantine → all three discovery surfaces admit again. + // `encrypted-blob/{oid}` may 500 without a live IPFS node after the gate + // opens; assert it is not the quarantine 404. + state.db.set_repo_quarantine(&repo_id, false).await.unwrap(); + for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] { + let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}"); + let resp = router.clone().oneshot(anon_get(&path)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}"); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("bafybeiquarantinedcid"), + "released repo must expose blob index on {path}: {text}" + ); + } + let get_path = format!("/api/v1/repos/{short}/enc-quar/encrypted-blob/deadbeef"); + let resp = router.oneshot(anon_get(&get_path)).await.unwrap(); + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "released get must clear the quarantine 404 (may 5xx without IPFS)" + ); + } + + /// Mirror-admission path (`upsert_mirror_repo` + slash-form id) must also + /// opaque-404 encrypted discovery while quarantined. + #[sqlx::test] + async fn encrypted_blobs_quarantined_mirror_admission_opaque_404(pool: PgPool) { + let state = test_state(pool).await; + let short = "z6MkEncMirrorAdmitAAAAAAAAAAAAAAAAAAAA"; + state + .db + .upsert_mirror_repo(short, "enc-mirror", "/tmp/enc-mirror", None, true) + .await + .unwrap(); + let repo_id = format!("{short}/enc-mirror"); + state + .db + .record_encrypted_blob(&repo_id, "aabbccdd", "bafybeimirrorcid", "") + .await + .unwrap(); + + let router = crate::server::build_router(state.clone()); + for suffix in [ + "encrypted-blobs", + "encrypted-blobs/replicate", + "encrypted-blob/aabbccdd", + ] { + let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}"); + let resp = router.clone().oneshot(anon_get(&path)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "anon must 404 quarantined mirror {path}" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + !text.contains("bafybeimirrorcid") && !text.contains("aabbccdd"), + "blob index must not leak on quarantine 404 for {path}: {text}" + ); + + let resp = router + .clone() + .oneshot(signed_request_as(short, Method::GET, &path, Body::empty())) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "bare-key mirror owner must not read quarantined {path}" + ); + } + + state.db.set_repo_quarantine(&repo_id, false).await.unwrap(); + for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] { + let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}"); + let resp = router.clone().oneshot(anon_get(&path)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}"); + } + let get_path = format!("/api/v1/repos/{short}/enc-mirror/encrypted-blob/aabbccdd"); + let resp = router.oneshot(anon_get(&get_path)).await.unwrap(); + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "released get must clear the quarantine 404 (may 5xx without IPFS)" + ); + } + + /// `withheld-paths` and `list_visibility` must share the quarantine gate. + #[sqlx::test] + async fn withheld_paths_and_list_visibility_quarantine_opaque(pool: PgPool) { + use crate::db::VisibilityMode; + + let state = test_state(pool).await; + let owner = "did:key:zVISQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let short = owner.split(':').next_back().unwrap(); + let mut repo = seed_private_repo(owner, "vis-quar"); + repo.is_public = true; + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + state + .db + .set_visibility_rule(&repo_id, "/secret/**", VisibilityMode::B, &[], owner) + .await + .unwrap(); + state.db.set_repo_quarantine(&repo_id, true).await.unwrap(); + + let router = crate::server::build_router(state.clone()); + let withheld = format!("/api/v1/repos/{short}/vis-quar/withheld-paths"); + let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "anon withheld-paths must 404 while quarantined" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + !text.contains("/secret"), + "withheld layout must not leak: {text}" + ); + + for caller in [owner, short] { + let resp = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/withheld-paths", + axum::routing::get(crate::api::visibility::withheld_paths), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + .oneshot(signed_request_as( + caller, + Method::GET, + &withheld, + Body::empty(), + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "owner form {caller} withheld-paths must 404 while quarantined" + ); + + let vis = format!("/api/v1/repos/{short}/vis-quar/visibility"); + let resp = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/visibility", + axum::routing::get(crate::api::visibility::list_visibility), + ) + .with_state(state.clone()) + .oneshot(signed_request_as(caller, Method::GET, &vis, Body::empty())) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "owner form {caller} list_visibility must 404 while quarantined" + ); + + // PUT/DELETE must also opaque-404 while quarantined (no rule mutation). + let put_body = Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#); + let resp = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/visibility", + axum::routing::put(crate::api::visibility::set_visibility), + ) + .with_state(state.clone()) + .oneshot(signed_request_as(caller, Method::PUT, &vis, put_body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "owner form {caller} set_visibility must 404 while quarantined" + ); + + let del_body = Body::from(r#"{"path_glob":"/secret/**"}"#); + let resp = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/visibility", + axum::routing::delete(crate::api::visibility::remove_visibility), + ) + .with_state(state.clone()) + .oneshot(signed_request_as(caller, Method::DELETE, &vis, del_body)) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "owner form {caller} remove_visibility must 404 while quarantined" + ); + } + + state.db.set_repo_quarantine(&repo_id, false).await.unwrap(); + let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("/secret"), + "released withheld-paths must return globs: {text}" + ); + + // Same release control for list_visibility (owner-gated after gate opens). + let vis = format!("/api/v1/repos/{short}/vis-quar/visibility"); + let resp = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/visibility", + axum::routing::get(crate::api::visibility::list_visibility), + ) + .with_state(state.clone()) + .oneshot(signed_request_as(owner, Method::GET, &vis, Body::empty())) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "released list_visibility must admit owner" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("/secret"), + "released list_visibility must return rules: {text}" + ); + } + #[sqlx::test] async fn repo_gate_public_repo_anon_read_admitted(pool: PgPool) { struct DirGuard(std::path::PathBuf);