GET /api/v1/stats returns "repos": list_all_repos().len() over an unfiltered count of every repo on the node, including is_public=false and mode-A (hide-existence) repos, with no authentication. It is a count oracle that defeats the existence-hiding the other listing surfaces enforce.
Where
crates/gitlawb-node/src/server.rs:454 — stats takes only State (no caller) and returns repos = list_all_repos().len().
crates/gitlawb-node/src/db/mod.rs:859 — list_all_repos() is SELECT ... FROM repos ORDER BY updated_at DESC, no WHERE is_public, so the count includes private and mode-A repos.
crates/gitlawb-node/src/server.rs:396 — /api/v1/stats is in meta_routes, which carries no auth layer (the read-routes group above it has optional_signature; write routes use add_auth_layers; meta_routes has neither). The final merge adds only TraceLayer, so the route is reachable unauthenticated.
Impact
Leaks an aggregate count, not repo names, owners, or content, which is why this is medium rather than high. It is not harmless: a polling observer can detect that a hidden repo was created and roughly when. Record repos=N, an owner creates a mode-A repo, the next poll returns N+1. That existence signal is exactly what mode-A is meant to suppress, and every per-repo and listing surface correctly withholds it. agents and pushes on the same endpoint are non-sensitive aggregates (the agent registry is already a public discovery surface) and are not in scope here.
Relationship to #97
The list, federated-list, and GraphQL repos enumeration vectors named in #97 are gated by the visibility fixes on fix/vis-subtree-withholding. stats reads the same list_all_repos() but was scoped out of that work as an aggregate count. This issue tracks that residual.
Suggested fix
Count only the repos an anonymous caller could list, reusing the helper the listing surfaces now share: over-fetch with list_all_repos(), batch-load rules via list_visibility_rules_for_repos, and count rows where visibility::listable_at_root(rules, is_public, owner_did, None) is true. Add a regression test that one public + one is_public=false repo yields repos == 1 for an anonymous GET /api/v1/stats.
GET /api/v1/statsreturns"repos": list_all_repos().len()over an unfiltered count of every repo on the node, includingis_public=falseand mode-A (hide-existence) repos, with no authentication. It is a count oracle that defeats the existence-hiding the other listing surfaces enforce.Where
crates/gitlawb-node/src/server.rs:454—statstakes onlyState(no caller) and returnsrepos = list_all_repos().len().crates/gitlawb-node/src/db/mod.rs:859—list_all_repos()isSELECT ... FROM repos ORDER BY updated_at DESC, noWHERE is_public, so the count includes private and mode-A repos.crates/gitlawb-node/src/server.rs:396—/api/v1/statsis inmeta_routes, which carries no auth layer (the read-routes group above it hasoptional_signature; write routes useadd_auth_layers;meta_routeshas neither). The final merge adds onlyTraceLayer, so the route is reachable unauthenticated.Impact
Leaks an aggregate count, not repo names, owners, or content, which is why this is medium rather than high. It is not harmless: a polling observer can detect that a hidden repo was created and roughly when. Record
repos=N, an owner creates a mode-A repo, the next poll returnsN+1. That existence signal is exactly what mode-A is meant to suppress, and every per-repo and listing surface correctly withholds it.agentsandpusheson the same endpoint are non-sensitive aggregates (the agent registry is already a public discovery surface) and are not in scope here.Relationship to #97
The list, federated-list, and GraphQL
reposenumeration vectors named in #97 are gated by the visibility fixes onfix/vis-subtree-withholding.statsreads the samelist_all_repos()but was scoped out of that work as an aggregate count. This issue tracks that residual.Suggested fix
Count only the repos an anonymous caller could list, reusing the helper the listing surfaces now share: over-fetch with
list_all_repos(), batch-load rules vialist_visibility_rules_for_repos, and count rows wherevisibility::listable_at_root(rules, is_public, owner_did, None)is true. Add a regression test that one public + oneis_public=falserepo yieldsrepos == 1for an anonymousGET /api/v1/stats.