From 063a5e689fd07dbaf4bede80ae2b0095e2636bd7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 26 Aug 2026 16:22:29 +0600 Subject: [PATCH 1/7] fix(node): gate /ipfs/pins and /arweave/anchors behind authentication (#134) Add auth rejection to list_pins and list_anchors handlers. The pin index spans the entire node and would expose metadata for every object ever pushed; anonymous callers must not see it. - Require AuthenticatedDid extension in both handlers, return 401 when absent - Add server.rs regression test for anonymous rejection through build_router - Fix closed-pool tests to pass authenticated requests (test 503 path) --- crates/gitlawb-node/src/api/arweave.rs | 9 +++++- crates/gitlawb-node/src/api/ipfs.rs | 11 ++++++- crates/gitlawb-node/src/server.rs | 42 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index ad8f45a73..dc3690507 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -1,7 +1,7 @@ //! GET /api/v1/arweave/anchors — list Arweave ref-update anchors. use axum::{ - extract::{Query, State}, + extract::{Extension, Query, State}, Json, }; use serde::Deserialize; @@ -24,7 +24,13 @@ fn default_limit() -> i64 { pub async fn list_anchors( State(state): State, Query(q): Query, + auth: Option>, ) -> Result> { + if auth.is_none() { + return Err(crate::error::AppError::Unauthorized( + "authentication required for anchor listing".into(), + )); + } let limit = q.limit.min(200); // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). @@ -60,6 +66,7 @@ mod closed_pool_tests { .oneshot( Request::builder() .uri("/api/v1/arweave/anchors") + .extension(crate::auth::AuthenticatedDid("did:key:test".into())) .body(axum::body::Body::empty()) .unwrap(), ) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 92d129803..c2e1db27a 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -2133,7 +2133,15 @@ async fn gate_and_serve( /// Returns all CIDs that have been pinned to the local IPFS node from git /// objects received via push. Each entry includes the git SHA-256 hex, the /// CIDv1 string, and the timestamp when it was pinned. -pub async fn list_pins(State(state): State) -> Result> { +pub async fn list_pins( + State(state): State, + auth: Option>, +) -> Result> { + if auth.is_none() { + return Err(crate::error::AppError::Unauthorized( + "authentication required for pin listing".into(), + )); + } // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). let pins = state.db.list_pinned_cids().await?; @@ -2426,6 +2434,7 @@ mod closed_pool_tests { .oneshot( Request::builder() .uri("/api/v1/ipfs/pins") + .extension(crate::auth::AuthenticatedDid("did:key:test".into())) .body(axum::body::Body::empty()) .unwrap(), ) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index de61fcbe2..330015bae 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -619,3 +619,45 @@ async fn p2p_info(State(state): State) -> Json { None => Json(json!({ "enabled": false })), } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use sqlx::PgPool; + use tower::ServiceExt; + + use crate::test_support::test_state; + + /// Regression: anonymous callers must not see the pin/anchor index (#121, #134). + #[sqlx::test] + async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) { + let state = test_state(pool).await; + let router = build_router(state); + + let pins = Request::builder() + .method("GET") + .uri("/api/v1/ipfs/pins?limit=50") + .body(Body::empty()) + .unwrap(); + let pins_resp = router.clone().oneshot(pins).await.unwrap(); + assert_eq!( + pins_resp.status(), + StatusCode::UNAUTHORIZED, + "anonymous pin listing must be rejected" + ); + + let anchors = Request::builder() + .method("GET") + .uri("/api/v1/arweave/anchors?limit=50") + .body(Body::empty()) + .unwrap(); + let anchors_resp = router.oneshot(anchors).await.unwrap(); + assert_eq!( + anchors_resp.status(), + StatusCode::UNAUTHORIZED, + "anonymous anchors listing must be rejected" + ); + } +} From f70bb0008e2ffed1f12ab8e5706bdf00af25ae3a Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 28 Aug 2026 16:04:51 +0600 Subject: [PATCH 2/7] fix(node): enforce authentication on /api/v1/ipfs/pins and /api/v1/arweave/anchors routes --- crates/gitlawb-node/src/server.rs | 129 +++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 330015bae..4509b0dc4 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -213,25 +213,37 @@ pub fn build_router(state: AppState) -> Router { // `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller // identity and can apply per-repo visibility (#110); anonymous callers stay // anonymous and still read genuinely public content. `/api/v1/ipfs/pins` - // stays unsigned — gating the pin index is tracked separately (#121). - // `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each - // request can drive a full-history git walk, so the per-IP rate limiter is the - // outermost layer (rejects a flood before the walk-admission work), mirroring the - // push/create routers. The extension MUST be attached or rate_limit_by_ip is a - // silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before. + // now carries the same `optional_signature` layer: the handler rejects + // requests without a verified `AuthenticatedDid`, so unsigned callers are + // denied (anonymous enumeration closed; #121). `/ipfs/{cid}` also carries a + // per-IP flood brake: it is anon-reachable and each request can drive a + // full-history git walk, so the per-IP rate limiter is the outermost layer + // (rejects a flood before the walk-admission work), mirroring the + // push/create routers. The extension MUST be attached or rate_limit_by_ip + // is a silent no-op. Axum layers only cover routes added before them, so + // both `/ipfs/{cid}` and `/api/v1/ipfs/pins` are built first and the auth + + // rate-limit layers are applied to the merged result. let ipfs_limiter = rate_limit::IpRateLimiter { limiter: state.ipfs_rate_limiter.clone(), trust: state.push_limiter_trust, }; let ipfs_routes = Router::new() .route("/ipfs/{cid}", get(ipfs::get_by_cid)) + .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))) .layer(middleware::from_fn(auth::optional_signature)) .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) - .layer(axum::Extension(ipfs_limiter)) - .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); + .layer(axum::Extension(ipfs_limiter)); // ── Arweave permanent anchors ────────────────────────────────────────── - let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); + // `list_anchors` rejects callers without a verified `AuthenticatedDid`, so + // unsigned enumeration is denied. The same `optional_signature` layer used + // on the other read surfaces is applied here — there is no anonymous + // anchor-listing path on a signed-build node, and this commit closes that + // gap alongside `/api/v1/ipfs/pins`. + let arweave_routes = + Router::new() + .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) + .layer(middleware::from_fn(auth::optional_signature)); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( @@ -660,4 +672,103 @@ mod tests { "anonymous anchors listing must be rejected" ); } + + /// Regression: a real RFC-9421 signature produced exactly as `gl` does — built + /// with `gitlawb_core::http_sig::sign_request` over a GET, headers attached, + /// and sent through the actual `build_router` — is verified by the + /// `optional_signature` layer that wraps the pin/anchor routes, and the + /// handler returns 200. Pairs with the anonymous-denial test above; one + /// proves headers are required, the other proves a valid header is honored. + /// Without this test the unsigned-denial test would stay green even if the + /// `optional_signature` layer were never wired onto these routes, because + /// signed and unsigned requests would fail identically (#134 review). + #[sqlx::test] + async fn signed_get_pins_and_anchors_succeeds_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let state = test_state(pool).await; + let router = build_router(state); + + let kp = Keypair::generate(); + + let pins_path = "/api/v1/ipfs/pins"; + let signed = sign_request(&kp, "GET", pins_path, b""); + let pins = Request::builder() + .method("GET") + .uri(pins_path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + let pins_resp = router.clone().oneshot(pins).await.unwrap(); + assert_eq!( + pins_resp.status(), + StatusCode::OK, + "a valid signature on /api/v1/ipfs/pins must be honored through build_router" + ); + + let anchors_path = "/api/v1/arweave/anchors"; + let signed = sign_request(&kp, "GET", anchors_path, b""); + let anchors = Request::builder() + .method("GET") + .uri(anchors_path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + let anchors_resp = router.oneshot(anchors).await.unwrap(); + assert_eq!( + anchors_resp.status(), + StatusCode::OK, + "a valid signature on /api/v1/arweave/anchors must be honored through build_router" + ); + } + + /// Companion to the two regressions above: a request that *carries* signature + /// headers but whose signature does not verify (here, garbled) must be denied + /// with 401, not silently treated as anonymous and re-checked by the handler. + /// This pins the failure mode of the `optional_signature` layer: when the + /// caller claims to be signed, the layer must commit to verifying — there is + /// no fall-through path that lets a bad signature bypass auth. + #[sqlx::test] + async fn malformed_signature_on_pins_is_401_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let state = test_state(pool).await; + let router = build_router(state); + + let kp = Keypair::generate(); + let path = "/api/v1/ipfs/pins"; + let mut signed = sign_request(&kp, "GET", path, b""); + // Flip a character well inside the signature value (base64) so the header + // still parses but does not verify against the key. + let mut tampered = signed.signature.clone(); + let mid = tampered.len() / 2; + let flipped = if tampered.as_bytes()[mid] == b'A' { + 'B' + } else { + 'A' + }; + tampered.replace_range(mid..mid + 1, &flipped.to_string()); + signed.signature = tampered; + + let req = Request::builder() + .method("GET") + .uri(path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "a malformed signature must be rejected by the auth layer, not silently accepted" + ); + } } From 752e117f0735ec1c5a30bdd9d2150cbca2698011 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 28 Aug 2026 16:09:52 +0600 Subject: [PATCH 3/7] fix(node): format arweave routes for consistency in build_router function --- crates/gitlawb-node/src/server.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 4509b0dc4..4e95451aa 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -240,10 +240,9 @@ pub fn build_router(state: AppState) -> Router { // on the other read surfaces is applied here — there is no anonymous // anchor-listing path on a signed-build node, and this commit closes that // gap alongside `/api/v1/ipfs/pins`. - let arweave_routes = - Router::new() - .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) - .layer(middleware::from_fn(auth::optional_signature)); + let arweave_routes = Router::new() + .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) + .layer(middleware::from_fn(auth::optional_signature)); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( From f49ed79336a555f4e14c5f5f33dca25845d7c226 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 29 Aug 2026 03:32:01 +0600 Subject: [PATCH 4/7] fix(node): gate scoped /api/v1/arweave/anchors reads on repository visibility Closes the review follow-up on #134: a signed non-reader could pass the auth-layer check on `?repo=/` and obtain private-repo anchor metadata (ref names, old/new SHAs, CIDs, irys tx ids). The auth check proved only that the signature was valid; it did not bind the request to the canonical repo-read policy. * Parse the slug through `validate_repo_slug` (same helper the sync path uses) and call `authorize_repo_read` before the SQL query. Missing repos, quarantined mirrors, and signed non-readers all collapse to the standard 404 (`repo_not_found`). * The unscoped global listing stays auth-only (#121 narrowing; the wider permissionless-identity enumeration is the #136 class, out of scope). * Clamp the limit to `[0, 200]` so `?limit=-1` no longer reaches Postgres as `LIMIT -1`. Five new `build_router` regressions cover the full production contract through the real `optional_signature` middleware: scoped unsigned 401, scoped owner 200, scoped non-reader 404 with no anchor metadata leaked in the body, scoped missing-repo 404 indistinguishable from non-reader, and `?limit=-1` clamped to an empty 200. --- crates/gitlawb-node/src/api/arweave.rs | 52 ++++- crates/gitlawb-node/src/server.rs | 278 +++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 10 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index dc3690507..55cb74d7e 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -6,7 +6,7 @@ use axum::{ }; use serde::Deserialize; -use crate::error::Result; +use crate::error::{AppError, Result}; use crate::state::AppState; #[derive(Debug, Deserialize)] @@ -21,23 +21,55 @@ fn default_limit() -> i64 { } /// GET /api/v1/arweave/anchors +/// +/// `?repo=/` returns anchors for one repository and binds the +/// request to the canonical read-authorization path used by every other +/// repo-scoped read: the same `authorize_repo_read` helper, with the caller +/// identity attached. Missing repositories, quarantined mirrors, and signed +/// non-readers all collapse to the standard `404` (no existence oracle). The +/// unscoped listing is auth-only — the #121 contract — and does not admit +/// visibility filtering, which is the #136 stale-index class and explicitly +/// out of scope for this auth slice. pub async fn list_anchors( State(state): State, Query(q): Query, auth: Option>, ) -> Result> { - if auth.is_none() { - return Err(crate::error::AppError::Unauthorized( + // The route's `optional_signature` layer is permissive (it admits unsigned + // legacy callers) so this in-handler check is the actual gate. A signed + // non-reader still passes this check and proceeds to the authz step below. + let caller = auth.as_ref().map(|e| e.0 .0.as_str()); + if caller.is_none() { + return Err(AppError::Unauthorized( "authentication required for anchor listing".into(), )); } - let limit = q.limit.min(200); - // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and - // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). - let anchors = state - .db - .list_arweave_anchors(q.repo.as_deref(), limit) - .await?; + + // Clamp before the SQL query. A negative or non-numeric limit must not + // reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the + // contract is the same default ceiling as the listing page itself. + let limit = q.limit.clamp(0, 200); + + let anchors = if let Some(repo_slug) = q.repo.as_deref() { + // Parse the user-supplied "owner/name" through the same slug validator + // the sync path uses, so a malformed query is a 400, not a 500. + let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) { + Ok(parts) => parts, + Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))), + }; + // Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for + // missing repos, quarantined mirrors, and signed non-readers. + crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?; + // Reuse the user-supplied slug string for the SQL filter, since the + // gate just verified it resolves to a readable repository. + state + .db + .list_arweave_anchors(Some(&format!("{owner}/{name}")), limit) + .await? + } else { + // No scope → no repo-read decision. Auth-only. + state.db.list_arweave_anchors(None, limit).await? + }; Ok(Json(serde_json::json!({ "anchors": anchors, diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 4e95451aa..b54a50a35 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -639,6 +639,7 @@ mod tests { use sqlx::PgPool; use tower::ServiceExt; + use crate::db::RepoRecord; use crate::test_support::test_state; /// Regression: anonymous callers must not see the pin/anchor index (#121, #134). @@ -770,4 +771,281 @@ mod tests { "a malformed signature must be rejected by the auth layer, not silently accepted" ); } + + // ── Scoped anchor contract (P1 follow-up: ?repo= must authorize_repo_read) ── + // + // The follow-up review (after the auth-layer wiring landed) found that a + // signed but unauthorized caller could still obtain scoped anchor metadata + // because the `?repo=` branch handed the user-supplied string straight to + // the SQL filter. These tests exercise the full production contract end to + // end: real RFC-9421 signature → `optional_signature` layer → + // `authorize_repo_read` → SQL. They fail closed if the authz call is moved + // after the query or removed entirely. + + /// Inline repo seed for the scoped-anchor tests. Mirrors the shape in + /// `test_support::tests::seed_repo` without taking a cross-module private + /// helper as a dependency. + fn seed_repo_inline(owner_did: &str, name: &str) -> RepoRecord { + let now = chrono::Utc::now(); + RepoRecord { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + owner_did: owner_did.to_string(), + description: None, + is_public: true, + default_branch: "main".to_string(), + created_at: now, + updated_at: now, + disk_path: format!("/tmp/{name}"), + forked_from: None, + machine_id: None, + } + } + + /// Unsigned scoped anchor request → 401, identical to the global anchor + /// 401 test. Proves the auth layer fires before any scope decision (no + /// existence oracle via the `?repo=` path either). + #[sqlx::test] + async fn unsigned_scoped_anchors_is_401_through_build_router(pool: PgPool) { + let state = test_state(pool).await; + let router = build_router(state); + + let resp = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/arweave/anchors?repo=did:key:zSCOPED%2Fpriv") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "unsigned scoped anchor request must be 401, before any scope lookup" + ); + } + + /// Signed owner on a private repo → 200, anchor metadata returned. Mirrors + /// the existing `list_webhooks_accepts_a_real_gl_signature_e2e` shape: real + /// signature, real middleware, real `authorize_repo_read`. + #[sqlx::test] + async fn signed_scoped_anchors_owner_succeeds_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let state = test_state(pool).await; + + let mut repo = seed_repo_inline(&owner_did, "scoped-priv"); + repo.is_public = false; + state.db.create_repo(&repo).await.expect("seed repo"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInput { + repo: &format!("{short}/scoped-priv"), + owner_did: &owner_did, + ref_name: "refs/heads/main", + old_sha: "0".repeat(64).as_str(), + new_sha: "1".repeat(64).as_str(), + cid: Some("bafytest"), + irys_tx_id: "irys-owner-tx", + arweave_url: "https://arweave.net/owner-tx", + node_did: "did:key:zNODE", + }) + .await + .expect("seed anchor"); + + let path = format!("/api/v1/arweave/anchors?repo={short}/scoped-priv"); + let signed = sign_request(&kp, "GET", &path, b""); + let req = Request::builder() + .method("GET") + .uri(&path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + + let router = build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the owner of a private repo must see their scoped anchors" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body = String::from_utf8_lossy(&bytes); + assert!( + body.contains("irys-owner-tx"), + "owner must see the anchor's irys tx id; body was: {body}" + ); + // Owner must see the ref name + new SHA so this is not just a count oracle. + assert!( + body.contains("refs/heads/main"), + "owner must see the ref name; body was: {body}" + ); + + // Sanity: a second test would need a different repo name to avoid + // colliding on `did:key:zSCOPED/scoped-priv` in the anchors table. + // Distinct repo names per test keep the rows queryable in isolation. + let _ = repo; + } + + /// Signed non-reader on a private repo → 404, anchor metadata MUST NOT + /// leak. This is the test that catches the "auth-but-no-authz" bug if + /// the gate is moved after the SQL query or removed entirely. The 404 + /// is the standard `repo_not_found` shape — indistinguishable from the + /// missing-repo case below. + #[sqlx::test] + async fn signed_scoped_anchors_non_reader_is_404_no_leak_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let owner_kp = Keypair::generate(); + let stranger_kp = Keypair::generate(); + let owner_did = owner_kp.did().to_string(); + let stranger_did = stranger_kp.did().to_string(); + let state = test_state(pool).await; + + let mut repo = seed_repo_inline(&owner_did, "scoped-priv-nr"); + repo.is_public = false; + state.db.create_repo(&repo).await.expect("seed repo"); + let short_owner = owner_did.split(':').next_back().unwrap().to_string(); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInput { + repo: &format!("{short_owner}/scoped-priv-nr"), + owner_did: &owner_did, + ref_name: "refs/heads/secret", + old_sha: "2".repeat(64).as_str(), + new_sha: "3".repeat(64).as_str(), + cid: Some("bafytest"), + irys_tx_id: "irys-secret-tx-DO-NOT-LEAK", + arweave_url: "https://arweave.net/secret-tx", + node_did: "did:key:zNODE", + }) + .await + .expect("seed anchor"); + + let _ = stranger_did; // signature carries the DID; this is just documentation + let path = format!("/api/v1/arweave/anchors?repo={short_owner}/scoped-priv-nr"); + let signed = sign_request(&stranger_kp, "GET", &path, b""); + let req = Request::builder() + .method("GET") + .uri(&path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + + let router = build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "a signed non-reader must be 404 on a private repo's anchors" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body = String::from_utf8_lossy(&bytes); + assert!( + !body.contains("irys-secret-tx-DO-NOT-LEAK"), + "the 404 body must not leak the anchor's irys tx id; body was: {body}" + ); + assert!( + !body.contains("refs/heads/secret"), + "the 404 body must not leak the ref name; body was: {body}" + ); + } + + /// Signed caller on a missing repo → 404, same shape as the non-reader + /// case. This is the indistinguishability half: a probe cannot tell + /// "private" from "absent" from the response. + #[sqlx::test] + async fn signed_scoped_anchors_missing_repo_is_404_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let state = test_state(pool).await; + + let short = kp + .did() + .to_string() + .split(':') + .next_back() + .unwrap() + .to_string(); + let path = format!("/api/v1/arweave/anchors?repo={short}/does-not-exist"); + let signed = sign_request(&kp, "GET", &path, b""); + let req = Request::builder() + .method("GET") + .uri(&path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + + let router = build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "a missing repo must 404 indistinguishably from a non-readable private repo" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!( + v["error"], "repo_not_found", + "the 404 body must carry the standard repo_not_found error code" + ); + } + + /// `?limit=-1` must not crash as `LIMIT -1` (Postgres 500). It clamps to + /// zero and returns 200 with an empty list — the same shape as a valid + /// listing that happens to have no rows in the configured range. + #[sqlx::test] + async fn signed_anchors_negative_limit_clamps_to_zero_through_build_router(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let state = test_state(pool).await; + + let path = "/api/v1/arweave/anchors?limit=-1"; + let signed = sign_request(&kp, "GET", path, b""); + let req = Request::builder() + .method("GET") + .uri(path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + + let router = build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "?limit=-1 must clamp, not 500" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!(v["count"], 0, "clamped limit yields an empty list"); + assert_eq!(v["anchors"].as_array().map(|a| a.len()), Some(0)); + } } From 8dd745e98b40a021021cdaa0807a413570da3720 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 29 Aug 2026 10:52:55 +0600 Subject: [PATCH 5/7] fix(node): keep /api/v1/ipfs/pins out of the CID resolver's per-IP flood brake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #134: the previous reordering put `/api/v1/ipfs/pins` and `/ipfs/{cid}` under the same `rate_limit_by_ip` + `IpRateLimiter` layer stack, so both routes shared the resolver's flood brake bucket. That bucket is documented as the once-per-request brake for `GET /ipfs/{cid}` because the resolver can drive bounded-but-expensive repo walks. The pin listing is a single `list_pinned_cids()` call and has no reason to share the bucket. Sharing meant /ipfs/{cid} traffic could exhaust the bucket and 429 the pins endpoint, and signed pin polling could exhaust the bucket and 429 legitimate CID reads. Compose `ipfs_routes` from two sub-routers with independent policies, then merge: * `/ipfs/{cid}` keeps `optional_signature` + `rate_limit_by_ip` + the `IpRateLimiter` extension. Same behavior as before #134. * `/api/v1/ipfs/pins` gets only `optional_signature`. Auth-required (handler denies with 401), but no rate limit — it has nothing to flood. Two new `build_router` regressions exercise the bucket independence through `TrustedProxy::XForwardedFor`: * 5 signed pin requests against a size-2 CID bucket all 200, then a `/ipfs/{cid}` from the same IP also 200 (pins did not debit the bucket). * A `/ipfs/{cid}` exhausts a size-1 CID bucket (200, then 429), then a signed pin from the same IP is 200 (the bucket does not gate pins). All 65 tests in `server::tests::` + `test_support::tests::ipfs_*` still pass, including the existing 429 tests for the CID resolver. --- crates/gitlawb-node/src/server.rs | 43 ++++-- crates/gitlawb-node/src/test_support.rs | 175 ++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 15 deletions(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index b54a50a35..fb8e1144f 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -210,29 +210,42 @@ pub fn build_router(state: AppState) -> Router { .layer(axum::Extension(push_limiter)); // ── IPFS content-addressed retrieval and pin listing ────────────────── - // `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller - // identity and can apply per-repo visibility (#110); anonymous callers stay - // anonymous and still read genuinely public content. `/api/v1/ipfs/pins` - // now carries the same `optional_signature` layer: the handler rejects - // requests without a verified `AuthenticatedDid`, so unsigned callers are - // denied (anonymous enumeration closed; #121). `/ipfs/{cid}` also carries a - // per-IP flood brake: it is anon-reachable and each request can drive a - // full-history git walk, so the per-IP rate limiter is the outermost layer - // (rejects a flood before the walk-admission work), mirroring the - // push/create routers. The extension MUST be attached or rate_limit_by_ip - // is a silent no-op. Axum layers only cover routes added before them, so - // both `/ipfs/{cid}` and `/api/v1/ipfs/pins` are built first and the auth + - // rate-limit layers are applied to the merged result. + // Two independent sub-routers, then merged. They share a URL prefix family + // but have separate rate-limit policies and must not share a bucket. + // + // `/ipfs/{cid}` (CID resolver): carries `optional_signature` so `get_by_cid` + // sees the caller identity and can apply per-repo visibility (#110); anon + // callers stay anonymous and still read genuinely public content. The + // per-IP flood brake is layered on because the resolver is anon-reachable + // and each request can drive a full-history git walk — the brake is the + // outermost layer (rejects a flood before the walk-admission work), mirroring + // the push/create routers. The `IpRateLimiter` extension MUST be attached + // or `rate_limit_by_ip` is a silent no-op. + // + // `/api/v1/ipfs/pins` (pin listing): now carries `optional_signature` only + // (#121). The handler rejects requests without a verified `AuthenticatedDid` + // with 401. It does NOT carry the CID flood brake — `list_pins` is a single + // `list_pinned_cids()` call, no walk, and routing pins through the resolver's + // bucket would let `/ipfs/{cid}` traffic exhaust the bucket and 429 the + // pins endpoint, or let signed pin polling exhaust the bucket for legitimate + // CID reads. The two surfaces have separate availability contracts. + // + // Both sub-routers are built first with their own layer sets, then merged. + // This is the structure the prior routing guidance called for and the + // earlier 429 test for `/ipfs/{cid}` (test_support.rs) assumes. let ipfs_limiter = rate_limit::IpRateLimiter { limiter: state.ipfs_rate_limiter.clone(), trust: state.push_limiter_trust, }; - let ipfs_routes = Router::new() + let ipfs_cid_routes = Router::new() .route("/ipfs/{cid}", get(ipfs::get_by_cid)) - .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))) .layer(middleware::from_fn(auth::optional_signature)) .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) .layer(axum::Extension(ipfs_limiter)); + let ipfs_pins_routes = Router::new() + .route("/api/v1/ipfs/pins", get(ipfs::list_pins)) + .layer(middleware::from_fn(auth::optional_signature)); + let ipfs_routes = ipfs_cid_routes.merge(ipfs_pins_routes); // ── Arweave permanent anchors ────────────────────────────────────────── // `list_anchors` rejects callers without a verified `AuthenticatedDid`, so diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 430c06002..698b0ae61 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -13766,6 +13766,181 @@ mod tests { ); } + /// #134 R2 (split buckets): signed `/api/v1/ipfs/pins` polling must NOT debit + /// the CID resolver's per-IP flood brake. The pre-fix composition merged + /// pins under the same router as `/ipfs/{cid}`, so every signed pin + /// request consumed a token from `ipfs_rate_limiter` and a flood of pinned + /// traffic could 429 legitimate CID reads. RED before the split (pins + /// debited the bucket): a flood of pins followed by a CID request → 429 + /// on the CID. GREEN after the split: the CID bucket is untouched. + #[sqlx::test] + async fn ipfs_pins_do_not_debit_the_cid_bucket(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let mut state = test_state(pool).await; + // Bucket size 2: any third /ipfs/{cid} request from the same IP would + // 429 if the bucket were touched by the preceding pin requests. + state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(2, Duration::from_secs(3600)); + state.ipfs_work_rate_limiter = + crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600)); + state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor; + + // Seed a public, walk-free CID so /ipfs/{cid} serves cheaply. This + // matters because the proof half of the test fires a real CID request + // and needs the route bucket to be the only thing that can 429 it. + let fx = seed_cid_repos(&slug, &short, &["pinsdebit"]); + let bare = std::path::PathBuf::from("/tmp") + .join(&slug) + .join("pinsdebit.git"); + let repo = seed_repo(&owner_did, "pinsdebit"); + state.db.create_repo(&repo).await.expect("seed repo"); + let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await; + + let router = crate::server::build_router(state); + let peer_ip = "203.0.113.50"; + + // Five signed pin requests — more than the bucket — all must 200 (the + // pins route has no rate limit; the bucket only charges /ipfs/{cid}). + let pins_path = "/api/v1/ipfs/pins"; + let pins_signed = sign_request(&kp, "GET", pins_path, b""); + for i in 0..5 { + let req = Request::builder() + .method(Method::GET) + .uri(pins_path) + .header("content-digest", &pins_signed.content_digest) + .header("signature-input", &pins_signed.signature_input) + .header("signature", &pins_signed.signature) + .header("x-forwarded-for", peer_ip) + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "pin request {i} must not be rate-limited even though the CID bucket is size 2" + ); + } + + // A subsequent /ipfs/{cid} request from the same IP must still serve + // 200 — the bucket was untouched by the pin traffic. (If pins had + // debited it, the bucket would be empty and the CID request would 429.) + let cid_path = format!("/ipfs/{cid}"); + let cid_signed = sign_request(&kp, "GET", &cid_path, b""); + let req = Request::builder() + .method(Method::GET) + .uri(&cid_path) + .header("content-digest", cid_signed.content_digest) + .header("signature-input", cid_signed.signature_input) + .header("signature", cid_signed.signature) + .header("x-forwarded-for", peer_ip) + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the CID bucket must still have tokens — pin traffic did not debit it" + ); + } + + /// #134 R2 (split buckets, second half): an EXHAUSTED CID bucket must + /// NOT block a valid signed `/api/v1/ipfs/pins` listing. The pre-fix + /// composition routed pins through the same middleware stack, so a CID + /// flood that exhausted the bucket would 429 the pins endpoint too. RED + /// before the split: a CID request that 429s the bucket makes the next + /// pin request 429 too. GREEN after the split: pins never see this + /// bucket and remain servable. + #[sqlx::test] + async fn ipfs_pins_survive_an_exhausted_cid_bucket(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let mut state = test_state(pool).await; + // Bucket size 1: the first /ipfs/{cid} request from the IP spends the + // only token; the second is 429 (proves exhaustion). + state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600)); + state.ipfs_work_rate_limiter = + crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600)); + state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor; + + let fx = seed_cid_repos(&slug, &short, &["pinsexh"]); + let bare = std::path::PathBuf::from("/tmp") + .join(&slug) + .join("pinsexh.git"); + let repo = seed_repo(&owner_did, "pinsexh"); + state.db.create_repo(&repo).await.expect("seed repo"); + let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await; + + let router = crate::server::build_router(state); + let peer_ip = "203.0.113.60"; + + // First /ipfs/{cid} request — debits the single CID token, serves 200. + let cid_path = format!("/ipfs/{cid}"); + let cid_signed = sign_request(&kp, "GET", &cid_path, b""); + let req = Request::builder() + .method(Method::GET) + .uri(&cid_path) + .header("content-digest", cid_signed.content_digest) + .header("signature-input", cid_signed.signature_input) + .header("signature", cid_signed.signature) + .header("x-forwarded-for", peer_ip) + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the first CID request spends the token and serves" + ); + + // Second /ipfs/{cid} request — bucket empty → 429 (proves the + // exhaustion is real and observable on this router). Re-sign because + // `SignedHeaders` holds owned `String`s. + let cid_signed_2 = sign_request(&kp, "GET", &cid_path, b""); + let req = Request::builder() + .method(Method::GET) + .uri(&cid_path) + .header("content-digest", cid_signed_2.content_digest) + .header("signature-input", cid_signed_2.signature_input) + .header("signature", cid_signed_2.signature) + .header("x-forwarded-for", peer_ip) + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "the second CID request from the same IP is 429 — bucket exhausted" + ); + + // A signed /api/v1/ipfs/pins request from the SAME IP must still + // serve 200 — pins are auth-only and never see this bucket. + let pins_path = "/api/v1/ipfs/pins"; + let pins_signed = sign_request(&kp, "GET", pins_path, b""); + let req = Request::builder() + .method(Method::GET) + .uri(pins_path) + .header("content-digest", pins_signed.content_digest) + .header("signature-input", pins_signed.signature_input) + .header("signature", pins_signed.signature) + .header("x-forwarded-for", peer_ip) + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "an exhausted CID bucket must not block a valid signed pin listing" + ); + } + /// U5 (R6): the two buckets are independent — the WORK budget can be exhausted /// (429) WITHOUT draining the ROUTE bucket. Through the production router, route /// generous (5) but work tight (1): one request drives two legacy probes, so the From 4242bb85adf1f86cb05bb841b92a82f1745ee309 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 30 Aug 2026 11:24:12 +0600 Subject: [PATCH 6/7] fix(node): normalize scoped anchor SQL filter to stored repo slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After authorize_repo_read succeeds, the SQL filter used the raw ?repo= string (format!("{owner}/{name}")). Anchor rows are stored with normalize_owner_key(owner_did)/name (short key), so a caller using ?repo=did:key:.../name passed authz but got anchors: [] — a false empty page that broke callers that legitimately pass the full DID form. Build the filter from record.owner_did normalized and record.name instead, matching the shape the seeder uses. --- crates/gitlawb-node/src/api/arweave.rs | 16 ++++-- crates/gitlawb-node/src/server.rs | 75 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 55cb74d7e..942c73134 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -6,6 +6,7 @@ use axum::{ }; use serde::Deserialize; +use crate::db::normalize_owner_key; use crate::error::{AppError, Result}; use crate::state::AppState; @@ -59,12 +60,19 @@ pub async fn list_anchors( }; // Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for // missing repos, quarantined mirrors, and signed non-readers. - crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?; - // Reuse the user-supplied slug string for the SQL filter, since the - // gate just verified it resolves to a readable repository. + let (record, _rules) = + crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?; + // Build the SQL filter from the canonical stored slug, NOT from the + // user-supplied `?repo=` string. Anchor rows are written as + // `{normalize_owner_key(owner_did)}/{name}` (the short form), so a + // request that passes authz with the full `did:key:…/name` form would + // match zero rows and return a false empty page. The gate already + // verified the repo is readable; `record.owner_did` is the canonical + // identity and `record.name` is the stored name. + let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name); state .db - .list_arweave_anchors(Some(&format!("{owner}/{name}")), limit) + .list_arweave_anchors(Some(&stored_slug), limit) .await? } else { // No scope → no repo-read decision. Auth-only. diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index fb8e1144f..5e99c878f 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -910,6 +910,81 @@ mod tests { let _ = repo; } + /// #121 R3: the SQL filter must use the canonical stored slug, not the + /// user-supplied `?repo=` string. Anchor rows are stored as + /// `{normalize_owner_key(owner_did)}/{name}` (the short form). Authz + /// passes for the full-DID form, but a literal `WHERE repo=$1` against + /// the full DID would match zero rows and return a false empty page, + /// breaking callers that legitimately use the full `did:key:…/name` + /// form in `?repo=`. RED before the fix: handler built the filter from + /// the raw `?repo=` string, so this query returned `anchors: []`. + #[sqlx::test] + async fn signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router( + pool: PgPool, + ) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let state = test_state(pool).await; + + let mut repo = seed_repo_inline(&owner_did, "scoped-did-form"); + repo.is_public = false; + state.db.create_repo(&repo).await.expect("seed repo"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInput { + repo: &format!("{short}/scoped-did-form"), + owner_did: &owner_did, + ref_name: "refs/heads/main", + old_sha: "0".repeat(64).as_str(), + new_sha: "1".repeat(64).as_str(), + cid: Some("bafytest"), + irys_tx_id: "irys-did-form-tx", + arweave_url: "https://arweave.net/did-form-tx", + node_did: "did:key:zNODE", + }) + .await + .expect("seed anchor"); + + // The caller passes the FULL DID form in ?repo=. validate_repo_slug + // accepts it, authorize_repo_read resolves it, and the handler MUST + // then translate to the canonical stored slug (short form) before + // running the SQL — otherwise this returns 200 with anchors: []. + let path = format!("/api/v1/arweave/anchors?repo={owner_did}/scoped-did-form"); + let signed = sign_request(&kp, "GET", &path, b""); + let req = Request::builder() + .method("GET") + .uri(&path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::empty()) + .unwrap(); + + let router = build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "authz passed via the full DID form, so the response must be 200" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body = String::from_utf8_lossy(&bytes); + assert!( + body.contains("irys-did-form-tx"), + "the SQL filter must use the stored short slug, not the full DID — \ + a false-empty page would break callers that pass ?repo=did:key:…/name. \ + body was: {body}" + ); + + let _ = repo; + } + /// Signed non-reader on a private repo → 404, anchor metadata MUST NOT /// leak. This is the test that catches the "auth-but-no-authz" bug if /// the gate is moved after the SQL query or removed entirely. The 404 From ac9ea619279ba67f79b3ba2126736bef0ee04a5e Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 30 Aug 2026 17:13:53 +0600 Subject: [PATCH 7/7] All three findings addressed on bug_fix_2: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 — signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router now seeds a second repo under a different owner and asserts irys-other-repo-tx is absent. Negative control: flipping the production filter to None made the test go RED with count: 2 showing the cross-repo leak. P3 — let short = owner_did.split(':').next_back().unwrap() replaced with let short = crate::db::normalize_owner_key(&owner_did).to_string(). Reader and writer now share one convention. P3 — PR #134 description updated: pins bullet now describes the actual pre-merge layering (and cites the axum-layer-vs-merge-pitfall memory entry), and round 2's stored-slug normalization is added as its own bullet with the regression's coverage. 5/5 anchor-related tests pass, cargo fmt --check clean. --- crates/gitlawb-node/src/server.rs | 51 ++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 5e99c878f..fb23419fc 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -932,7 +932,13 @@ mod tests { let mut repo = seed_repo_inline(&owner_did, "scoped-did-form"); repo.is_public = false; state.db.create_repo(&repo).await.expect("seed repo"); - let short = owner_did.split(':').next_back().unwrap().to_string(); + // Use the same slug-construction the writer uses + // (`db::normalize_owner_key`) so the test reader and the production + // writer can't drift if a future change moves the slug off the + // short-form. P3 (reviewer-3): the previous `split(':').next_back()` + // hand-rolled a third convention that agreed with the writer for + // `did:key` owners and diverged for every other method. + let short = crate::db::normalize_owner_key(&owner_did).to_string(); state .db .record_arweave_anchor(&crate::db::RecordAnchorInput { @@ -949,6 +955,43 @@ mod tests { .await .expect("seed anchor"); + // P2 (reviewer-3): the previous suite only proved the authorized + // row is present. A correct filter must ALSO prove that anchors + // for OTHER repos do not leak into the response — the failure + // mode of the original bug was a cross-repo leak, not a + // fail-safe empty page. Seed a second repo (also under the + // same owner, so the authz test stays scoped) with its own + // irys_tx_id and assert it is absent from the body. + let other_owner = { + let kp2 = Keypair::generate(); + kp2.did().to_string() + }; + let mut other_repo = seed_repo_inline(&other_owner, "other-repo"); + other_repo.is_public = true; + state + .db + .create_repo(&other_repo) + .await + .expect("seed other repo"); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInput { + repo: &format!( + "{}/other-repo", + crate::db::normalize_owner_key(&other_owner) + ), + owner_did: &other_owner, + ref_name: "refs/heads/main", + old_sha: "0".repeat(64).as_str(), + new_sha: "2".repeat(64).as_str(), + cid: Some("bafyother"), + irys_tx_id: "irys-other-repo-tx", + arweave_url: "https://arweave.net/other-tx", + node_did: "did:key:zNODE", + }) + .await + .expect("seed other anchor"); + // The caller passes the FULL DID form in ?repo=. validate_repo_slug // accepts it, authorize_repo_read resolves it, and the handler MUST // then translate to the canonical stored slug (short form) before @@ -981,6 +1024,12 @@ mod tests { a false-empty page would break callers that pass ?repo=did:key:…/name. \ body was: {body}" ); + assert!( + !body.contains("irys-other-repo-tx"), + "the scoped anchor filter must exclude other repos' anchors — \ + a cross-repo leak would expose metadata of repos the caller \ + did not authorize. body was: {body}" + ); let _ = repo; }