Compare commits

...
Author SHA1 Message Date
beardthelion 92f75c9c54 fix(node): release quarantined mirror fixtures, bound 404 body read in gl clone (#566) 2026-11-09 21:31:17 -06:00
beardthelion 55242a892d fix(node): gate sibling mutation endpoints on quarantine, reconcile 404 contract in gl clone (#276) 2026-11-09 18:48:02 -06:00
beardthelion 7bdf9be65e Merge remote-tracking branch 'origin/main' into pr-276 2026-11-09 17:48:52 -06:00
Ayush7614 30e68564e1 fix(node): close quarantine bypass on encrypted blobs and visibility routes
Rebuilt per review: this branch now carries ONLY the quarantine fix
(dropped the unrelated task-claim and Windows fetch commits, which live
in #275/#312, and the author-local .cursor symlink). The ipfs.rs and
db-layer halves of the original commit were superseded by main's paged
scan, which already carries per-row quarantine flags; what remains is
routing encrypted-blob and visibility handlers through
authorize_repo_read so a quarantined repo is an opaque 404 there too,
plus the three regression tests for exactly that surface.
2026-08-31 18:17:55 +08:00
11 changed files with 563 additions and 153 deletions

No files matched your search

+10 -31
View File
@@ -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<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
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<Extension<AuthenticatedDid>>,
Path((owner, repo, oid)): Path<(String, String, String)>,
) -> Result<Vec<u8>> {
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<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
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()
+74 -40
View File
@@ -238,49 +238,32 @@ pub async fn close_issue(
headers: axum::http::HeaderMap,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// READ-GATE before any snapshot work or rate charging. The author fallback below
// needs the issue blob, which needs the repo tree, so authorship cannot be established
// without a download; but a caller who cannot even READ the repo must be
// stopped here, cheaply, before any Tigris transfer or extraction happens.
// Without this, any signed non-owner could issue parallel close requests for
// arbitrary issue ids and drive unbounded downloads and blocking extraction
// (a disposable-identity DoS), because the route has no other pre-authorization.
// READ-GATE before any snapshot work or rate charging, through the shared
// authorize_repo_read gate so quarantined repos are opaque here too. The
// author fallback below needs the issue blob, which needs the repo tree, so
// authorship cannot be established without a download; but a caller who
// cannot even READ the repo must be stopped here, cheaply, before any
// Tigris transfer or extraction happens. Without this, any signed non-owner
// could issue parallel close requests for arbitrary issue ids and drive
// unbounded downloads and blocking extraction (a disposable-identity DoS),
// because the route has no other pre-authorization.
//
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
// from a missing one (INV-12 read-denial status contract).
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden
// repo from a missing one (INV-12 read-denial status contract).
//
// mirror-rows-handled: a repo row synced from a peer is stored public and
// carries none of the owner's visibility rules, so for such a row this check
// can only return allow. That is deliberate rather than overlooked, and it is
// the same verdict every other read gate in this API reaches for one. Refusing
// instead would deny the repo's real owner and the issue's real author on any
// node whose only copy of the repo is a synced one, which is an ordinary state
// here, and it would not buy the protection it appears to, because a synced
// row's recorded owner comes from the peer that sent it. The expensive work
// this check guards is bounded ahead of it by the per-IP limiter and the read
// pool slot taken below, and the authoritative owner-or-author decision still
// runs below and again under the write lock.
{
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.0.as_str();
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
Some(caller),
"/",
) == crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
}
// carries none of the owner's visibility rules, so for such a row this gate
// can only return allow. That is deliberate rather than overlooked, and it
// is the same verdict every other read gate in this API reaches for one.
// Refusing instead would deny the repo's real owner and the issue's real
// author on any node whose only copy of the repo is a synced one, which is
// an ordinary state here, and it would not buy the protection it appears
// to, because a synced row's recorded owner comes from the peer that sent
// it. The expensive work this check guards is bounded ahead of it by the
// per-IP limiter and the read pool slot taken below, and the authoritative
// owner-or-author decision still runs below and again under the write lock.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
// policy as the push advertisement. The pre-lock snapshot downloads the
@@ -502,6 +485,15 @@ mod tests {
.await
.expect("get_repo")
.expect("repo exists");
// upsert_mirror_repo seeds the row quarantined, and the read gate now
// hides quarantined rows as opaque 404 before the owner-or-author check
// this test names. Release it so the stranger is refused by the 403, not
// by the quarantine short-circuit.
state
.db
.set_repo_quarantine(&record.id, false)
.await
.expect("release seed quarantine");
// An independent session holds the repo's write lock for the whole call.
let key = crate::git::repo_store::advisory_lock_key_for_test(
@@ -703,6 +695,15 @@ mod tests {
.await
.expect("get_repo")
.expect("seeded repo exists");
// upsert_mirror_repo seeds the admission flag as quarantined, but the
// gate now hides quarantined rows from every caller class. Release it
// so these tests exercise what they name: the owner-or-author check
// after the read gate, not the quarantine short-circuit.
state
.db
.set_repo_quarantine(&record.id, false)
.await
.expect("release seed quarantine");
let _ = owner_did;
let path = state
.repo_store
@@ -728,6 +729,39 @@ mod tests {
path
}
/// Quarantine precedes every caller class: even the owner gets the opaque
/// 404 on a quarantined repo, and the seeded row's quarantined flag is left
/// set deliberately. The released-mirror variants above exercise the
/// owner-or-author path.
#[sqlx::test]
async fn quarantined_repo_close_is_opaque_404(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let owner_did = "did:key:z6MkT1Owner";
state
.db
.upsert_mirror_repo("z6MkT1Owner", "tquar", "/unused", None, true)
.await
.expect("seed repo row");
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
axum::extract::Path((
"z6MkT1Owner".to_string(),
"tquar".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.70:5000".parse().unwrap())),
)
.await;
assert!(
matches!(res, Err(crate::error::AppError::RepoNotFound(_))),
"a quarantined repo must 404 close_issue for every caller: {:?}",
res.ok().map(|_| "unexpected success")
);
}
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
/// owner check above the lock, so this is the arm most likely to have broken,
/// and the deny test alone could not see it.
+4 -10
View File
@@ -44,11 +44,8 @@ pub async fn add_label(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let added = state.db.add_label(&record.id, &label).await?;
@@ -71,11 +68,8 @@ pub async fn remove_label(
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
+44 -7
View File
@@ -177,13 +177,22 @@ mod authz_guard {
"\nasync fn ",
"\npub fn ",
"\nfn ",
"#[cfg(test)]",
]
.iter()
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
.min()
.unwrap_or(rest.len());
rest[..end]
.lines()
// Strip block comments too: a marker surviving only inside /* */ would
// otherwise satisfy a row with the real gate deleted.
let mut body = rest[..end].to_string();
while let Some(open) = body.find("/*") {
match body[open..].find("*/").map(|i| open + i + 2) {
Some(close) => body.replace_range(open..close, ""),
None => break,
}
}
body.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
@@ -209,9 +218,14 @@ mod authz_guard {
// (source, handler, expected gate marker)
let rows: &[(&str, &str, &str)] = &[
// Bucket A — owner-gate (require_repo_owner -> 403)
// Bucket A — owner-gate (require_repo_owner -> 403), now behind
// the shared read gate first so a hidden repo 404s for every
// caller class before the owner check can answer 403.
(pulls, "merge_pr", "authorize_repo_read("),
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "create_webhook", "authorize_repo_read("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "authorize_repo_read("),
(webhooks, "delete_webhook", "require_repo_owner("),
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
// authorize_repo_read 404s a non-reader of a private repo, then
@@ -220,10 +234,15 @@ mod authz_guard {
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
(labels, "add_label", "authorize_repo_read("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "authorize_repo_read("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
// Bucket A' — owner OR author (did_matches against the author),
// also behind the shared read gate first.
(pulls, "close_pr", "authorize_repo_read("),
(pulls, "close_pr", "did_matches("),
(issues, "close_issue", "authorize_repo_read("),
(issues, "close_issue", "did_matches("),
// Bucket B — read-gate (authorize_repo_read)
(pulls, "create_review", "authorize_repo_read("),
@@ -269,21 +288,39 @@ mod authz_guard {
(bounties, "dispute_bounty", "did_matches("),
(replicas, "register_replica", "did_matches("),
(replicas, "unregister_replica", "replica_did = &auth.0"),
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
// PRE-GATED — owner-gated behind the shared read gate; guard both
(protect, "protect_branch", "authorize_repo_read("),
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "authorize_repo_read("),
(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"
);
// Order matters as much as presence: hoisting require_owner above the
// read gate would 403 a non-owner on a hidden repo, an existence
// oracle. Pin quarantine-then-owner on every handler that takes both.
for func in ["set_visibility", "remove_visibility", "list_visibility"] {
let body = fn_body(visibility, func);
let gate = body.find("authorize_repo_read(").expect("gate marker");
let owner = body.find("require_owner(").expect("owner marker");
assert!(
gate < owner,
"visibility::{func} must run authorize_repo_read before require_owner"
);
}
// The CID read surface (#173) enforces its gate inside the shared
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
// the markers must live there (the get_by_cid row above only proves delegation).
+4 -10
View File
@@ -17,11 +17,8 @@ pub async fn protect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
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.as_str()), "/").await?;
// Only the repo owner can protect branches (DID-safe match, shared idiom).
let caller = &auth.0;
@@ -51,11 +48,8 @@ pub async fn unprotect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
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.as_str()), "/").await?;
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
+4 -10
View File
@@ -199,11 +199,8 @@ pub async fn merge_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
// Owner-only merge (N7). Merging writes the served tree, so this is the same
// trust boundary as owner-only push; it subsumes branch protection (a
@@ -274,11 +271,8 @@ pub async fn close_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let pr = state
.db
+16 -32
View File
@@ -85,11 +85,10 @@ pub async fn set_visibility(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<SetVisibilityRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
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<RemoveVisibilityRequest>,
) -> Result<Json<serde_json::Value>> {
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<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
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<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
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);
+4 -10
View File
@@ -33,11 +33,8 @@ pub async fn create_webhook(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<(StatusCode, Json<Webhook>)> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Gate the target through the same hardened public-host validator the peer
@@ -118,11 +115,8 @@ pub async fn delete_webhook(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Verify the webhook belongs to this repo
+12 -1
View File
@@ -578,7 +578,18 @@ async fn replicate_encrypted_blobs(
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate");
let resp = match client.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
_ => return,
// A peer that predates the replicate route answers 404: expected and
// quiet. Anything else is the route existing and refusing or failing,
// which should be observable rather than read as route-absent.
Ok(r) if r.status().as_u16() == 404 => return,
Ok(r) => {
tracing::warn!(repo = %repo, status = %r.status(), "encrypted-blobs replicate refused; skipping");
return;
}
Err(e) => {
tracing::warn!(repo = %repo, error = %e, "encrypted-blobs replicate request failed; skipping");
return;
}
};
let parsed: ReplicateResponse = match resp.json().await {
Ok(p) => p,
+360
View File
@@ -18723,6 +18723,366 @@ 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_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
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"
);
}
// A signed non-owner must get 404 too, never 403: this leg is what pins
// the quarantine-before-owner ordering. If require_owner were hoisted
// above authorize_repo_read the same caller would read Forbidden, an
// existence oracle on a hidden repo.
let stranger = "did:key:zSTRANGERQUARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
for (method, path, body) in [
(Method::GET, withheld.as_str(), Body::empty()),
(Method::GET, vis.as_str(), Body::empty()),
(
Method::PUT,
vis.as_str(),
Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#),
),
(
Method::DELETE,
vis.as_str(),
Body::from(r#"{"path_glob":"/secret/**"}"#),
),
] {
let handler: axum::routing::MethodRouter<crate::state::AppState> = match method.clone()
{
Method::GET => axum::routing::get(crate::api::visibility::list_visibility),
Method::PUT => axum::routing::put(crate::api::visibility::set_visibility),
_ => axum::routing::delete(crate::api::visibility::remove_visibility),
};
let router = if path == withheld.as_str() {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(crate::api::visibility::withheld_paths),
)
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::optional_signature,
))
.with_state(state.clone())
} else {
Router::new()
.route("/api/v1/repos/{owner}/{repo}/visibility", handler)
.with_state(state.clone())
};
let resp = router
.oneshot(signed_request_as(stranger, method.clone(), path, body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"non-owner {method} {path} must 404 (not 403) 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);
+31 -2
View File
@@ -267,7 +267,31 @@ async fn fetch_withheld(node: &str, owner: &str, name: &str) -> Result<(Vec<Stri
};
let resp = match resp {
Ok(r) if r.status().is_success() => r,
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => {
// Two 404s share this status and must be told apart before the
// legacy empty-glob fallback is taken: a route miss on a node that
// predates withheld-paths has an empty body, while a current node
// returns the JSON AppError body for absent/denied/quarantined
// repos. The JSON body proves the route exists, so the repo is
// unavailable to this caller and cloning cannot proceed.
if r.status().as_u16() == 404 {
let status = r.status();
let body = crate::http::read_body_capped(r, 4096).await;
if body.read_failed {
// The read died part-way: we cannot classify the denial,
// and a failed read must not collapse into the legacy
// route-miss arm and start a clone.
bail!("withheld-paths 404 body from this node could not be read");
}
if !body.text.trim().is_empty() {
bail!(
"repository {owner}/{name} is not available on this node ({status}): {}",
sanitize_node_msg(&body.text)
);
}
}
return Ok((Vec::new(), Vec::new()));
}
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
Err(err) => return Err(err).context("fetching withheld paths"),
};
@@ -327,7 +351,12 @@ async fn recover_encrypted_blobs(
.await
{
Ok(r) if r.status().is_success() => r,
_ => return Ok(vec![]),
// Discovery failure is not an empty inventory: a denied, quarantined,
// erroring, or unreachable node must surface so the caller can warn
// and still run the Arweave fallback rather than report a clean clone
// with authorized blobs silently missing.
Ok(r) => bail!("encrypted-blobs inventory unavailable: {}", r.status()),
Err(err) => return Err(err).context("fetching encrypted-blobs inventory"),
};
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
if body.blobs.is_empty() {