gabriel / musehub public
Closed #90 Bug security
filed by gabriel human · 3 days ago

SSR layer enforces no repo visibility — all 14 repo-scoped page routes expose private repos

0 Anchors
Blast radius
Churn 30d
0 Proposals

Background

Issue #87 reported that the SSR repo home page returns 200 to unauthenticated requests on private repos. An audit of the full SSR layer reveals this is not a one-off bug — it is a systematic gap: no SSR route handler enforces repo visibility, and the pre-launch checklist entry that claimed this was done (docs/pre-launch-checklist.md:42) is false.

What is checked ✓ and what is not ✗

The JSON API layer (musehub/api/routes/musehub/*.py) enforces visibility correctly throughout:

# Pattern used in proposals.py, releases.py, objects.py, blame.py, issues.py, …
if repo.visibility != "public" and claims is None:
    raise HTTPException(status_code=404, detail="Repo not found.")

The SSR layer (musehub/api/routes/musehub/ui_*.py) has zero uses of optional_token, require_valid_token, or any visibility check in any repo-scoped handler. Every SSR page for a private repo returns HTTP 200 with full content to any anonymous request.

Why the web UI cannot "sign in" today

MSign is a per-request Ed25519 signing scheme. The browser has no private key and no session mechanism — there is no "signed-in" state in the browser until magic links are built. This is intentional for now. It also means the correct short-term behaviour is explicit: private repos are inaccessible via the web UI to any anonymous browser. Any browser request to a private repo should receive a 404 (not a 403, which would confirm the repo exists).


Scope — unprotected SSR route files

All 14 files below call _resolve_repo or _resolve_repo_full from _ui_helpers.py and pass zero auth context:

File Handler count Visibility check Notes
ui_repo.py 1 Repo home page (#87)
ui_commits.py 3 Commit list, diff, single commit
ui_tree.py 2 Directory tree browse
ui_blob.py ~3 File content viewer
ui_blame.py 1 Blame view
ui_issues.py 3 Issue list, issue detail
ui_proposals.py 4+ Proposal list, proposal detail
ui_releases.py 3 Release list, release detail
ui_intel.py ~20 All intelligence strip fragments
ui_symbols.py 2 Symbol browser
ui_agents.py 5 Agent activity pages
ui_sessions.py 2 Session list — should be owner-only
ui_repo_settings.py 2 Settings — must be owner-only
ui_view.py varies Generic view fragments

Additionally:

  • ui_mists.py — uses mist.visibility for mist-level gating but does not gate the parent repo
  • musehub/api/routes/musehub/__init__.py — has 2 auth uses; needs review

Root cause

_resolve_repo and _resolve_repo_full in _ui_helpers.py perform exactly one job: look up the repo by owner/slug and raise 404 if it doesn't exist. Neither accepts a claims argument nor checks repo.visibility. Every SSR handler calls one of these helpers and then proceeds with full access.


Goal

Every SSR route that renders repo-scoped content must enforce the same visibility contract already used in the JSON API:

  • Public repo, any request → proceed.
  • Private repo, no MSign token → 404 (repo "not found" — does not confirm existence).
  • Private repo, valid MSign token → proceed if the token's handle is the owner (or a future collaborator).
  • Repo settings / sessions pages → always require a valid MSign token and the token's handle must equal the repo owner. These are not just visibility-gated; they are owner-only.

Design

Central fix — _resolve_repo and _resolve_repo_full

Both helpers receive a new optional claims: MSignContext | None = None parameter. The visibility gate is added after the DB lookup:

# _ui_helpers.py — _resolve_repo (abbreviated)
async def _resolve_repo(
    owner: str,
    repo_slug: str,
    db: AsyncSession,
    claims: MSignContext | None = None,
) -> tuple[str, str, dict]:
    row = await musehub_repository.get_repo_by_owner_slug_row(db, owner, repo_slug)
    if row is None:
        raise HTTPException(status_code=404, detail="Repo not found.")
    if row.visibility != "public" and claims is None:
        raise HTTPException(status_code=404, detail="Repo not found.")
    ...
# _ui_helpers.py — _resolve_repo_full (abbreviated)
async def _resolve_repo_full(
    owner: str,
    repo_slug: str,
    db: AsyncSession,
    claims: MSignContext | None = None,
) -> tuple[RepoResponse, str]:
    repo = await musehub_repository.get_repo_by_owner_slug(db, owner, repo_slug)
    if repo is None:
        raise HTTPException(status_code=404, detail="Repo not found.")
    if repo.visibility != "public" and claims is None:
        raise HTTPException(status_code=404, detail="Repo not found.")
    return repo, _base_url(owner, repo_slug)

Thread optional_token into every SSR handler

Every handler that calls _resolve_repo or _resolve_repo_full adds:

from musehub.auth.dependencies import optional_token, TokenClaims

# In the handler signature:
claims: TokenClaims | None = Depends(optional_token),

# Pass through:
repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)

Owner-only gate for settings and sessions

ui_repo_settings.py and ui_sessions.py need stricter enforcement — not just "has a token" but "token handle == repo owner":

from musehub.auth.dependencies import require_valid_token, TokenClaims
from fastapi import HTTPException, status

# In the handler signature:
claims: TokenClaims = Depends(require_valid_token),  # 401 if no token

# After resolving the repo:
if claims.handle != owner:
    raise HTTPException(status_code=403, detail="Repo owner access required.")

Phases

Phase 1 — Central visibility gate in _ui_helpers.py

Deliverables:

  • AV_01 — Add claims: MSignContext | None = None parameter to _resolve_repo. Add visibility check: if row.visibility != "public" and claims is None: raise HTTPException(404).
  • AV_02 — Same for _resolve_repo_full.
  • AV_03 — Default is None so existing callers that don't pass claims get safe behavior: private repos 404. No call-site changes required for Phase 1.

Phase 2 — Thread optional_token into all repo-scoped SSR handlers

One change per file: add Depends(optional_token) to every handler signature, pass claims to the resolver call.

Deliverables:

  • AV_04ui_repo.pyrepo_page
  • AV_05ui_commits.py — all handlers
  • AV_06ui_tree.py — all handlers
  • AV_07ui_blob.py — all handlers
  • AV_08ui_blame.py — all handlers
  • AV_09ui_issues.py — all handlers
  • AV_10ui_proposals.py — all handlers
  • AV_11ui_releases.py — all handlers
  • AV_12ui_intel.py — all handlers (large file; ~20 handlers)
  • AV_13ui_symbols.py — all handlers
  • AV_14ui_agents.py — all handlers
  • AV_15ui_view.py — all handlers

Phase 3 — Owner-only gate for settings and sessions

  • AV_16ui_repo_settings.py — swap optional_token for require_valid_token; add claims.handle != owner → 403 after resolver.
  • AV_17ui_sessions.py — same owner-only gate.
  • AV_18 — Correct docs/pre-launch-checklist.md:42 — mark as [ ] with a note that SSR layer was not covered.

Phase 4 — Tests

One new test file: tests/test_ssr_visibility.py.

Deliverables:

  • AV_19 — Anonymous GET on a private repo's home page (/{owner}/{slug}) returns 404.
  • AV_20 — Anonymous GET on a private repo's commits page returns 404.
  • AV_21 — Anonymous GET on a private repo's issues page returns 404.
  • AV_22 — Anonymous GET on a public repo's home page returns 200.
  • AV_23 — Authenticated GET (valid MSign token, owner handle) on a private repo returns 200.
  • AV_24 — Unauthenticated GET on /{owner}/{slug}/settings returns 401 (not 200 or 404).
  • AV_25 — Authenticated non-owner GET on /{owner}/{slug}/settings returns 403.
  • AV_26 — Authenticated owner GET on /{owner}/{slug}/settings returns 200.

Acceptance criteria

  1. Anonymous HTTP GET on any page of a private repo returns 404 — not 200, not 403.
  2. Authenticated GET (valid MSign token, owner handle) on a private repo returns 200.
  3. Public repos remain accessible without any token.
  4. Repo settings and sessions pages require a valid token from the repo owner; non-owner returns 403; no token returns 401.
  5. AV_19–AV_26 all green.
  6. No regression to existing JSON API auth tests.

Out of scope

  • Collaborator access to private repos (future: when team membership is implemented, the visibility check expands to owner OR collaborator; for now, owner-only is correct).
  • Browser "signed-in" state / magic links — this issue gates only on MSign token presence, which is CLI/API-only for now. Web UI users cannot access private repos until magic links exist.
  • Changes to MSign verification logic or token format.
  • Rate limiting or audit logging of 404 responses on private repos.
Activity1
gabriel opened this issue 3 days ago
gabriel 3 days ago

All 26 deliverables complete. Tests AV_19–AV_26 live in tests/test_ssr_visibility.py — 8/8 green. Also fixed test_musehub_ui_settings.py and test_musehub_ui_settings_ssr.py which were broken by the Phase 3 require_valid_token gate (22 tests updated to use auth_headers + owner=testuser). Merged to dev and pushed.

closed this issue 3 days ago