Summary
The repo-scoped REST read endpoints do not enforce path-scoped visibility. Private repositories, and private subtrees of public repositories, have their content and metadata served to unauthenticated callers. This bypasses the entire withholding mechanism that the git smart-HTTP and replication paths enforce.
The git clone/fetch path (git_upload_pack / info_refs) and the IPFS/Pinata replication path both gate on visibility_check and withhold private blobs correctly. The REST API does not, so the same private bytes are reachable through a different door.
Impact
Highest severity is content disclosure. GET /api/v1/repos/{owner}/{repo}/blob/{*path} returns the raw bytes of any file, including files in a withheld subtree and every file in a fully-private repo, with no auth and no visibility check:
GET /api/v1/repos/alice/website/blob/secret/.env -> 200, cleartext
GET .../pulls/{n}/diff likewise returns diffs (file content) ungated.
Affected endpoints (confirmed, all with zero visibility/is_public checks)
Content:
GET /blob/{*path} (repos::get_blob) — raw file bytes
GET /pulls/{n}/diff (pulls::get_pr_diff) — diffs
Structure / metadata:
GET /tree, GET /tree/{*path} (repos::get_tree_root, get_tree)
GET /commits (repos::list_commits) — commit messages
GET /refs (repos::list_refs)
GET /changelog (changelog::get_changelog)
GET /pulls, GET /pulls/{n} (pulls::list_prs, get_pr)
GET /repos/{owner}/{repo} (repos::get_repo) — repo metadata
These are mounted in the read_routes router ("open for public repos") and merged with no gating layer, so they are fully public.
Root cause
Path-scoped visibility was retrofitted onto the git protocol and replication paths but never applied to the REST read surface. The handlers call get_repo and read straight from disk; none consult list_visibility_rules or visibility_check.
Scope
Pre-existing. get_blob dates to the initial public release and is present on main today, so this is a live hole independent of the in-flight visibility work. It is called out here because the visibility/withholding feature is meaningless while this bypass exists.
Suggested fix
Two tiers:
- Repo-level read gate for every repo-scoped read endpoint: resolve the repo, load its rules, and
visibility_check(rules, is_public, owner_did, caller, "/"); return 404 on Deny. This closes fully-private repos across the whole surface and is best done so new routes cannot silently skip it.
- Path-level gate for endpoints that expose subtree content:
get_blob must additionally visibility_check at the requested file path, because a public repo with a private subtree passes the / gate but the specific blob must still be withheld. get_pr_diff needs equivalent treatment.
To authenticate authorized readers over REST (so the gate allows them, not just the owner/public), the gated routes need the optional_signature middleware, mirroring the git read routes.
Summary
The repo-scoped REST read endpoints do not enforce path-scoped visibility. Private repositories, and private subtrees of public repositories, have their content and metadata served to unauthenticated callers. This bypasses the entire withholding mechanism that the git smart-HTTP and replication paths enforce.
The git clone/fetch path (
git_upload_pack/info_refs) and the IPFS/Pinata replication path both gate onvisibility_checkand withhold private blobs correctly. The REST API does not, so the same private bytes are reachable through a different door.Impact
Highest severity is content disclosure.
GET /api/v1/repos/{owner}/{repo}/blob/{*path}returns the raw bytes of any file, including files in a withheld subtree and every file in a fully-private repo, with no auth and no visibility check:GET .../pulls/{n}/difflikewise returns diffs (file content) ungated.Affected endpoints (confirmed, all with zero visibility/is_public checks)
Content:
GET /blob/{*path}(repos::get_blob) — raw file bytesGET /pulls/{n}/diff(pulls::get_pr_diff) — diffsStructure / metadata:
GET /tree,GET /tree/{*path}(repos::get_tree_root,get_tree)GET /commits(repos::list_commits) — commit messagesGET /refs(repos::list_refs)GET /changelog(changelog::get_changelog)GET /pulls,GET /pulls/{n}(pulls::list_prs,get_pr)GET /repos/{owner}/{repo}(repos::get_repo) — repo metadataThese are mounted in the
read_routesrouter ("open for public repos") and merged with no gating layer, so they are fully public.Root cause
Path-scoped visibility was retrofitted onto the git protocol and replication paths but never applied to the REST read surface. The handlers call
get_repoand read straight from disk; none consultlist_visibility_rulesorvisibility_check.Scope
Pre-existing.
get_blobdates to the initial public release and is present onmaintoday, so this is a live hole independent of the in-flight visibility work. It is called out here because the visibility/withholding feature is meaningless while this bypass exists.Suggested fix
Two tiers:
visibility_check(rules, is_public, owner_did, caller, "/"); return 404 onDeny. This closes fully-private repos across the whole surface and is best done so new routes cannot silently skip it.get_blobmust additionallyvisibility_checkat the requested file path, because a public repo with a private subtree passes the/gate but the specific blob must still be withheld.get_pr_diffneeds equivalent treatment.To authenticate authorized readers over REST (so the gate allows them, not just the owner/public), the gated routes need the
optional_signaturemiddleware, mirroring the git read routes.