diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 39ecda2e..6a43cb58 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -9,28 +9,47 @@ //! `git cat-file ` (i.e. without the git framing header). //! This is consistent with how `gitlawb_core::cid::Cid::from_git_object_bytes` //! computes CIDs when objects are pushed. +//! +//! Serving is access-controlled: an object is returned only from a repo row the +//! requesting caller is permitted to read (per-caller path-scoped visibility, +//! see `get_by_cid`). use axum::{ extract::{Path, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, response::{IntoResponse, Response}, - Json, + Extension, Json, }; use cid::CidGeneric; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::git::store; +use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids}; use crate::state::AppState; +use crate::visibility::{visibility_check, Decision}; /// GET /ipfs/{cid} /// /// Search all repos on the node for a git object whose SHA-256 hash matches -/// the given CIDv1. Returns the raw object content bytes with appropriate -/// headers if found, or 404 if not found. +/// the given CIDv1, returning its raw content if the caller may read it. +/// +/// Visibility (#110): the object is served only from a repo row the caller +/// passes. For each iterated row we gate against that row's OWN rules +/// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` +/// — `get_repo`'s fuzzy match could otherwise authorize a different physical +/// row than the one read (KTD2a). When the row carries path-scoped rules, a +/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and +/// genuine not-found both fall through to an opaque 404. +/// +/// Scope: this closes the direct unauthenticated scan. A stale-public mirror +/// row still serves withheld content (tracked separately, #124). pub async fn get_by_cid( Path(cid_str): Path, State(state): State, + auth: Option>, ) -> Result { // 1. Decode the CID and extract the SHA-256 digest let cid = CidGeneric::<64>::from_str(&cid_str) @@ -46,6 +65,8 @@ pub async fn get_by_cid( } let sha256_hex = hex::encode(mh.digest()); + let caller = auth.as_ref().map(|e| e.0 .0.as_str()); + let caller_owned = caller.map(|c| c.to_string()); // 2. Search all repos for an object with this SHA-256 let repos = state @@ -54,12 +75,74 @@ pub async fn get_by_cid( .await .map_err(AppError::Internal)?; + // Fetch every repo's visibility rules in one query rather than one per row + // (the gate runs each row against its OWN rules — KTD2a). A row absent from + // the map has no rules. + let repo_ids: Vec = repos.iter().map(|r| r.id.clone()).collect(); + let rules_by_repo = state + .db + .list_visibility_rules_for_repos(&repo_ids) + .await + .map_err(AppError::Internal)?; + + // Request-scoped memo of the per-repo withheld set (KTD1). The caller is + // constant for one request, so `repo.id` alone is a safe, sufficient key — + // never a coarse caller "class", which `visibility_check`'s exact full-DID + // reader match would make unsafe. + let mut withheld_memo: HashMap> = HashMap::new(); + for repo in &repos { + // Repo-level read gate against THIS row's own rules (KTD2a). + let rules: &[crate::db::VisibilityRule] = rules_by_repo + .get(&repo.id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if visibility_check(rules, repo.is_public, &repo.owner_did, caller, "/") == Decision::Deny { + continue; + } + let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await { Ok(p) => p, Err(_) => continue, }; + // Per-blob withholding only applies when a path-scoped rule exists (KTD4). + if has_path_scoped_rule(rules) { + if !withheld_memo.contains_key(&repo.id) { + let rp = repo_path.clone(); + let r = rules.to_vec(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + // Full-history walk shells out to git — keep it off the async runtime. + let walk = tokio::task::spawn_blocking(move || { + withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + }) + .await; + // Fail closed on EITHER a task panic (JoinError) or a walk error: + // we cannot prove the caller may read here, so skip this repo and + // let a public copy (if any) serve. Never serve on an unproven gate. + let set = match walk { + Ok(Ok(set)) => set, + Ok(Err(e)) => { + tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo"); + continue; + } + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo"); + continue; + } + }; + withheld_memo.insert(repo.id.clone(), set); + } + if withheld_memo + .get(&repo.id) + .is_some_and(|set| set.contains(&sha256_hex)) + { + continue; + } + } + match store::read_object(&repo_path, &sha256_hex) { Ok(Some((_obj_type, content))) => { // 3. Return the content with IPFS-style headers diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 85335fd4..00eed018 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -166,6 +166,7 @@ mod authz_guard { let profiles = include_str!("profiles.rs"); let repos = include_str!("repos.rs"); let register = include_str!("register.rs"); + let ipfs = include_str!("ipfs.rs"); // (source, handler, expected gate marker) let rows: &[(&str, &str, &str)] = &[ @@ -186,6 +187,9 @@ mod authz_guard { (issues, "create_issue", "authorize_repo_read("), (bounties, "create_bounty", "authorize_repo_read("), (repos, "fork_repo", "authorize_repo_read("), + // get_by_cid gates each iterated repo row directly via visibility_check + // (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve). + (ipfs, "get_by_cid", "visibility_check("), // Bucket C — signer-self: the acting DID is matched/bound to auth.0 (tasks, "create_task", "did_matches("), (tasks, "claim_task", "did_matches("), diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 11a4724d..bc00aad4 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -187,9 +187,14 @@ pub fn build_router(state: AppState) -> Router { ); // ── IPFS content-addressed retrieval and pin listing ────────────────── + // `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller + // identity and can apply per-repo visibility (#110); anonymous callers stay + // anonymous and still read genuinely public content. `/api/v1/ipfs/pins` + // stays unsigned — gating the pin index is tracked separately (#121). let ipfs_routes = Router::new() .route("/ipfs/{cid}", get(ipfs::get_by_cid)) - .route("/api/v1/ipfs/pins", get(ipfs::list_pins)); + .layer(middleware::from_fn(auth::optional_signature)) + .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); // ── Arweave permanent anchors ────────────────────────────────────────── let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 3306c7f3..98fccc56 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1369,4 +1369,477 @@ mod tests { "empty DB yields repos == 0 without error" ); } + + // ---- #110: GET /ipfs/{cid} per-caller visibility gate ---- + + /// Seed a SHA-256 source repo (public/a.txt + secret/b.txt), bare-clone it + /// into each `/tmp//.git` path, and return guards + oids. + /// SHA-256 object format is required: `get_by_cid` resolves a CID whose + /// multihash digest IS the git object id, which only matches in sha256 repos. + struct CidFixture { + _guards: Vec, + secret_oid: String, + public_oid: String, + secret_tree_oid: String, + } + impl Drop for CidFixture { + fn drop(&mut self) { + for p in &self._guards { + let _ = std::fs::remove_dir_all(p); + } + } + } + fn seed_cid_repos(slug: &str, tag: &str, bare_names: &[&str]) -> CidFixture { + use std::process::Command; + let run = |args: &[&str], cwd: &std::path::Path| { + let out = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + let src = std::env::temp_dir().join(format!("gl-cid-src-{tag}")); + let _ = std::fs::remove_dir_all(&src); + std::fs::create_dir_all(src.join("public")).unwrap(); + std::fs::create_dir_all(src.join("secret")).unwrap(); + std::fs::write(src.join("public/a.txt"), b"public bytes\n").unwrap(); + std::fs::write(src.join("secret/b.txt"), b"TOP SECRET\n").unwrap(); + run(&["init", "-q", "--object-format=sha256"], &src); + run(&["config", "user.email", "t@t"], &src); + run(&["config", "user.name", "t"], &src); + run(&["add", "."], &src); + run(&["commit", "-qm", "seed"], &src); + let oid = |rev: &str| { + let out = Command::new("git") + .args(["rev-parse", rev]) + .current_dir(&src) + .output() + .unwrap(); + assert!(out.status.success(), "rev-parse {rev}"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let secret_oid = oid("HEAD:secret/b.txt"); + let public_oid = oid("HEAD:public/a.txt"); + let secret_tree_oid = oid("HEAD:secret"); + let mut guards = vec![src.clone()]; + for name in bare_names { + let bare = std::path::PathBuf::from("/tmp") + .join(slug) + .join(format!("{name}.git")); + let _ = std::fs::remove_dir_all(&bare); + std::fs::create_dir_all(bare.parent().unwrap()).unwrap(); + run( + &[ + "clone", + "--bare", + "-q", + src.to_str().unwrap(), + bare.to_str().unwrap(), + ], + &src, + ); + } + // One guard for the whole /tmp/ tree covers every bare clone. + guards.push(std::path::PathBuf::from("/tmp").join(slug)); + CidFixture { + _guards: guards, + secret_oid, + public_oid, + secret_tree_oid, + } + } + + /// CID whose sha2-256 multihash digest equals the given 64-hex git oid, so + /// `get_by_cid` decodes it back to that oid and `git cat-file`s it. + fn cid_for_oid(oid_hex: &str) -> String { + use gitlawb_core::cid::Cid; + let bytes = hex::decode(oid_hex).expect("hex oid"); + let arr: [u8; 32] = bytes.as_slice().try_into().expect("32-byte sha256 oid"); + Cid::from_sha256_bytes(&arr).to_string() + } + + fn cid_router(state: &AppState) -> Router { + Router::new() + .route( + "/ipfs/{cid}", + axum::routing::get(crate::api::ipfs::get_by_cid), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + } + async fn cid_parts(resp: axum::response::Response) -> (StatusCode, String) { + let st = resp.status(); + let b = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (st, String::from_utf8_lossy(&b).to_string()) + } + fn cid_anon(cid: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(format!("/ipfs/{cid}")) + .body(Body::empty()) + .unwrap() + } + fn cid_signed(kp: &gitlawb_core::identity::Keypair, cid: &str) -> Request { + let path = format!("/ipfs/{cid}"); + let s = gitlawb_core::http_sig::sign_request(kp, "GET", &path, b""); + Request::builder() + .method(Method::GET) + .uri(&path) + .header("content-digest", s.content_digest) + .header("signature-input", s.signature_input) + .header("signature", s.signature) + .body(Body::empty()) + .unwrap() + } + + /// #110: `GET /ipfs/{cid}` must gate a withheld blob by per-caller visibility. + /// RED before U2 (the current handler serves the secret to anon). + #[sqlx::test] + async fn ipfs_cid_gate_withholds_blob_from_unauthorized(pool: PgPool) { + use crate::db::VisibilityMode; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let reader = Keypair::generate(); + let reader_did = reader.did().to_string(); + let stranger = Keypair::generate(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let fx = seed_cid_repos(&slug, &short, &["withhold"]); + let secret_cid = cid_for_oid(&fx.secret_oid); + let tree_cid = cid_for_oid(&fx.secret_tree_oid); + let public_cid = cid_for_oid(&fx.public_oid); + + state + .db + .create_repo(&seed_repo(&owner_did, "withhold")) + .await + .expect("seed repo"); + let rec = state + .db + .get_repo(&owner_did, "withhold") + .await + .unwrap() + .unwrap(); + state + .db + .set_visibility_rule( + &rec.id, + "/secret/**", + VisibilityMode::B, + std::slice::from_ref(&reader_did), + &owner_did, + ) + .await + .expect("deny rule"); + + // anon → withheld blob: must 404, must not leak content. (RED on current handler.) + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "anon must not read the withheld blob" + ); + assert!( + !body.contains("TOP SECRET"), + "404 body must not leak the secret" + ); + + // signed non-reader → 404. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&stranger, &secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "non-reader must not read the withheld blob" + ); + assert!(!body.contains("TOP SECRET")); + + // owner (signed) → 200 + secret bytes. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&owner, &secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!(st, StatusCode::OK, "owner reads the withheld blob"); + assert!(body.contains("TOP SECRET"), "owner gets the content"); + + // listed reader (signed) → 200. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&reader, &secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!(st, StatusCode::OK, "listed reader reads the blob"); + assert!(body.contains("TOP SECRET")); + + // KTD3: anon tree CID under /secret → 200 (trees/commits are not withheld). + let (st, _) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&tree_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!(st, StatusCode::OK, "tree object is served to anon (KTD3)"); + + // R3: public blob anon → 200 (non-withheld content not affected). + let (st, _) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&public_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!(st, StatusCode::OK, "public blob stays served"); + + // R5: a genuine unknown CID also 404, uniform with the withheld 404. + let absent_cid = cid_for_oid(&"ab".repeat(32)); + let (st, _) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&absent_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "absent CID 404 (uniform with withheld)" + ); + + // malformed CID → 400 (unchanged). + let (st, _) = cid_parts( + cid_router(&state) + .oneshot(cid_anon("not-a-cid")) + .await + .unwrap(), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "malformed CID still 400"); + } + + /// R4: the same object withheld in one repo but public in another is still + /// served from the public copy; the withholding repo is iterated first. + #[sqlx::test] + async fn ipfs_cid_served_from_public_copy_when_withheld_elsewhere(pool: PgPool) { + use crate::db::VisibilityMode; + use chrono::Utc; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let fx = seed_cid_repos(&slug, &short, &["withhold", "pubcopy"]); + let secret_cid = cid_for_oid(&fx.secret_oid); + + // Withholding repo, iterated FIRST (later updated_at; list_all_repos is DESC). + let mut withhold = seed_repo(&owner_did, "withhold"); + withhold.updated_at = Utc::now(); + state + .db + .create_repo(&withhold) + .await + .expect("withhold repo"); + state + .db + .set_visibility_rule( + &withhold.id, + "/secret/**", + VisibilityMode::B, + &[], + &owner_did, + ) + .await + .expect("deny rule"); + + // Public copy, no rules, iterated AFTER. + let mut pubcopy = seed_repo(&owner_did, "pubcopy"); + pubcopy.updated_at = Utc::now() - chrono::Duration::seconds(60); + state.db.create_repo(&pubcopy).await.expect("pubcopy repo"); + + // anon: denied at the withholding repo (continue), served from the public copy. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::OK, + "served from the public copy despite the other deny" + ); + assert!( + body.contains("TOP SECRET"), + "the public copy serves the content" + ); + } + + /// Repo-level "/" gate (KTD2a, first continue branch): a fully private repo + /// (is_public=false, no rules) denies anon before any per-blob check; the + /// owner still reads. The path-scoped tests pass the "/" gate and deny at the + /// per-blob stage, so this exercises the coarser repo-level deny separately. + #[sqlx::test] + async fn ipfs_cid_private_repo_denies_anon_at_repo_gate(pool: PgPool) { + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let fx = seed_cid_repos(&slug, &short, &["priv"]); + let blob_cid = cid_for_oid(&fx.public_oid); + + let mut rec = seed_repo(&owner_did, "priv"); + rec.is_public = false; + state.db.create_repo(&rec).await.expect("private repo"); + + // anon → repo-level deny → 404, no content leaked. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&blob_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "anon denied at a private repo's / gate" + ); + assert!(!body.contains("public bytes"), "404 must not leak content"); + + // owner-signed → 200. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&owner, &blob_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::OK, + "owner reads their private repo's object" + ); + assert!(body.contains("public bytes"), "owner gets the content"); + } + + /// Fail-closed walk-error arm: if `withheld_blob_oids` errors (here, a ref + /// pointing at a non-tree-ish blob, which `git ls-tree -r` cannot traverse — + /// the same induction as `visibility_pack::fails_closed_when_a_ref_cannot_be_traversed`), + /// the handler skips the whole repo rather than serving. Asserts no leak of the + /// withheld blob AND that even the *public* blob in that repo is withheld — the + /// latter distinguishes fail-closed-skip from normal per-blob withholding and + /// would serve 200 if the error arm wrongly proceeded. + #[sqlx::test] + async fn ipfs_cid_walk_error_fails_closed(pool: PgPool) { + use crate::db::VisibilityMode; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let fx = seed_cid_repos(&slug, &short, &["withhold"]); + let secret_cid = cid_for_oid(&fx.secret_oid); + let public_cid = cid_for_oid(&fx.public_oid); + + // Force the withheld walk to fail closed: a ref pointing at a blob (not + // tree-ish) makes `git ls-tree -r` error, which `withheld_blob_oids` + // propagates as Err → the handler's `Ok(Err)` arm skips the repo. + let bare = std::path::PathBuf::from("/tmp") + .join(&slug) + .join("withhold.git"); + std::fs::write( + bare.join("refs/heads/blobref"), + format!("{}\n", fx.secret_oid), + ) + .unwrap(); + + state + .db + .create_repo(&seed_repo(&owner_did, "withhold")) + .await + .expect("seed repo"); + let rec = state + .db + .get_repo(&owner_did, "withhold") + .await + .unwrap() + .unwrap(); + state + .db + .set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did) + .await + .expect("deny rule"); + + // Withheld secret CID under a walk error → 404, no leak. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&secret_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "walk error must not serve the withheld blob" + ); + assert!( + !body.contains("TOP SECRET"), + "walk-error 404 must not leak the secret" + ); + + // The PUBLIC blob in the same repo is also 404: the walk error fails closed + // by skipping the whole repo, not by serving. Without the fail-closed arm + // this would serve 200, so this assertion is the load-bearing discriminator. + let (st, _) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&public_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "walk error fails closed: repo skipped, even the public blob is not served" + ); + } }