""" Security-focused tests — what should be blocked or rejected. These document the current security posture and will catch regressions. Tests marked 'known_gap' document real issues that are not yet fixed; they assert the current (insecure) behaviour so we notice when it changes. """ import pytest # --------------------------------------------------------------------------- # Path traversal # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_files_no_path_traversal_in_filename(client): """ File endpoint must not serve files outside the ALLOWED set. Note: paths containing '..' are URL-normalized before reaching FastAPI. '/files/../../etc/passwd' becomes '/etc/passwd' at the ASGI layer — it never hits the files router. We verify no file content is returned (any non-200 code is safe); 302 redirects to login are fine. """ dangerous = [ "../config.py", "../../etc/passwd", "SOUL.md/../config.py", ".env", "TASKS.json", "CRONS.json", ] for name in dangerous: r = await client.get(f"/files/{name}") assert r.status_code != 200 or "content" not in r.json(), \ f"Got 200 with file content for {name!r} — path traversal may be possible" @pytest.mark.anyio async def test_persona_traversal_blocked_in_chat(client, mock_llm): """Path traversal in persona name must be rejected before any file access.""" for bad in ("../inara", "../../etc", "inara/../inara", "inara\x00extra"): r = await client.post("/chat", json={"message": "hi", "persona": bad}) assert r.status_code == 200 # SSE stream, not HTTP error import json for line in r.text.splitlines(): if line.startswith("data: "): event = json.loads(line[6:]) if event.get("type") == "error": break else: pytest.fail(f"Expected error event for persona={bad!r}, got: {r.text[:200]}") @pytest.mark.anyio async def test_orchestrate_path_traversal(client, mock_llm): r = await client.post("/orchestrate", json={"task": "hi", "persona": "../../etc"}) assert r.status_code == 400 # --------------------------------------------------------------------------- # Signature verification # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_nct_replayed_request_rejected(client): """A request with correct format but wrong HMAC should always be rejected.""" import json, hashlib, hmac as hmac_lib payload = json.dumps({"type": "Create", "actor": {}, "object": {}, "target": {}}).encode() # Use wrong secret to generate sig wrong_sig = hmac_lib.new(b"wrong-secret", b"abc123" + payload, hashlib.sha256).hexdigest() _channels = {"nextcloud": {"bot_secret": "correct-secret", "url": "https://nc.example.com"}} from unittest.mock import patch with patch("routers.nextcloud_talk.get_user_channels", return_value=_channels): r = await client.post( "/webhook/nextcloud/scott", content=payload, headers={ "Content-Type": "application/json", "X-Nextcloud-Talk-Random": "abc123", "X-Nextcloud-Talk-Signature": wrong_sig, }, ) assert r.status_code == 401 # --------------------------------------------------------------------------- # Known gaps — document current behaviour, alert when it changes # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_known_gap__distill_no_app_auth(client): """ KNOWN GAP: /distill/* has no app-layer auth. Anyone reaching port 8000 directly can trigger LLM calls and overwrite memory. Protection is currently nginx-only. This test documents the current state — update when app-layer auth is added. """ r = await client.get("/distill/status") assert r.status_code == 200 # currently open @pytest.mark.anyio async def test_known_gap__files_put_no_app_auth(client): """ KNOWN GAP: PUT /files/{filename} has no app-layer auth. Overwriting SOUL.md or IDENTITY.md changes agent behavior. Protection is currently nginx-only. """ r = await client.put("/files/PROTOCOLS.md", json={"content": "# Modified"}) assert r.status_code == 200 # currently open @pytest.mark.anyio async def test_known_gap__gchat_no_audience_bypass(client, mock_llm): """ KNOWN GAP: Google Chat JWT verification is silently skipped when GOOGLE_CHAT_AUDIENCE is empty (the default). Anyone can POST and get LLM responses without a valid token. Fix: make audience required; fail loudly if not set. """ # Channel config with no audience — JWT check is skipped (the known gap). _channels = {"google_chat": {"persona": "inara"}} from unittest.mock import patch with patch("routers.google_chat.get_user_channels", return_value=_channels): r = await client.post("/channels/google-chat/scott", json={ "chat": { "messagePayload": { "message": {"text": "Exploit"}, "space": {"name": "spaces/x", "type": "DM"}, }, "user": {"displayName": "Attacker"}, } }) # This currently succeeds — it should not when audience is unconfigured assert r.status_code == 200 # documents the gap