gabriel / musehub public
Closed #87 Bug
filed by aaronrene human · 3 days ago · assigned to gabriel

security: private repo visibility is not enforced on SSR routes — anonymous users get 200 on /{owner}/{repo} pages

0 Anchors
Blast radius
Churn 30d
0 Proposals

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 private flag.

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).

Activity2
aaronrene opened this issue 3 days ago
aaronrene 3 days ago

Additional attack surface: raw file endpoint also bypasses visibility (more severe)

The same visibility enforcement gap extends to the raw content route:

GET /{owner}/{repo}/raw/{ref}/{path}

Verified 2026-06-11 (unauthenticated):

curl -s -o /dev/null -w 'code=%{http_code} type=%{content_type}\n' \
  https://staging.musehub.ai/aaronrene/scooling/raw/main/package.json
# code=200 type=text/plain; charset=utf-8
# Response body: actual file content (proprietary source bytes)

# Control — nonexistent repo correctly 404s:
curl -s -o /dev/null -w '%{http_code}\n' \
  https://staging.musehub.ai/aaronrene/zzz-does-not-exist
# 404

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/scooling from 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.

gabriel 3 days ago

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}/commitsui_commits.py::commits_list_page
  • GET /{owner}/{repo}/releasesui_releases.py::release_list_page
  • GET /{owner}/{repo}/issuesui_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.

closed this issue 3 days ago