Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ pub(crate) async fn authorize_repo_read(
.get_repo(owner, name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// A quarantined mirror (admitted by the iCaptcha propagation gate but not
// validated) is hidden from every reader — serve/clone and fork alike — as if
// it did not exist, until an operator releases it. Checked before the
// visibility gate so its existence is never disclosed.
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, path) == Decision::Deny
{
Expand Down
58 changes: 56 additions & 2 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ pub async fn create_repo(
}

// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;
let verified_proof = proof.consume(&state.db).await?;

let disk_path = state
.repo_store
Expand All @@ -230,6 +230,14 @@ pub async fn create_repo(

state.db.create_repo(&record).await?;

// Persist the proof so it can travel with the repo and a mirroring peer can
// re-verify it (enforce-mode origins only; off/shadow yield no proof here).
if let Some(p) = verified_proof {
if let Err(e) = p.record_for_repo(&state.db, &record.id).await {
tracing::warn!(repo = %req.name, err = %e, "failed to record iCaptcha proof for repo");
}
}

tracing::info!(repo = %req.name, owner = %owner_did, "created repository");

let resp = to_response(&record, &state, 0);
Expand Down Expand Up @@ -489,6 +497,12 @@ pub async fn git_info_refs(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;

// A quarantined mirror is served to no one (clone or push advertisement) —
// hidden as repo-not-found until an operator releases it.
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}
Comment on lines +500 to +504

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'pub async fn git_receive_pack|is_repo_quarantined|git-receive-pack' crates/gitlawb-node/src/api/repos.rs crates/gitlawb-node/src/server.rs

Repository: Gitlawb/node

Length of output: 1055


🏁 Script executed:

sed -n '540,840p' crates/gitlawb-node/src/api/repos.rs

Repository: Gitlawb/node

Length of output: 11740


🏁 Script executed:

sed -n '150,220p' crates/gitlawb-node/src/server.rs

Repository: Gitlawb/node

Length of output: 2953


🏁 Script executed:

sed -n '556,576p' crates/gitlawb-node/src/api/repos.rs

Repository: Gitlawb/node

Length of output: 1017


Add the quarantine gate to git_receive_pack. The direct /{owner}/{repo}/git-receive-pack POST route still lacks an is_repo_quarantined check, so quarantined mirrors can accept pushes even though they’re hidden from clone/fetch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 500 - 504, The
`git_receive_pack` POST route is missing the quarantine gate that already exists
in the clone/fetch path, so quarantined mirrors can still accept pushes. Add the
same `state.db.is_repo_quarantined(&record.id).await?` check in
`git_receive_pack` before any receive-pack handling, and return
`AppError::RepoNotFound` for quarantined repos just like the existing repository
lookup flow in `repos.rs`.


let service = query
.service
.ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?;
Expand Down Expand Up @@ -550,6 +564,11 @@ pub async fn git_upload_pack(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;

// A quarantined mirror is never served for clone/fetch.
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}

let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
Expand Down Expand Up @@ -781,6 +800,12 @@ pub async fn git_receive_pack(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;

// A quarantined mirror is hidden from every git endpoint, push included —
// it must not accept writes while withheld from clone/fetch.
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}

// Parse ref updates from pkt-line body before handing to git
let ref_updates = parse_ref_updates(&body);
tracing::debug!(
Expand Down Expand Up @@ -1457,7 +1482,7 @@ pub async fn fork_repo(
}

// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;
let verified_proof = proof.consume(&state.db).await?;

// Ensure source repo is on local disk (downloads from Tigris on cache miss)
let source_path = state
Expand Down Expand Up @@ -1509,11 +1534,40 @@ pub async fn fork_repo(

state.db.create_repo(&record).await?;

// Persist the proof so the fork carries it when it propagates to peers.
if let Some(p) = verified_proof {
if let Err(e) = p.record_for_repo(&state.db, &record.id).await {
tracing::warn!(fork = %fork_name, err = %e, "failed to record iCaptcha proof for fork");
}
}

tracing::info!(fork = %fork_name, source = %source.name, forker = %forker_did, "forked repository");

Ok((StatusCode::CREATED, Json(to_response(&record, &state, 0))))
}

/// GET /api/v1/repos/{owner}/{repo}/icaptcha-proof
///
/// Returns the iCaptcha proof token this repo was created with (`null` if none).
/// A peer mirroring this repo fetches it and re-verifies it offline before
/// admitting the mirror (see [`crate::icaptcha::admit_mirror`]). Not owner-gated,
/// but gated on whole-repo `"/"` read like the other replication endpoints, so a
/// private repo's proof is never disclosed.
pub async fn get_icaptcha_proof(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let proof = state.db.get_repo_proof_token(&record.id).await?;
Comment on lines +1549 to +1564

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Don’t expose an unbound bearer proof for mirror admission.

This returns the raw creation proof to any whole-repo reader, while mirror admission validates the token against the owner binding only. For public repos, any reader can reuse that token from another origin or spend it first on a target node. Bind the propagated proof to repo identity/content or make it peer/target-specific before accepting it for admission.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 1543 - 1558, The
get_icaptcha_proof endpoint is returning a raw repo creation proof that can be
reused by any whole-repo reader, so mirror admission should not rely on this
unbound token. Update get_icaptcha_proof and the downstream admission flow in
crate::icaptcha::admit_mirror so the propagated proof is bound to repo
identity/content or made peer/target-specific before acceptance, and ensure the
returned value cannot be replayed across origins.

Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"proof": proof,
})))
}

// ── Pkt-line parsing ──────────────────────────────────────────────────────

struct RefUpdate {
Expand Down
Loading
Loading