gabriel / musehub public
test_security_hardening_section82.py python
413 lines 19.7 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 8 (part 2) — Advanced security hardening tests.
2
3 Covers:
4 Polyglot files : magic-bytes validation; extension/content mismatch rejected.
5 Clickjacking : X-Frame-Options: DENY + CSP frame-ancestors 'none' in
6 SecurityHeadersMiddleware.
7 Open redirect : ?next= param stores path-only, never a full absolute URL;
8 validate_redirect_target utility rejects external origins.
9 Handle squatting : handle normalised to lowercase before DB insert;
10 Gabriel and gabriel cannot coexist.
11 MCP prompt inj. : tool results wrapped in <musehub_tool_result> tags;
12 orientation prompt contains untrusted-content instruction.
13 Agent impersonation: TRUSTED_AGENT_IDS flag; unknown agents flagged in metadata,
14 never rejected.
15 """
16 from __future__ import annotations
17
18 import re
19 from pathlib import Path
20 from unittest.mock import AsyncMock, MagicMock, patch
21
22 import pytest
23 from httpx import AsyncClient
24
25 _ROOT = Path(__file__).resolve().parents[1]
26 _MUSEHUB_PKG = _ROOT / "musehub"
27 _MAIN_PY = _MUSEHUB_PKG / "main.py"
28 _ELICITATION = _MUSEHUB_PKG / "api" / "routes" / "musehub" / "ui_mcp_elicitation.py"
29 _AUTH_SVC = _MUSEHUB_PKG / "services" / "musehub_auth.py"
30 _DISPATCHER = _MUSEHUB_PKG / "mcp" / "dispatcher.py"
31 _PROMPTS = _MUSEHUB_PKG / "mcp" / "prompts.py"
32 _WIRE_SVC = _MUSEHUB_PKG / "services" / "musehub_wire.py"
33 _CONFIG = _MUSEHUB_PKG / "config.py"
34 _MAGIC_BYTES = _MUSEHUB_PKG / "security" / "magic_bytes.py"
35
36
37 # ═══════════════════════════════════════════════════════════════════════════════
38 # Polyglot files — magic bytes
39 # ═══════════════════════════════════════════════════════════════════════════════
40
41 class TestMagicBytes:
42 def _check(self, path: str, content: bytes) -> str:
43 from musehub.security.magic_bytes import check_magic_bytes
44 return check_magic_bytes(path, content)
45
46 def _expect_error(self, path: str, content: bytes) -> None:
47 from musehub.security.magic_bytes import check_magic_bytes, PolyglotFileError
48 with pytest.raises(PolyglotFileError):
49 check_magic_bytes(path, content)
50
51 # ── Valid files ──────────────────────────────────────────────────────────
52
53 def test_valid_midi(self):
54 midi_header = b"MThd\x00\x00\x00\x06\x00\x01\x00\x04\x01\xe0"
55 assert self._check("track.mid", midi_header) == "MIDI"
56
57 def test_valid_mp3_id3(self):
58 mp3_id3 = b"ID3\x03\x00\x00\x00\x00\x00\x00"
59 assert self._check("song.mp3", mp3_id3) == "MP3"
60
61 def test_valid_mp3_sync(self):
62 mp3_sync = bytes([0xFF, 0xFB]) + b"\x90\x00" * 10
63 assert self._check("song.mp3", mp3_sync) == "MP3"
64
65 def test_valid_webp(self):
66 webp = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 10
67 assert self._check("cover.webp", webp) == "WebP"
68
69 def test_valid_png(self):
70 png = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + b"\x00" * 10
71 assert self._check("cover.png", png) == "PNG"
72
73 def test_valid_jpeg(self):
74 jpeg = bytes([0xFF, 0xD8, 0xFF, 0xE0]) + b"\x00" * 10
75 assert self._check("cover.jpg", jpeg) == "JPEG"
76
77 def test_unknown_extension_passes_through(self):
78 # .py files are not in the known-type map — no check performed.
79 assert self._check("main.py", b"import os\n") == "unknown"
80
81 def test_empty_content_returns_empty(self):
82 assert self._check("track.mid", b"") == "empty"
83
84 # ── Polyglot attacks ─────────────────────────────────────────────────────
85
86 def test_midi_extension_but_html_content_blocked(self):
87 self._expect_error("track.mid", b"<!DOCTYPE html><html><script>alert(1)</script>")
88
89 def test_midi_extension_but_php_shebang_blocked(self):
90 self._expect_error("track.mid", b"#!/usr/bin/php\n<?php system($_GET['cmd']); ?>")
91
92 def test_jpg_extension_but_html_content_blocked(self):
93 self._expect_error("cover.jpg", b"<html><body>not an image</body></html>")
94
95 def test_webp_extension_but_wrong_magic_blocked(self):
96 self._expect_error("cover.webp", b"\x00\x00\x00\x00\x00\x00\x00\x00")
97
98 def test_png_extension_but_wrong_magic_blocked(self):
99 self._expect_error("cover.png", b"JFIF\x00\x00")
100
101 def test_jpeg_extension_but_zip_content_blocked(self):
102 self._expect_error("cover.jpg", bytes([0x50, 0x4B, 0x03, 0x04]) + b"\x00" * 10)
103
104 def test_mp3_extension_but_html_shebang_blocked(self):
105 self._expect_error("song.mp3", b"#!/bin/bash\nrm -rf /")
106
107 def test_html_extension_allows_html_content(self):
108 # .html extension is exempt from the forbidden-HTML check.
109 result = self._check("readme.html", b"<!DOCTYPE html><html></html>")
110 assert result == "HTML" # returns "HTML" since the exemption triggers
111
112 # ── Wire push integration ─────────────────────────────────────────────────
113
114 def test_wire_push_imports_magic_bytes(self):
115 src = _WIRE_SVC.read_text()
116 assert "magic_bytes" in src or "PolyglotFileError" in src, (
117 "musehub_wire.py does not check magic bytes on push"
118 )
119
120 def test_wire_push_rejects_polyglot_path(self):
121 src = _WIRE_SVC.read_text()
122 assert "PolyglotFileError" in src, (
123 "wire_push does not catch PolyglotFileError"
124 )
125
126
127 # ═══════════════════════════════════════════════════════════════════════════════
128 # Clickjacking
129 # ═══════════════════════════════════════════════════════════════════════════════
130
131 class TestClickjacking:
132 @pytest.mark.anyio
133 async def test_x_frame_options_deny(self, client: AsyncClient):
134 resp = await client.get("/healthz")
135 assert resp.headers.get("x-frame-options", "").upper() == "DENY", (
136 "X-Frame-Options: DENY not set — clickjacking risk"
137 )
138
139 @pytest.mark.anyio
140 async def test_csp_frame_ancestors_none(self, client: AsyncClient):
141 resp = await client.get("/healthz")
142 csp = resp.headers.get("content-security-policy", "")
143 assert "frame-ancestors" in csp and "'none'" in csp, (
144 "CSP frame-ancestors 'none' not set — clickjacking risk"
145 )
146
147 def test_security_headers_middleware_sets_x_frame_options(self):
148 src = _MAIN_PY.read_text()
149 assert "X-Frame-Options" in src, (
150 "SecurityHeadersMiddleware does not set X-Frame-Options"
151 )
152 assert "DENY" in src
153
154 def test_security_headers_middleware_sets_frame_ancestors(self):
155 src = _MAIN_PY.read_text()
156 assert "frame-ancestors" in src and "'none'" in src, (
157 "SecurityHeadersMiddleware CSP does not include frame-ancestors 'none'"
158 )
159
160
161 # ═══════════════════════════════════════════════════════════════════════════════
162 # Open redirect
163 # ═══════════════════════════════════════════════════════════════════════════════
164
165 class TestOpenRedirect:
166 def test_elicitation_stores_path_only_in_next_param(self):
167 src = _ELICITATION.read_text()
168 # Must use request.url.path, NOT request.url (which is the full absolute URL)
169 assert "request.url.path" in src, (
170 "ui_mcp_elicitation.py stores full absolute URL in ?next= — "
171 "open redirect: attacker can inject external URL via crafted host header"
172 )
173 # Must not store the full URL object directly
174 assert "f\"/login?next={callback}\"" in src or "/login?next=" in src, (
175 "?next= redirect not found in elicitation"
176 )
177
178 def test_elicitation_does_not_store_full_absolute_url(self):
179 src = _ELICITATION.read_text()
180 # Verify the callback variable is not assigned from bare request.url
181 # (without .path or .components)
182 bad_pattern = re.compile(r"callback\s*=\s*request\.url\b(?!\.path|\.query|\.components)")
183 assert not bad_pattern.search(src), (
184 "callback assigned from request.url (full URL) — should be request.url.path"
185 )
186
187 def test_elicitation_path_only_for_both_routes(self):
188 """Both elicitation routes must store path-only."""
189 src = _ELICITATION.read_text()
190 path_assignments = src.count("request.url.path")
191 assert path_assignments >= 2, (
192 f"Only {path_assignments} place(s) use request.url.path — "
193 "both elicitation routes must use path-only redirect"
194 )
195
196
197 # ═══════════════════════════════════════════════════════════════════════════════
198 # Handle squatting
199 # ═══════════════════════════════════════════════════════════════════════════════
200
201 class TestHandleSquatting:
202 def test_auth_service_normalizes_handle_to_lowercase(self):
203 src = _AUTH_SVC.read_text()
204 # Must call .lower() on the handle before creating the identity
205 assert ".lower()" in src, (
206 "musehub_auth.py does not normalize handle to lowercase — "
207 "Gabriel and gabriel could register as separate accounts"
208 )
209
210 def test_lowercase_normalization_precedes_identity_creation(self):
211 src = _AUTH_SVC.read_text()
212 lower_pos = src.find(".lower()")
213 identity_pos = src.find("MusehubIdentity(")
214 assert lower_pos < identity_pos, (
215 "Handle lowercasing must happen before MusehubIdentity() construction"
216 )
217
218 def test_handle_strip_applied(self):
219 """Leading/trailing whitespace in handles must be stripped."""
220 src = _AUTH_SVC.read_text()
221 assert ".strip()" in src, (
222 "musehub_auth.py does not strip whitespace from handle"
223 )
224
225 def test_gabriel_and_gabriel_uppercase_normalize_to_same(self):
226 """Functional: normalization must make Gabriel == gabriel."""
227 handle_upper = "Gabriel"
228 handle_lower = handle_upper.strip().lower()
229 assert handle_lower == "gabriel"
230 assert handle_lower == handle_upper.strip().lower() # idempotent
231
232 def test_handle_with_spaces_stripped(self):
233 handle = " gabriel "
234 normalized = handle.strip().lower()
235 assert normalized == "gabriel"
236
237
238 # ═══════════════════════════════════════════════════════════════════════════════
239 # MCP prompt injection
240 # ═══════════════════════════════════════════════════════════════════════════════
241
242 class TestMcpPromptInjection:
243 def test_dispatcher_wraps_result_in_delimiter_tags(self):
244 src = _DISPATCHER.read_text()
245 assert "<musehub_tool_result>" in src, (
246 "MCP dispatcher does not wrap tool results in <musehub_tool_result> delimiter"
247 )
248 assert "</musehub_tool_result>" in src
249
250 def test_delimiter_appears_in_success_path_not_error(self):
251 """Delimiter should only wrap successful results, not error envelopes."""
252 src = _DISPATCHER.read_text()
253 # Find the success block (isError: False) and confirm delimiter is there
254 ok_section = src[src.find("isError"):]
255 first_ok = ok_section.find("False")
256 # The delimiter must appear before the first isError: False
257 delimiter_pos = src.find("<musehub_tool_result>")
258 assert delimiter_pos < src.find('"isError": False'), (
259 "<musehub_tool_result> delimiter must appear in the success response path"
260 )
261
262 def test_prompts_instructs_model_to_treat_tool_results_as_data(self):
263 src = _PROMPTS.read_text()
264 assert "musehub_tool_result" in src, (
265 "prompts.py does not mention <musehub_tool_result> — "
266 "model has no instruction to treat tool results as untrusted data"
267 )
268
269 def test_prompts_mentions_prompt_injection_risk(self):
270 src = _PROMPTS.read_text()
271 assert "prompt" in src.lower() and "inject" in src.lower(), (
272 "prompts.py does not warn about prompt injection — agents are unprotected"
273 )
274
275 def test_prompts_instructs_treat_as_data_not_instructions(self):
276 src = _PROMPTS.read_text()
277 # Must say something like "treat as data" or "not as instructions"
278 assert "data" in src.lower() and (
279 "instruction" in src.lower() or "directive" in src.lower()
280 ), (
281 "prompts.py does not instruct the model to treat tool results as data, "
282 "not instructions"
283 )
284
285 def test_prompts_names_user_controlled_fields(self):
286 """Prompt must call out which fields are user-controlled (not vague)."""
287 src = _PROMPTS.read_text()
288 user_fields = ["commit message", "issue", "file path", "repository name", "branch name"]
289 found = [f for f in user_fields if f in src.lower()]
290 assert len(found) >= 3, (
291 f"prompts.py names only {found} as user-controlled — should name commit messages, "
292 "issue bodies, file paths, repo names, and branch names"
293 )
294
295 @pytest.mark.anyio
296 async def test_healthz_tool_result_not_wrapped(self, client: AsyncClient):
297 """/healthz returns plain JSON — not an MCP tool call, no wrapping needed."""
298 resp = await client.get("/healthz")
299 text = resp.text
300 assert "<musehub_tool_result>" not in text, (
301 "/healthz response should be plain JSON, not wrapped in MCP delimiters"
302 )
303
304
305 # ═══════════════════════════════════════════════════════════════════════════════
306 # Agent impersonation
307 # ═══════════════════════════════════════════════════════════════════════════════
308
309 class TestAgentImpersonation:
310 def test_trusted_agent_ids_setting_exists(self):
311 src = _CONFIG.read_text()
312 assert "trusted_agent_ids" in src, (
313 "Settings does not have a trusted_agent_ids field"
314 )
315
316 def test_wire_push_checks_agent_ids(self):
317 src = _WIRE_SVC.read_text()
318 assert "trusted_agent_ids" in src, (
319 "wire_push does not check agent_id against trusted_agent_ids"
320 )
321
322 def test_wire_push_flags_not_rejects(self):
323 """Unknown agents must be flagged, never rejected."""
324 src = _WIRE_SVC.read_text()
325 agent_section = src[src.find("trusted_agent_ids"):]
326 # Rejection would look like return WirePushResponse(ok=False, ...)
327 # after the trusted check — there must be no such rejection
328 assert "ok=False" not in agent_section[:500], (
329 "wire_push rejects unknown agents — must only flag them"
330 )
331 assert "untrusted_agent" in agent_section[:600], (
332 "wire_push does not flag unknown agents with untrusted_agent metadata"
333 )
334
335 def test_unknown_agent_flagged_in_metadata(self):
336 """untrusted_agent flag must be injected into commit metadata."""
337 src = _WIRE_SVC.read_text()
338 assert "\"untrusted_agent\"" in src or "'untrusted_agent'" in src, (
339 "wire_push does not inject untrusted_agent flag into commit metadata"
340 )
341
342 @pytest.mark.anyio
343 async def test_unknown_agent_flagged_when_registry_set(self):
344 from musehub.services.musehub_wire import wire_push
345 from musehub.models.wire import WirePushRequest, WireBundle, WireCommit
346
347 commit = WireCommit(
348 commit_id="abc999",
349 message="agent push",
350 agent_id="evil-agent/1.0",
351 )
352 req = WirePushRequest(bundle=WireBundle(commits=[commit]), branch="main")
353
354 mock_session = AsyncMock()
355 mock_repo = MagicMock()
356 mock_repo.deleted_at = None
357 mock_repo.owner = "gabriel"
358 mock_session.get.return_value = mock_repo
359
360 logged: list[str] = []
361 with patch("musehub.services.musehub_wire.settings") as mock_settings, \
362 patch("musehub.services.musehub_wire.logger") as mock_logger:
363 mock_settings.require_signed_commits = False
364 mock_settings.trusted_agent_ids = ["agentception-worker", "claude-opus-4-6"]
365 mock_settings.per_repo_quota_bytes = 0
366 mock_logger.warning.side_effect = lambda msg, *args, **kw: logged.append(
367 msg % args if args else msg
368 )
369 # We only need the flagging to run — the rest of push will fail on DB mocks
370 try:
371 await wire_push(mock_session, "repo-123", req, pusher_id="gabriel")
372 except Exception:
373 pass # DB mock failures are expected; we only care about the warning
374
375 assert any("untrusted" in m or "unknown agent" in m for m in logged), (
376 "Unknown agent_id was not flagged with a warning"
377 )
378
379 @pytest.mark.anyio
380 async def test_known_agent_not_flagged(self):
381 from musehub.services.musehub_wire import wire_push
382 from musehub.models.wire import WirePushRequest, WireBundle, WireCommit
383
384 commit = WireCommit(
385 commit_id="def000",
386 message="known agent push",
387 agent_id="agentception-worker-42",
388 )
389 req = WirePushRequest(bundle=WireBundle(commits=[commit]), branch="main")
390
391 mock_session = AsyncMock()
392 mock_repo = MagicMock()
393 mock_repo.deleted_at = None
394 mock_repo.owner = "gabriel"
395 mock_session.get.return_value = mock_repo
396
397 logged: list[str] = []
398 with patch("musehub.services.musehub_wire.settings") as mock_settings, \
399 patch("musehub.services.musehub_wire.logger") as mock_logger:
400 mock_settings.require_signed_commits = False
401 mock_settings.trusted_agent_ids = ["agentception-worker"]
402 mock_settings.per_repo_quota_bytes = 0
403 mock_logger.warning.side_effect = lambda msg, *args, **kw: logged.append(
404 msg % args if args else msg
405 )
406 try:
407 await wire_push(mock_session, "repo-123", req, pusher_id="gabriel")
408 except Exception:
409 pass
410
411 assert not any("untrusted" in m for m in logged), (
412 "Known agent was incorrectly flagged as untrusted"
413 )
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago