Compare commits

..
Author SHA1 Message Date
beardthelionandAyush7614 d15f194f7f fix(node): pin no-redirect and bound the body read on the iCaptcha public-key fetch (#574)
* fix(node): pin no-redirect and bound the body read on the iCaptcha public-key fetch

The node fetches the iCaptcha public key that anchors its proof gate, but built
its client with reqwest's default redirect policy and trusted `.json()` to
buffer the response. A redirect from the configured ICAPTCHA_URL could move the
trust anchor, and an oversized body was buffered without bound.

- Build the key client with `redirect::Policy::none()`, matching every other
  outbound client the node runs.
- Read the response body through a capped chunk loop (8 KiB) and parse from the
  received bytes instead of buffering unboundedly.
- Add mockito tests proving a valid JWKS still parses, a 302 to a host serving
  a real key is NOT followed, and an oversized body is refused.

Closes #507

* test(node): make iCaptcha oversized-body test use a valid padded JWKS

coderabbit flagged that the size-limit test fed fetch_key a body of
repeated x bytes, which is invalid JSON: even without a byte cap the
parse would return None, so the test passed regardless of the limit.
Pad jwks_body() with JSON whitespace past KEY_MAX_BODY_BYTES so an
unbounded read would still parse successfully, making the assertion
actually exercise the cap.

* chore(ci): re-trigger PR checks on the flaky test (beta) run

* fix(node): close the anchor paths the redirect pin left open, pin the boundary

- fetch_key refuses any non-2xx status before parsing the body: under
  Policy::none() a 3xx surfaces rather than being followed, but its own
  body could still carry a parseable JWKS and move the anchor anyway
- read_key_body accepts a body of exactly the cap (the loop now probes
  for end-of-body instead of exiting at the limit); over-cap is still
  refused, matching the documented "exceeds" contract
- the no-redirect test now points Location at the mocked /v1/pubkey so a
  redirect-following client would return a real key, making the pin
  load-bearing, and asserts the target is never contacted
- new pins: at-cap JWKS parses, a non-2xx with a valid JWKS body is
  refused, an unreachable endpoint is fail-safe None

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-10 20:38:29 +00:00
beardthelionandcarlitos973 f67688eae2 fix(gl): resolve webhook owner from caller identity, not node DID (#568)
* fix(gl): resolve webhook owner from caller identity, not node DID

resolve_owner() in webhook.rs unconditionally fetched the node's own
root DID via GET / and used it as the repo owner, ignoring both an
explicit owner/repo argument and the caller's own loaded identity.
This made gl webhook create/list/delete effectively unusable for any
repo not owned by the node operator itself -- every call resolved to
the wrong owner and 404d.

cert.rs's resolve_repo already has the correct pattern: check for an
explicit slash first, then prefer the caller's own keypair, falling
back to the node DID only if no local identity is available. This
applies that same pattern to webhook create/list/delete.

Verified against a live self-hosted node: webhook create/list/delete
all resolve the correct owner now. Existing webhook test suite passes
unchanged (8/8).

* fix(gl): print fully-qualified owner/repo in webhook create delete hint

Per CodeRabbit review on #159: the resolve_owner destructuring shadowed
repo with the bare name, so the printed delete hint lost the owner
qualification -- only correct by coincidence if the caller runs it
under the same identity used at create time. Print owner/repo
explicitly so the hint always works regardless of default identity.

* fix(gl): thread identity resolution and status checking through webhook list

Apply the same fix already applied to create/delete to the list
subcommand, per review feedback on this PR:

- gl webhook list now accepts --dir and resolves the caller identity
  the same way create/delete do, instead of always falling back to
  the node root DID for bare repo names
- cmd_list checks the HTTP status before parsing, so a non-2xx error
  (e.g. repo not found, not authorized) surfaces as an error instead
  of silently rendering as an empty webhook list
- list output now prints the fully-qualified owner/repo, matching the
  fix already applied to the create/delete hints

Also tightens resolve_owners doc comment: it falls back to the node
DID on any keypair-load failure, not only when no keypair exists.

* fix(gl): thread caller-identity repo resolution through MCP webhook tools (#159)

* test(gl): pin unsigned webhook_list node-denial fallback on MCP (#568)

---------

Co-authored-by: carlitos973 <[email protected]>
2026-11-10 18:53:03 +00:00
beardthelionandJaime Alfaro Salazar af6a759330 test(node): pin task list limit ceiling and default on the cursor handler (#572)
* Fix #399: GET /api/v1/tasks: clamp limit to match siblings; negative limit returns 500 with raw DB error

* test(api): test exact limit boundaries for negative and i64::MAX limit requests

* test(node): pin task list limit ceiling and default on the cursor handler

Port the #405 regression coverage onto the current cursor-paginated
handler: i64::MAX clamps to the 200 ceiling and an omitted limit falls
back to the 50-row default. Negative-limit behavior is already pinned
by negative_limit_returns_empty.

---------

Co-authored-by: Jaime Alfaro Salazar <[email protected]>
2026-11-10 17:12:41 +00:00
beardthelionandAyush7614 66fe646320 fix(node): close quarantine bypass on encrypted blobs, visibility routes, and sibling mutations (#566)
* 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.

* fix(node): gate sibling mutation endpoints on quarantine, reconcile 404 contract in gl clone (#276)

* fix(node): release quarantined mirror fixtures, bound 404 body read in gl clone (#566)

* test(node): pin read-before-owner order on every dual-gated handler (#566)

* test(node): pin list_webhooks in the order loop, close-owner second assert, tighter release legs (#566)

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-10 15:29:23 +00:00
13 changed files with 811 additions and 154 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()
+75 -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,40 @@ mod tests {
path
}
/// The read gate blocks the owner (and any caller) before the
/// owner-or-author checks: 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?;
+71 -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,66 @@ 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 the owner/author check
// above the read gate would 403 a non-owner on a hidden repo, an
// existence oracle. Pin read-gate-then-owner on every handler that
// takes both; the owner marker differs per bucket (require_repo_owner
// for owner-only, require_owner for visibility, did_matches for the
// owner-or-author and protect paths).
for (src, func, owner_marker) in [
(visibility, "set_visibility", "require_owner("),
(visibility, "remove_visibility", "require_owner("),
(visibility, "list_visibility", "require_owner("),
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "list_webhooks", "require_repo_owner("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
(pulls, "close_pr", "did_matches("),
(issues, "close_issue", "did_matches("),
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "did_matches("),
] {
let body = fn_body(src, func);
let gate = body.find("authorize_repo_read(").expect("gate marker");
let owner = body.find(owner_marker).expect("owner marker");
assert!(
gate < owner,
"{func} must run authorize_repo_read before {owner_marker}"
);
// The owner-or-author and protect handlers also carry
// require_repo_owner: a hoist of that call above the read gate is
// the same 403-vs-404 oracle, so pin it too where it exists.
if func == "close_pr" || func == "close_issue" {
let owner_gate = body.find("require_repo_owner(").expect("owner marker");
assert!(
gate < owner_gate,
"{func} must run authorize_repo_read before require_repo_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
+54
View File
@@ -1296,6 +1296,60 @@ mod visible_tasks_tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// An over-ceiling limit caps at MAX_VISIBLE_TASKS rather than scanning or
/// serializing the whole table (#405, adapted to the cursor handler).
#[sqlx::test]
async fn limit_ceiling_caps_at_200(pool: PgPool) {
let state = test_state(pool).await;
for i in 0..201 {
state
.db
.create_task(&task(&format!("ceil-{i}"), None, DELEGATOR))
.await
.unwrap();
}
let resp = list_router(state)
.oneshot(signed_request_as(
DELEGATOR,
Method::GET,
"/api/v1/tasks?limit=9223372036854775807",
Body::empty(),
))
.await
.unwrap();
let body = body_json(resp).await;
assert_eq!(
body["count"], 200,
"i64::MAX must clamp to the 200 ceiling, not scan the table"
);
}
/// An omitted limit falls back to the 50-row default (#405).
#[sqlx::test]
async fn omitted_limit_uses_default_50(pool: PgPool) {
let state = test_state(pool).await;
for i in 0..60 {
state
.db
.create_task(&task(&format!("def-{i}"), None, DELEGATOR))
.await
.unwrap();
}
let resp = list_router(state)
.oneshot(signed_request_as(
DELEGATOR,
Method::GET,
"/api/v1/tasks",
Body::empty(),
))
.await
.unwrap();
let body = body_json(resp).await;
assert_eq!(body["count"], 50, "omitted limit must default to 50");
}
/// A negative limit must clamp to zero through `collect_visible_tasks`,
/// not fall through to the visible set.
#[sqlx::test]
+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
+166 -1
View File
@@ -126,15 +126,55 @@ fn decode_key(b64url: &str) -> Option<VerifyingKey> {
VerifyingKey::from_bytes(&arr).ok()
}
/// Upper bound on the iCaptcha `/v1/pubkey` response body. The document is a
/// handful of keys at most; anything larger is refused rather than buffered
/// into memory (the unbounded `.json()` read this replaces, #507).
const KEY_MAX_BODY_BYTES: usize = 8 * 1024;
/// Read at most [`KEY_MAX_BODY_BYTES`] bytes of a key response. Returns `None`
/// when the body exceeds the cap, so an oversized document is treated as absent
/// exactly like a network error or a timeout (fail safe, never a partial key).
async fn read_key_body(mut resp: reqwest::Response) -> Option<Vec<u8>> {
let mut buf = Vec::with_capacity(1024);
loop {
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = (KEY_MAX_BODY_BYTES - buf.len()).min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
return None; // exceeded the cap mid-chunk
}
}
Ok(None) => return Some(buf),
Err(_) => return None,
}
}
}
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
// Bounded request: a hung /v1/pubkey must never block node startup. On
// timeout/error we return None and the gate stays inert (fail safe).
//
// No redirects and a capped body read: the key this fetch produces is the
// TRUST ANCHOR for the whole proof gate, so a 3xx must not be allowed to move
// where it comes from (anchor substitution / SSRF, matching the node's other
// outbound clients — `build_http_client` in main.rs, the sync worker), and
// the body is bounded rather than handed to `.json()`'s unbounded buffering.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.ok()?;
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
let resp = client.get(&endpoint).send().await.ok()?;
// A non-2xx response body is not a key: with Policy::none() a 3xx is
// surfaced rather than followed, but its own body must not be parsed
// either, or the redirect would move the anchor after all.
if !resp.status().is_success() {
return None;
}
let body = read_key_body(resp).await?;
let jwks: Jwks = serde_json::from_slice(&body).ok()?;
decode_key(&jwks.keys.first()?.x)
}
@@ -647,4 +687,129 @@ mod tests {
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
}
/// `{"keys":[{"x":"<base64url ed25519 point>"}]}` for the live `PUBKEY_X`.
fn jwks_body() -> Vec<u8> {
format!(r#"{{"keys":[{{"x":"{PUBKEY_X}"}}]}}"#).into_bytes()
}
/// The no-redirect / bounded-body client still parses a valid JWKS, so the
/// hardening did not break the happy path the gate depends on at startup.
#[tokio::test]
async fn fetch_key_parses_a_valid_jwks() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
let key = fetch_key(&server.url()).await.expect("valid JWKS parses");
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
}
/// A 302 must not move the trust anchor. The redirect target serves a REAL
/// key, so following the redirect would make `fetch_key` adopt it; instead
/// `Policy::none()` leaves the 302's (non-JWKS) body as the answer → None.
#[tokio::test]
async fn fetch_key_does_not_follow_a_redirect() {
let mut target = mockito::Server::new_async().await;
let _t = target
.mock("GET", "/v1/pubkey")
.expect(0)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
let mut redirector = mockito::Server::new_async().await;
// Location points at the target's /v1/pubkey, so a client that DID
// follow the redirect would fetch the real key and return Some: the
// assertion below only holds while Policy::none() is in place.
let _r = redirector
.mock("GET", "/v1/pubkey")
.with_status(302)
.with_header("Location", &format!("{}/v1/pubkey", target.url()))
.create_async()
.await;
assert!(
fetch_key(&redirector.url()).await.is_none(),
"a redirect must not be followed to a host serving a valid key"
);
}
/// A body past the cap is refused rather than buffered, so a broken
/// endpoint cannot make the node materialize an arbitrarily large document.
/// The body is a VALID JWKS (`jwks_body()`) padded with JSON whitespace past
/// the cap, so an unbounded read would still parse it; this test only passes
/// because the byte cap refuses it.
#[tokio::test]
async fn fetch_key_rejects_an_oversized_body() {
let mut server = mockito::Server::new_async().await;
let mut oversized = jwks_body();
oversized.resize(KEY_MAX_BODY_BYTES + 1, b' ');
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(oversized)
.create_async()
.await;
assert!(
fetch_key(&server.url()).await.is_none(),
"an oversized body must be refused even when it is a valid JWKS"
);
}
/// A body of exactly the cap still parses: the contract refuses bodies that
/// EXCEED it, not bodies that reach it (the padded JWKS is exactly
/// KEY_MAX_BODY_BYTES bytes of otherwise-valid JSON).
#[tokio::test]
async fn fetch_key_accepts_a_body_at_the_cap() {
let mut server = mockito::Server::new_async().await;
let mut at_cap = jwks_body();
at_cap.resize(KEY_MAX_BODY_BYTES, b' ');
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(at_cap)
.create_async()
.await;
let key = fetch_key(&server.url()).await.expect("at-cap JWKS parses");
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
}
/// A non-2xx response never supplies the anchor, even when its body is a
/// perfectly valid JWKS: the redirect pin only stops the hop, and the
/// status gate is what stops the 3xx's own body from being adopted.
#[tokio::test]
async fn fetch_key_rejects_a_non_success_status() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(500)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
assert!(
fetch_key(&server.url()).await.is_none(),
"a non-2xx body must not be adopted as the anchor even when it parses"
);
}
/// An unreachable endpoint is the same fail-safe None as every other fetch
/// error: the gate stays inert rather than the node failing to start.
#[tokio::test]
async fn fetch_key_returns_none_when_the_endpoint_is_unreachable() {
assert!(fetch_key("http://127.0.0.1:1").await.is_none());
}
}
+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!(
resp.status() == StatusCode::OK || resp.status().is_server_error(),
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
resp.status()
);
}
/// 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!(
resp.status() == StatusCode::OK || resp.status().is_server_error(),
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
resp.status()
);
}
/// `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() {