gabriel / musehub public
test_mpack_size_gates.py python
273 lines 8.9 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 49 days ago
1 """TDD — Phase 1 mpack push security: size gates.
2
3 Three gates, zero trust:
4
5 1. presign rejects size_bytes above _MAX_BUNDLE_BYTES → HTTP 413
6 Fires before a presigned URL is issued — client never gets a URL to abuse.
7
8 2. unpack-mpack rejects wire_bytes above _MAX_BUNDLE_BYTES → HTTP 422
9 Defends against a client that bypassed presign and PUT directly to MinIO.
10
11 3. unpack-mpack rejects commits_count / objects_count above their caps → HTTP 422
12 These are logging inputs, not trusted counts, but bounding them prevents
13 absurd allocations and log lines in the background worker.
14
15 All caps live in musehub.config.Settings so they can be tuned per environment.
16 """
17 from __future__ import annotations
18
19 import msgpack
20 import pytest
21 import pytest_asyncio
22 from httpx import AsyncClient, ASGITransport
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request
26 from musehub.db.database import get_db
27 from musehub.main import app
28
29 pytestmark = pytest.mark.wire
30
31
32 _AUTH_CTX = MSignContext(
33 handle="gabriel",
34 identity_id="sha256:" + "0" * 64,
35 is_agent=False,
36 is_admin=True,
37 )
38
39
40 @pytest_asyncio.fixture()
41 async def client(db_session: AsyncSession) -> None:
42 async def _override_get_db() -> None:
43 yield db_session
44
45 app.dependency_overrides[get_db] = _override_get_db
46 app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX
47 app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX
48
49 async with AsyncClient(
50 transport=ASGITransport(app=app),
51 base_url="https://localhost:1337",
52 ) as c:
53 yield c
54
55 app.dependency_overrides.clear()
56
57
58 @pytest_asyncio.fixture()
59 async def repo(client: AsyncClient) -> None:
60 resp = await client.post(
61 "/api/repos",
62 json={"owner": "gabriel", "name": "size-gates-test", "visibility": "public", "initialize": False},
63 )
64 assert resp.status_code in (200, 201), resp.text
65 data = resp.json()
66 yield data["slug"]
67 await client.delete(f"/api/repos/{data['repoId']}")
68
69
70 # ── Gate 1: presign rejects oversized mpacks ──────────────────────────────
71
72 @pytest.mark.asyncio
73 async def test_presign_rejects_oversized_mpack(
74 client: AsyncClient, repo: str,
75 ) -> None:
76 """POST /push/mpack-presign with size_bytes above cap → 413.
77
78 The cap is enforced before the presigned URL is issued so the client
79 never receives a URL they can abuse.
80 """
81 from musehub.config import get_settings
82 settings = get_settings()
83 oversized = settings.mpack_max_bytes + 1
84
85 resp = await client.post(
86 f"/gabriel/{repo}/push/mpack-presign",
87 content=msgpack.packb(
88 {
89 "mpack_key": "sha256:" + "a" * 64,
90 "size_bytes": oversized,
91 },
92 use_bin_type=True,
93 ),
94 headers={"Content-Type": "application/x-msgpack"},
95 )
96
97 assert resp.status_code == 413, (
98 f"Expected 413 for mpack size {oversized:,} bytes, got {resp.status_code}: {resp.text}"
99 )
100
101
102 @pytest.mark.asyncio
103 async def test_presign_accepts_mpack_at_limit(
104 client: AsyncClient, repo: str,
105 ) -> None:
106 """POST /push/mpack-presign with size_bytes == cap → 200 (not rejected).
107
108 The limit is exclusive: exactly at the cap is still allowed.
109 """
110 from musehub.config import get_settings
111 settings = get_settings()
112 at_limit = settings.mpack_max_bytes
113
114 resp = await client.post(
115 f"/gabriel/{repo}/push/mpack-presign",
116 content=msgpack.packb(
117 {
118 "mpack_key": "sha256:" + "b" * 64,
119 "size_bytes": at_limit,
120 },
121 use_bin_type=True,
122 ),
123 headers={"Content-Type": "application/x-msgpack"},
124 )
125
126 assert resp.status_code == 200, (
127 f"Expected 200 for mpack size == cap ({at_limit:,}), got {resp.status_code}: {resp.text}"
128 )
129
130
131 # ── Gate 2: unpack-mpack rejects oversized wire bytes ─────────────────────
132
133 @pytest.mark.asyncio
134 async def test_unpack_mpack_rejects_oversized_wire_bytes(
135 client: AsyncClient, repo: str, monkeypatch: pytest.MonkeyPatch,
136 ) -> None:
137 """POST /push/unpack-mpack where stored bytes exceed cap → 422.
138
139 We shrink mpack_max_bytes to just below the wire bytes length so the
140 size gate fires without allocating gigabytes in the test process.
141 """
142 from muse.core.mpack import build_wire_mpack
143 from muse.core.types import blob_id
144 from musehub.config import get_settings
145 from musehub.storage.backends import get_backend
146
147 # Build a tiny-but-valid MUSE wire mpack.
148 wire_bytes = build_wire_mpack({"blobs": [], "commits": [], "snapshots": [], "tags": []})
149 mpack_key = blob_id(wire_bytes)
150
151 # Lower the limit to one byte below the actual wire bytes length.
152 settings = get_settings()
153 monkeypatch.setattr(settings, "mpack_max_bytes", len(wire_bytes) - 1)
154
155 backend = get_backend()
156 await backend.put_mpack(mpack_key, wire_bytes)
157
158 resp = await client.post(
159 f"/gabriel/{repo}/push/unpack-mpack",
160 content=msgpack.packb(
161 {
162 "mpack_key": mpack_key,
163 "branch": "main",
164 "head": "sha256:" + "c" * 64,
165 "commits_count": 0,
166 "blobs_count": 0,
167 },
168 use_bin_type=True,
169 ),
170 headers={"Content-Type": "application/x-msgpack"},
171 )
172
173 assert resp.status_code == 422, (
174 f"Expected 422 for wire bytes ({len(wire_bytes):,}) > capped limit ({len(wire_bytes)-1:,}), "
175 f"got {resp.status_code}: {resp.text}"
176 )
177
178
179 # ── Gate 3: unpack-mpack rejects absurd count values ─────────────────────
180
181 @pytest.mark.asyncio
182 async def test_unpack_mpack_rejects_absurd_commits_count(
183 client: AsyncClient, repo: str,
184 ) -> None:
185 """commits_count above cap → 422 at the route layer, before MinIO is touched."""
186 from musehub.config import get_settings
187 settings = get_settings()
188
189 resp = await client.post(
190 f"/gabriel/{repo}/push/unpack-mpack",
191 content=msgpack.packb(
192 {
193 "mpack_key": "sha256:" + "d" * 64,
194 "branch": "main",
195 "head": "sha256:" + "e" * 64,
196 "commits_count": settings.mpack_max_commits + 1,
197 "objects_count": 1,
198 },
199 use_bin_type=True,
200 ),
201 headers={"Content-Type": "application/x-msgpack"},
202 )
203
204 assert resp.status_code == 422, (
205 f"Expected 422 for commits_count above cap, got {resp.status_code}: {resp.text}"
206 )
207
208
209 @pytest.mark.asyncio
210 async def test_unpack_mpack_rejects_absurd_objects_count(
211 client: AsyncClient, repo: str,
212 ) -> None:
213 """objects_count above cap → 422 at the route layer, before MinIO is touched."""
214 from musehub.config import get_settings
215 settings = get_settings()
216
217 resp = await client.post(
218 f"/gabriel/{repo}/push/unpack-mpack",
219 content=msgpack.packb(
220 {
221 "mpack_key": "sha256:" + "f" * 64,
222 "branch": "main",
223 "head": "sha256:" + "a" * 64,
224 "commits_count": 1,
225 "objects_count": settings.mpack_max_objects + 1,
226 },
227 use_bin_type=True,
228 ),
229 headers={"Content-Type": "application/x-msgpack"},
230 )
231
232 assert resp.status_code == 422, (
233 f"Expected 422 for objects_count above cap, got {resp.status_code}: {resp.text}"
234 )
235
236
237 @pytest.mark.asyncio
238 async def test_unpack_mpack_accepts_counts_at_limit(
239 client: AsyncClient, repo: str,
240 ) -> None:
241 """commits_count and blobs_count exactly at cap → count gate does not fire.
242
243 The subsequent storage GET fails (key not stored), producing a 422 from
244 the "mpack not found" path — proving the count gate was not the cause.
245 """
246 from musehub.config import get_settings
247 settings = get_settings()
248
249 # Use a key that is not stored in MemoryBackend → get_mpack returns None → 422.
250 unknown_key = "sha256:" + "0" * 64
251
252 resp = await client.post(
253 f"/gabriel/{repo}/push/unpack-mpack",
254 content=msgpack.packb(
255 {
256 "mpack_key": unknown_key,
257 "branch": "main",
258 "head": "sha256:" + "b" * 64,
259 "commits_count": settings.mpack_max_commits,
260 "blobs_count": settings.mpack_max_objects,
261 },
262 use_bin_type=True,
263 ),
264 headers={"Content-Type": "application/x-msgpack"},
265 )
266
267 # Count gate did not fire. The 422 here is from the missing mpack — correct.
268 assert resp.status_code == 422, resp.text
269 body = resp.json()
270 err = str(body).lower()
271 assert "commits_count" not in err and "blobs_count" not in err, (
272 f"Count gate fired at limit — should only fire above limit. Response: {body}"
273 )
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 49 days ago