test_authz_wire_push.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Regression tests for musehub issue #131 — wire-protocol write authorization. |
| 2 | |
| 3 | Confirmed gap: several wire-protocol write endpoints (push/unpack-mpack, |
| 4 | push/mpack-presign, releases, tags, version-tags) depended only on |
| 5 | ``require_valid_token`` — proof the request carries *some* registered |
| 6 | identity's valid MSign signature — with no check that the signer has |
| 7 | write/admin access to the *specific repo* being written to. Any registered |
| 8 | identity could push to any repo, public or private, regardless of |
| 9 | collaborator status. |
| 10 | |
| 11 | AUTHZ_01 / AUTHZ_02 (from the issue): a non-collaborator must be rejected on |
| 12 | both a private repo and a public repo. Push is not like opening an issue — |
| 13 | direct writes to a repo's branches always require owner or write/admin |
| 14 | collaborator status, regardless of visibility. |
| 15 | |
| 16 | These tests assert the *fixed* (secure) behavior directly. Before the fix in |
| 17 | ``musehub/api/routes/wire.py`` (``_assert_writable``), every rejection |
| 18 | assertion below fails — i.e. the attacker's request succeeds when it must |
| 19 | not. That is the vulnerability this file proves closed. |
| 20 | """ |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import logging |
| 24 | from datetime import datetime, timezone |
| 25 | from datetime import datetime, timezone |
| 26 | |
| 27 | import msgpack |
| 28 | import pytest |
| 29 | import pytest_asyncio |
| 30 | from httpx import AsyncClient, ASGITransport |
| 31 | from sqlalchemy.ext.asyncio import AsyncSession |
| 32 | |
| 33 | from muse.core.mpack import build_wire_mpack |
| 34 | from muse.core.types import blob_id, fake_id |
| 35 | from musehub.auth.dependencies import require_valid_token |
| 36 | from musehub.auth.request_signing import MSignContext |
| 37 | from musehub.core.genesis import compute_collaborator_id, compute_identity_id |
| 38 | from musehub.db.musehub_collaborator_models import MusehubCollaborator |
| 39 | from musehub.db.musehub_repo_models import MusehubRepo |
| 40 | from musehub.db.database import get_db |
| 41 | from musehub.main import app |
| 42 | from musehub.services.musehub_repository import create_repo |
| 43 | |
| 44 | |
| 45 | def utc_now_iso() -> str: |
| 46 | return datetime.now(timezone.utc).isoformat() |
| 47 | |
| 48 | logger = logging.getLogger(__name__) |
| 49 | |
| 50 | _OWNER = "alice-authz" |
| 51 | _OWNER_IDENTITY_ID = compute_identity_id(_OWNER.encode()) |
| 52 | _ATTACKER = "mallory-authz" |
| 53 | _ATTACKER_IDENTITY_ID = compute_identity_id(_ATTACKER.encode()) |
| 54 | _WRITE_COLLAB = "writer-authz" |
| 55 | _WRITE_COLLAB_IDENTITY_ID = compute_identity_id(_WRITE_COLLAB.encode()) |
| 56 | _READ_COLLAB = "reader-authz" |
| 57 | _READ_COLLAB_IDENTITY_ID = compute_identity_id(_READ_COLLAB.encode()) |
| 58 | _PENDING_COLLAB = "pending-authz" |
| 59 | _PENDING_COLLAB_IDENTITY_ID = compute_identity_id(_PENDING_COLLAB.encode()) |
| 60 | |
| 61 | _MPACK_BYTES = build_wire_mpack({"objects": [], "commits": [], "snapshots": []}) |
| 62 | _MPACK_KEY = blob_id(_MPACK_BYTES) |
| 63 | _HEAD = fake_id("authz-tip-commit") |
| 64 | |
| 65 | |
| 66 | def _claims(handle: str, identity_id: str) -> MSignContext: |
| 67 | return MSignContext(handle=handle, identity_id=identity_id, is_agent=False, is_admin=False) |
| 68 | |
| 69 | |
| 70 | _OWNER_CLAIMS = _claims(_OWNER, _OWNER_IDENTITY_ID) |
| 71 | _ATTACKER_CLAIMS = _claims(_ATTACKER, _ATTACKER_IDENTITY_ID) |
| 72 | _WRITE_COLLAB_CLAIMS = _claims(_WRITE_COLLAB, _WRITE_COLLAB_IDENTITY_ID) |
| 73 | _READ_COLLAB_CLAIMS = _claims(_READ_COLLAB, _READ_COLLAB_IDENTITY_ID) |
| 74 | _PENDING_COLLAB_CLAIMS = _claims(_PENDING_COLLAB, _PENDING_COLLAB_IDENTITY_ID) |
| 75 | |
| 76 | |
| 77 | # --------------------------------------------------------------------------- |
| 78 | # Fixtures |
| 79 | # --------------------------------------------------------------------------- |
| 80 | |
| 81 | @pytest_asyncio.fixture() |
| 82 | async def db(db_session: AsyncSession) -> AsyncSession: |
| 83 | return db_session |
| 84 | |
| 85 | |
| 86 | async def _make_client(db_session: AsyncSession, claims: MSignContext) -> AsyncClient: |
| 87 | async def _override_db() -> None: |
| 88 | yield db_session |
| 89 | |
| 90 | app.dependency_overrides[get_db] = _override_db |
| 91 | app.dependency_overrides[require_valid_token] = lambda: claims |
| 92 | return AsyncClient(transport=ASGITransport(app=app), base_url="https://localhost:1337") |
| 93 | |
| 94 | |
| 95 | async def _make_repo(db_session: AsyncSession, *, name: str, visibility: str) -> MusehubRepo: |
| 96 | r = await create_repo( |
| 97 | db_session, |
| 98 | name=name, |
| 99 | owner=_OWNER, |
| 100 | owner_user_id=_OWNER_IDENTITY_ID, |
| 101 | visibility=visibility, |
| 102 | initialize=False, |
| 103 | ) |
| 104 | await db_session.commit() |
| 105 | return r |
| 106 | |
| 107 | |
| 108 | async def _add_collaborator( |
| 109 | db_session: AsyncSession, |
| 110 | repo: MusehubRepo, |
| 111 | *, |
| 112 | handle: str, |
| 113 | identity_id: str, |
| 114 | permission: str, |
| 115 | accepted: bool, |
| 116 | ) -> None: |
| 117 | created_at = utc_now_iso() |
| 118 | row = MusehubCollaborator( |
| 119 | id=compute_collaborator_id(repo.repo_id, identity_id, created_at), |
| 120 | repo_id=repo.repo_id, |
| 121 | identity_handle=handle, |
| 122 | permission=permission, |
| 123 | ) |
| 124 | db_session.add(row) |
| 125 | await db_session.flush() |
| 126 | if accepted: |
| 127 | row.accepted_at = row.invited_at |
| 128 | await db_session.commit() |
| 129 | |
| 130 | |
| 131 | @pytest_asyncio.fixture(autouse=True) |
| 132 | async def mock_backend(): |
| 133 | from unittest.mock import AsyncMock, MagicMock, patch |
| 134 | |
| 135 | mock = MagicMock() |
| 136 | mock.get_mpack = AsyncMock(return_value=_MPACK_BYTES) |
| 137 | mock.presign_mpack_put = AsyncMock(return_value="https://example.invalid/presigned") |
| 138 | with patch("musehub.services.musehub_wire.get_backend", return_value=mock), \ |
| 139 | patch("musehub.services.musehub_wire_push.get_backend", return_value=mock), \ |
| 140 | patch("musehub.storage.backends.get_backend", return_value=mock), \ |
| 141 | patch("musehub.storage.get_backend", return_value=mock): |
| 142 | yield mock |
| 143 | |
| 144 | |
| 145 | def _unpack_body(mpack_key: str = _MPACK_KEY, branch: str = "main") -> bytes: |
| 146 | return msgpack.packb( |
| 147 | {"mpack_key": mpack_key, "branch": branch, "head": "", "commits_count": 0, "blobs_count": 0}, |
| 148 | use_bin_type=True, |
| 149 | ) |
| 150 | |
| 151 | |
| 152 | def _presign_body() -> bytes: |
| 153 | return msgpack.packb({"mpack_key": _MPACK_KEY, "size_bytes": len(_MPACK_BYTES)}, use_bin_type=True) |
| 154 | |
| 155 | |
| 156 | # --------------------------------------------------------------------------- |
| 157 | # AUTHZ_01 — private repo, no collaborator record → push rejected |
| 158 | # --------------------------------------------------------------------------- |
| 159 | |
| 160 | @pytest.mark.asyncio |
| 161 | async def test_authz01_private_repo_non_collaborator_push_rejected(db_session: AsyncSession) -> None: |
| 162 | repo = await _make_repo(db_session, name="authz01-private", visibility="private") |
| 163 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 164 | async with client: |
| 165 | resp = await client.post( |
| 166 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 167 | content=_unpack_body(), |
| 168 | headers={"Content-Type": "application/x-msgpack"}, |
| 169 | ) |
| 170 | app.dependency_overrides.clear() |
| 171 | assert resp.status_code == 403, f"non-collaborator pushed to private repo: {resp.status_code} {resp.text}" |
| 172 | |
| 173 | |
| 174 | # --------------------------------------------------------------------------- |
| 175 | # AUTHZ_02 — public repo, no collaborator record → push STILL rejected |
| 176 | # --------------------------------------------------------------------------- |
| 177 | |
| 178 | @pytest.mark.asyncio |
| 179 | async def test_authz02_public_repo_non_collaborator_push_rejected(db_session: AsyncSession) -> None: |
| 180 | """Public visibility affects *reads* and issue/proposal creation, not direct code push.""" |
| 181 | repo = await _make_repo(db_session, name="authz02-public", visibility="public") |
| 182 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 183 | async with client: |
| 184 | resp = await client.post( |
| 185 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 186 | content=_unpack_body(), |
| 187 | headers={"Content-Type": "application/x-msgpack"}, |
| 188 | ) |
| 189 | app.dependency_overrides.clear() |
| 190 | assert resp.status_code == 403, f"non-collaborator pushed to public repo: {resp.status_code} {resp.text}" |
| 191 | |
| 192 | |
| 193 | # --------------------------------------------------------------------------- |
| 194 | # Regression guards — legitimate access must keep working |
| 195 | # --------------------------------------------------------------------------- |
| 196 | |
| 197 | @pytest.mark.asyncio |
| 198 | async def test_owner_can_push(db_session: AsyncSession) -> None: |
| 199 | repo = await _make_repo(db_session, name="authz-owner-ok", visibility="private") |
| 200 | client = await _make_client(db_session, _OWNER_CLAIMS) |
| 201 | async with client: |
| 202 | resp = await client.post( |
| 203 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 204 | content=_unpack_body(), |
| 205 | headers={"Content-Type": "application/x-msgpack"}, |
| 206 | ) |
| 207 | app.dependency_overrides.clear() |
| 208 | assert resp.status_code == 200, resp.text |
| 209 | |
| 210 | |
| 211 | @pytest.mark.asyncio |
| 212 | async def test_accepted_write_collaborator_can_push(db_session: AsyncSession) -> None: |
| 213 | repo = await _make_repo(db_session, name="authz-writer-ok", visibility="private") |
| 214 | await _add_collaborator( |
| 215 | db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_IDENTITY_ID, |
| 216 | permission="write", accepted=True, |
| 217 | ) |
| 218 | client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS) |
| 219 | async with client: |
| 220 | resp = await client.post( |
| 221 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 222 | content=_unpack_body(), |
| 223 | headers={"Content-Type": "application/x-msgpack"}, |
| 224 | ) |
| 225 | app.dependency_overrides.clear() |
| 226 | assert resp.status_code == 200, resp.text |
| 227 | |
| 228 | |
| 229 | @pytest.mark.asyncio |
| 230 | async def test_read_only_collaborator_cannot_push(db_session: AsyncSession) -> None: |
| 231 | repo = await _make_repo(db_session, name="authz-reader-blocked", visibility="private") |
| 232 | await _add_collaborator( |
| 233 | db_session, repo, handle=_READ_COLLAB, identity_id=_READ_COLLAB_IDENTITY_ID, |
| 234 | permission="read", accepted=True, |
| 235 | ) |
| 236 | client = await _make_client(db_session, _READ_COLLAB_CLAIMS) |
| 237 | async with client: |
| 238 | resp = await client.post( |
| 239 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 240 | content=_unpack_body(), |
| 241 | headers={"Content-Type": "application/x-msgpack"}, |
| 242 | ) |
| 243 | app.dependency_overrides.clear() |
| 244 | assert resp.status_code == 403, resp.text |
| 245 | |
| 246 | |
| 247 | @pytest.mark.asyncio |
| 248 | async def test_pending_write_collaborator_cannot_push(db_session: AsyncSession) -> None: |
| 249 | """Invited but not yet accepted -- must not grant access.""" |
| 250 | repo = await _make_repo(db_session, name="authz-pending-blocked", visibility="private") |
| 251 | await _add_collaborator( |
| 252 | db_session, repo, handle=_PENDING_COLLAB, identity_id=_PENDING_COLLAB_IDENTITY_ID, |
| 253 | permission="write", accepted=False, |
| 254 | ) |
| 255 | client = await _make_client(db_session, _PENDING_COLLAB_CLAIMS) |
| 256 | async with client: |
| 257 | resp = await client.post( |
| 258 | f"/{_OWNER}/{repo.slug}/push/unpack-mpack", |
| 259 | content=_unpack_body(), |
| 260 | headers={"Content-Type": "application/x-msgpack"}, |
| 261 | ) |
| 262 | app.dependency_overrides.clear() |
| 263 | assert resp.status_code == 403, resp.text |
| 264 | |
| 265 | |
| 266 | # --------------------------------------------------------------------------- |
| 267 | # Sibling wire-protocol write endpoints — same class of bug |
| 268 | # --------------------------------------------------------------------------- |
| 269 | |
| 270 | @pytest.mark.asyncio |
| 271 | async def test_presign_rejects_non_collaborator(db_session: AsyncSession) -> None: |
| 272 | repo = await _make_repo(db_session, name="authz-presign-blocked", visibility="private") |
| 273 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 274 | async with client: |
| 275 | resp = await client.post( |
| 276 | f"/{_OWNER}/{repo.slug}/push/mpack-presign", |
| 277 | content=_presign_body(), |
| 278 | headers={"Content-Type": "application/x-msgpack"}, |
| 279 | ) |
| 280 | app.dependency_overrides.clear() |
| 281 | assert resp.status_code == 403, resp.text |
| 282 | |
| 283 | |
| 284 | @pytest.mark.asyncio |
| 285 | async def test_create_release_rejects_non_collaborator(db_session: AsyncSession) -> None: |
| 286 | repo = await _make_repo(db_session, name="authz-release-blocked", visibility="public") |
| 287 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 288 | body = msgpack.packb( |
| 289 | {"tag": "v1.0.0-attacker", "title": "hijacked", "notes": "", "assets": []}, |
| 290 | use_bin_type=True, |
| 291 | ) |
| 292 | async with client: |
| 293 | resp = await client.post( |
| 294 | f"/{_OWNER}/{repo.slug}/releases", |
| 295 | content=body, |
| 296 | headers={"Content-Type": "application/x-msgpack"}, |
| 297 | ) |
| 298 | app.dependency_overrides.clear() |
| 299 | assert resp.status_code == 403, resp.text |
| 300 | |
| 301 | |
| 302 | @pytest.mark.asyncio |
| 303 | async def test_push_tags_rejects_non_collaborator(db_session: AsyncSession) -> None: |
| 304 | repo = await _make_repo(db_session, name="authz-tags-blocked", visibility="public") |
| 305 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 306 | body = msgpack.packb( |
| 307 | {"tags": [{"tag_id": fake_id("attacker-tag"), "commit_id": _HEAD, "tag": "emotion:joyful", "created_at": utc_now_iso()}]}, |
| 308 | use_bin_type=True, |
| 309 | ) |
| 310 | async with client: |
| 311 | resp = await client.post( |
| 312 | f"/{_OWNER}/{repo.slug}/tags", |
| 313 | content=body, |
| 314 | headers={"Content-Type": "application/x-msgpack"}, |
| 315 | ) |
| 316 | app.dependency_overrides.clear() |
| 317 | assert resp.status_code == 403, resp.text |
| 318 | |
| 319 | |
| 320 | @pytest.mark.asyncio |
| 321 | async def test_push_version_tags_rejects_non_collaborator(db_session: AsyncSession) -> None: |
| 322 | repo = await _make_repo(db_session, name="authz-vtags-blocked", visibility="public") |
| 323 | client = await _make_client(db_session, _ATTACKER_CLAIMS) |
| 324 | body = msgpack.packb( |
| 325 | {"tags": [{"tag": "v9.9.9-attacker", "commit_id": _HEAD}]}, |
| 326 | use_bin_type=True, |
| 327 | ) |
| 328 | async with client: |
| 329 | resp = await client.post( |
| 330 | f"/{_OWNER}/{repo.slug}/version-tags", |
| 331 | content=body, |
| 332 | headers={"Content-Type": "application/x-msgpack"}, |
| 333 | ) |
| 334 | app.dependency_overrides.clear() |
| 335 | assert resp.status_code == 403, resp.text |