gabriel / musehub public
test_authz_wire_push.py python
335 lines 13.1 KB
Raw
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 2 days ago
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
File History 1 commit
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 2 days ago