The GraphQL refUpdates query is not visibility-gated, so an anonymous caller can read ref-level metadata for a private repo by passing its name. This is the same withholding-bypass class as #97 (repo listing) and #110 (blob content), on the GraphQL ref-history surface. The #97 visibility gate was applied to the sibling repos resolver but not to refUpdates.
Where
crates/gitlawb-node/src/graphql/query.rs, the ref_updates resolver (async fn ref_updates, around line 48).
- The resolver calls
db.list_ref_updates_filtered(repo.as_deref(), limit) and maps every row straight to RefUpdateType. It never reads ctx.data::<crate::auth::AuthenticatedDid>() and never consults visibility rules. The repos resolver directly above it threads the caller DID through crate::visibility::listable_at_root; ref_updates does not.
Db::list_ref_updates_filtered (crates/gitlawb-node/src/db/mod.rs, ~line 1975) runs SELECT ... FROM received_ref_updates WHERE repo=$1 ORDER BY timestamp DESC LIMIT $2 with no is_public, no visibility join, and no reader-DID predicate. The unfiltered branch (no repo arg) is equally open.
/graphql is wrapped only by auth::optional_signature, not require_signature (crates/gitlawb-node/src/server.rs:66), so the query is reachable anonymously.
Impact
For any private repo whose name an attacker can guess or enumerate, an unauthenticated GraphQL query returns its refName, oldSha, newSha, pusherDid, nodeDid, and timestamp. That discloses the repo's existence, its branch/tag names, commit SHAs, and the identity of who pushed, for a repo the read path otherwise withholds.
Verified by execution: a test that seeds a private repo (is_public=false), records a received ref update for it, then runs an anonymous { refUpdates(repo: "secret-repo") { refName newSha pusherDid } } returns the row:
{"refUpdates":[{"newSha":"deadbeef...","pusherDid":"did:key:zPUSHER...","refName":"refs/heads/main"}]}
Suggested remediation
Gate ref_updates on the same "/" visibility decision the repos resolver and the per-repo endpoints use. Resolve the named repo, fetch its visibility rules, and return ref data only when listable_at_root(rules, is_public, owner_did, caller) allows the caller (anonymous = None); fail closed (empty result) otherwise. The unfiltered branch must filter each row's repo through the same gate before returning.
The GraphQL
refUpdatesquery is not visibility-gated, so an anonymous caller can read ref-level metadata for a private repo by passing its name. This is the same withholding-bypass class as #97 (repo listing) and #110 (blob content), on the GraphQL ref-history surface. The #97 visibility gate was applied to the siblingreposresolver but not torefUpdates.Where
crates/gitlawb-node/src/graphql/query.rs, theref_updatesresolver (async fn ref_updates, around line 48).db.list_ref_updates_filtered(repo.as_deref(), limit)and maps every row straight toRefUpdateType. It never readsctx.data::<crate::auth::AuthenticatedDid>()and never consults visibility rules. Thereposresolver directly above it threads the caller DID throughcrate::visibility::listable_at_root;ref_updatesdoes not.Db::list_ref_updates_filtered(crates/gitlawb-node/src/db/mod.rs, ~line 1975) runsSELECT ... FROM received_ref_updates WHERE repo=$1 ORDER BY timestamp DESC LIMIT $2with nois_public, no visibility join, and no reader-DID predicate. The unfiltered branch (norepoarg) is equally open./graphqlis wrapped only byauth::optional_signature, notrequire_signature(crates/gitlawb-node/src/server.rs:66), so the query is reachable anonymously.Impact
For any private repo whose name an attacker can guess or enumerate, an unauthenticated GraphQL query returns its
refName,oldSha,newSha,pusherDid,nodeDid, andtimestamp. That discloses the repo's existence, its branch/tag names, commit SHAs, and the identity of who pushed, for a repo the read path otherwise withholds.Verified by execution: a test that seeds a private repo (
is_public=false), records a received ref update for it, then runs an anonymous{ refUpdates(repo: "secret-repo") { refName newSha pusherDid } }returns the row:{"refUpdates":[{"newSha":"deadbeef...","pusherDid":"did:key:zPUSHER...","refName":"refs/heads/main"}]}Suggested remediation
Gate
ref_updateson the same"/"visibility decision thereposresolver and the per-repo endpoints use. Resolve the named repo, fetch its visibility rules, and return ref data only whenlistable_at_root(rules, is_public, owner_did, caller)allows the caller (anonymous =None); fail closed (empty result) otherwise. The unfiltered branch must filter each row's repo through the same gate before returning.