security: private repo visibility is not enforced on SSR routes — anonymous users get 200 on /{owner}/{repo} pages
Summary
Private repositories on MuseHub staging serve all SSR sub-routes with HTTP 200 to unauthenticated requests. An anonymous user who knows (or guesses) the owner/repo slug can read the repository home page, commit list, release list, issue list, and individual issue pages — even though the repository is marked private.
Reproduction (unauthenticated curl)
# Repo created as --visibility private (aaronrene/scooling on staging)
curl -s -o /dev/null -w '%{http_code}\n' https://staging.musehub.ai/aaronrene/scooling
# Expected: 404 or 403 Actual: 200
curl -s -o /dev/null -w '%{http_code}\n' https://staging.musehub.ai/aaronrene/scooling/commits
# Expected: 404 or 403 Actual: 200
curl -s -o /dev/null -w '%{http_code}\n' https://staging.musehub.ai/aaronrene/scooling/releases
# Expected: 404 or 403 Actual: 200
curl -s -o /dev/null -w '%{http_code}\n' https://staging.musehub.ai/aaronrene/scooling/issues
# Expected: 404 or 403 Actual: 200
Root cause (source inspection)
In musehub/api/routes/musehub/ui.py, the visibility field on the repo model is read and passed to the Jinja2 template context for display (e.g. the public/private badge), but there is no access-control guard that returns 404/403 before the template is rendered for unauthenticated callers.
The fix is to add an auth check at the top of every repo-scoped SSR route handler:
if repo.visibility == 'private':
current_user = await get_optional_current_user(request)
if current_user is None or current_user.id != repo.owner_id:
raise HTTPException(status_code=404)
Impact
- Any private repository on this hub leaks its existence and full page content (commits, releases, issues) to unauthenticated users.
- Severity: high — breaks the confidentiality contract of the
--visibility privateflag.
Environment
- Hub: staging.musehub.ai
- Probe date: 2026-06-11
- CLI version: rc10 (muse --version)
Filed by the Scooling integration agent as part of the Phase 4 staging smoke runbook (precondition: privacy probe must return 403/404 before live push is authorized).
Fixed in proposal https://staging.musehub.ai/gabriel/musehub/proposals/sha256:6f36c98a99d530c1a63004a7f1d886e5acecfd2e45d7d7edeef233084e1b47ca (branch task/ssr-visibility-gate).
All five attack surfaces are covered:
GET /{owner}/{repo}—ui_repo.py::repo_page✓GET /{owner}/{repo}/commits—ui_commits.py::commits_list_page✓GET /{owner}/{repo}/releases—ui_releases.py::release_list_page✓GET /{owner}/{repo}/issues—ui_issues.py::issue_list_page✓GET /{owner}/{repo}/raw/{ref}/{path}—ui_tree.py::raw_file_semantic✓ (covers the follow-up comment)
The fix is centralized in _resolve_repo / _resolve_repo_full in _ui_helpers.py — both helpers now raise HTTP 404 when repo.visibility != 'public' and no valid MSign token is present. All 14 SSR route files thread claims through to these helpers, so the gate is not per-handler but per-resolver call.
Additional attack surface: raw file endpoint also bypasses visibility (more severe)
The same visibility enforcement gap extends to the raw content route:
Verified 2026-06-11 (unauthenticated):
This is worse than the SSR route leak: it returns raw file bytes (not just rendered page chrome), making it trivially scriptable to bulk-download a private repo's source tree.
Mitigation taken: soft-deleted
aaronrene/scoolingfrom staging immediately (muse hub repo delete). The repo page and raw endpoint now both return 404. Will not re-push until this and the SSR fix are confirmed resolved.Suggested addition to the fix: the auth guard must wrap the raw-content route handler separately from the SSR handlers in
ui.py. A regression test should assert that an unauthenticated raw GET on a private repo never returns 200 with content.