Skip to content

Unauthenticated GET /api/v1/stats leaks the count of private/mode-A repos (count oracle) #104

Description

@beardthelion

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:454stats takes only State (no caller) and returns repos = list_all_repos().len().
  • crates/gitlawb-node/src/db/mod.rs:859list_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:nodegitlawb-node — the serving node and REST APIkind:securityVulnerability fix or hardeningsev:mediumDegraded but workaround existssubsystem:apiNode REST API request/response surfacesubsystem:visibilityPath-scoped visibility and content withholding

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions