"""Regression tests for musehub issue #131 — wire-protocol write authorization. Confirmed gap: several wire-protocol write endpoints (push/unpack-mpack, push/mpack-presign, releases, tags, version-tags) depended only on ``require_valid_token`` — proof the request carries *some* registered identity's valid MSign signature — with no check that the signer has write/admin access to the *specific repo* being written to. Any registered identity could push to any repo, public or private, regardless of collaborator status. AUTHZ_01 / AUTHZ_02 (from the issue): a non-collaborator must be rejected on both a private repo and a public repo. Push is not like opening an issue — direct writes to a repo's branches always require owner or write/admin collaborator status, regardless of visibility. These tests assert the *fixed* (secure) behavior directly. Before the fix in ``musehub/api/routes/wire.py`` (``_assert_writable``), every rejection assertion below fails — i.e. the attacker's request succeeds when it must not. That is the vulnerability this file proves closed. """ from __future__ import annotations import logging from datetime import datetime, timezone from datetime import datetime, timezone import msgpack import pytest import pytest_asyncio from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import AsyncSession from muse.core.mpack import build_wire_mpack from muse.core.types import blob_id, fake_id from musehub.auth.dependencies import require_valid_token from musehub.auth.request_signing import MSignContext from musehub.core.genesis import compute_collaborator_id, compute_identity_id from musehub.db.musehub_collaborator_models import MusehubCollaborator from musehub.db.musehub_repo_models import MusehubRepo from musehub.db.database import get_db from musehub.main import app from musehub.services.musehub_repository import create_repo def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() logger = logging.getLogger(__name__) _OWNER = "alice-authz" _OWNER_IDENTITY_ID = compute_identity_id(_OWNER.encode()) _ATTACKER = "mallory-authz" _ATTACKER_IDENTITY_ID = compute_identity_id(_ATTACKER.encode()) _WRITE_COLLAB = "writer-authz" _WRITE_COLLAB_IDENTITY_ID = compute_identity_id(_WRITE_COLLAB.encode()) _READ_COLLAB = "reader-authz" _READ_COLLAB_IDENTITY_ID = compute_identity_id(_READ_COLLAB.encode()) _PENDING_COLLAB = "pending-authz" _PENDING_COLLAB_IDENTITY_ID = compute_identity_id(_PENDING_COLLAB.encode()) _MPACK_BYTES = build_wire_mpack({"objects": [], "commits": [], "snapshots": []}) _MPACK_KEY = blob_id(_MPACK_BYTES) _HEAD = fake_id("authz-tip-commit") def _claims(handle: str, identity_id: str) -> MSignContext: return MSignContext(handle=handle, identity_id=identity_id, is_agent=False, is_admin=False) _OWNER_CLAIMS = _claims(_OWNER, _OWNER_IDENTITY_ID) _ATTACKER_CLAIMS = _claims(_ATTACKER, _ATTACKER_IDENTITY_ID) _WRITE_COLLAB_CLAIMS = _claims(_WRITE_COLLAB, _WRITE_COLLAB_IDENTITY_ID) _READ_COLLAB_CLAIMS = _claims(_READ_COLLAB, _READ_COLLAB_IDENTITY_ID) _PENDING_COLLAB_CLAIMS = _claims(_PENDING_COLLAB, _PENDING_COLLAB_IDENTITY_ID) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture() async def db(db_session: AsyncSession) -> AsyncSession: return db_session async def _make_client(db_session: AsyncSession, claims: MSignContext) -> AsyncClient: async def _override_db() -> None: yield db_session app.dependency_overrides[get_db] = _override_db app.dependency_overrides[require_valid_token] = lambda: claims return AsyncClient(transport=ASGITransport(app=app), base_url="https://localhost:1337") async def _make_repo(db_session: AsyncSession, *, name: str, visibility: str) -> MusehubRepo: r = await create_repo( db_session, name=name, owner=_OWNER, owner_user_id=_OWNER_IDENTITY_ID, visibility=visibility, initialize=False, ) await db_session.commit() return r async def _add_collaborator( db_session: AsyncSession, repo: MusehubRepo, *, handle: str, identity_id: str, permission: str, accepted: bool, ) -> None: created_at = utc_now_iso() row = MusehubCollaborator( id=compute_collaborator_id(repo.repo_id, identity_id, created_at), repo_id=repo.repo_id, identity_handle=handle, permission=permission, ) db_session.add(row) await db_session.flush() if accepted: row.accepted_at = row.invited_at await db_session.commit() @pytest_asyncio.fixture(autouse=True) async def mock_backend(): from unittest.mock import AsyncMock, MagicMock, patch mock = MagicMock() mock.get_mpack = AsyncMock(return_value=_MPACK_BYTES) mock.presign_mpack_put = AsyncMock(return_value="https://example.invalid/presigned") with patch("musehub.services.musehub_wire.get_backend", return_value=mock), \ patch("musehub.services.musehub_wire_push.get_backend", return_value=mock), \ patch("musehub.storage.backends.get_backend", return_value=mock), \ patch("musehub.storage.get_backend", return_value=mock): yield mock def _unpack_body(mpack_key: str = _MPACK_KEY, branch: str = "main") -> bytes: return msgpack.packb( {"mpack_key": mpack_key, "branch": branch, "head": "", "commits_count": 0, "blobs_count": 0}, use_bin_type=True, ) def _presign_body() -> bytes: return msgpack.packb({"mpack_key": _MPACK_KEY, "size_bytes": len(_MPACK_BYTES)}, use_bin_type=True) # --------------------------------------------------------------------------- # AUTHZ_01 — private repo, no collaborator record → push rejected # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_authz01_private_repo_non_collaborator_push_rejected(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz01-private", visibility="private") client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, f"non-collaborator pushed to private repo: {resp.status_code} {resp.text}" # --------------------------------------------------------------------------- # AUTHZ_02 — public repo, no collaborator record → push STILL rejected # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_authz02_public_repo_non_collaborator_push_rejected(db_session: AsyncSession) -> None: """Public visibility affects *reads* and issue/proposal creation, not direct code push.""" repo = await _make_repo(db_session, name="authz02-public", visibility="public") client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, f"non-collaborator pushed to public repo: {resp.status_code} {resp.text}" # --------------------------------------------------------------------------- # Regression guards — legitimate access must keep working # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_owner_can_push(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-owner-ok", visibility="private") client = await _make_client(db_session, _OWNER_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_accepted_write_collaborator_can_push(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-writer-ok", visibility="private") await _add_collaborator( db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_IDENTITY_ID, permission="write", accepted=True, ) client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 200, resp.text @pytest.mark.asyncio async def test_read_only_collaborator_cannot_push(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-reader-blocked", visibility="private") await _add_collaborator( db_session, repo, handle=_READ_COLLAB, identity_id=_READ_COLLAB_IDENTITY_ID, permission="read", accepted=True, ) client = await _make_client(db_session, _READ_COLLAB_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_pending_write_collaborator_cannot_push(db_session: AsyncSession) -> None: """Invited but not yet accepted -- must not grant access.""" repo = await _make_repo(db_session, name="authz-pending-blocked", visibility="private") await _add_collaborator( db_session, repo, handle=_PENDING_COLLAB, identity_id=_PENDING_COLLAB_IDENTITY_ID, permission="write", accepted=False, ) client = await _make_client(db_session, _PENDING_COLLAB_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text # --------------------------------------------------------------------------- # Sibling wire-protocol write endpoints — same class of bug # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_presign_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-presign-blocked", visibility="private") client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/push/mpack-presign", content=_presign_body(), headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_create_release_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-release-blocked", visibility="public") client = await _make_client(db_session, _ATTACKER_CLAIMS) body = msgpack.packb( {"tag": "v1.0.0-attacker", "title": "hijacked", "notes": "", "assets": []}, use_bin_type=True, ) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/releases", content=body, headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_push_tags_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-tags-blocked", visibility="public") client = await _make_client(db_session, _ATTACKER_CLAIMS) body = msgpack.packb( {"tags": [{"tag_id": fake_id("attacker-tag"), "commit_id": _HEAD, "tag": "emotion:joyful", "created_at": utc_now_iso()}]}, use_bin_type=True, ) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/tags", content=body, headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_push_version_tags_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="authz-vtags-blocked", visibility="public") client = await _make_client(db_session, _ATTACKER_CLAIMS) body = msgpack.packb( {"tags": [{"tag": "v9.9.9-attacker", "commit_id": _HEAD}]}, use_bin_type=True, ) async with client: resp = await client.post( f"/{_OWNER}/{repo.slug}/version-tags", content=body, headers={"Content-Type": "application/x-msgpack"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text