summaryrefslogtreecommitdiff
path: root/tests/test_security.py
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-13 13:35:03 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-13 13:35:03 +0900
commitf2d24cb7d437ad099291262d7c1b021809cd66ae (patch)
treed9a11a3795e0296358cea7a497edffbe898f4583 /tests/test_security.py
parentf36cc852cc1792bd624ff701fb2476d0b43fa1af (diff)
feat(multimedia): MCP画像・音声artifactの安全な表示経路を追加
MCP Tool結果をテキストと型付きartifactへ分離し、画像・音声のbase64をMIME、サイズ、件数のallowlistで検証する。annotations.audienceを反映し、ユーザー向けartifactだけをセッションへ保存してSSEにはメタデータのみを公開する。 画像はvision capabilityが明示されたモデルだけへ渡し、非対応時は既存の多言語プロンプト機構による固定通知へ置き換える。MCP生成音声とユーザー添付音声はOllamaへ送らず、誤デコードしたテキストやbase64がモデル入力へ混入しないポリシーに統一する。 保存メディア用の認証済み同一オリジン配信ルートを追加し、セッションスコープ、MIME allowlist、nosniff、private/no-store、Rangeリクエストを適用する。UIはMCP由来HTMLを使わずDOM APIでimgとaudio controlsを生成し、CSPへmedia-src selfを追加する。 web_searchは文字列メタデータだけを返す既存仕様を維持する。scrape_urlとsearch_and_fetchは画像・音声・動画レスポンスを拒否し、search_and_fetchの対象拡張子を明示的allowlistへ変更して標準Webツールをテキスト専用の契約に揃える。 責務境界と導入しない過剰対策をdocs/multimedia.mdへ記録し、README、ツール仕様、CHANGELOGを更新する。MCP artifact変換・永続化、vision分岐、音声非送信、認証、Range配信、MIME偽装拒否をテストで追加確認した。 Validation: 148 tests passed; ruff check passed; mypy src passed; node --check src/ollama_web/static/app.js passed.
Diffstat (limited to 'tests/test_security.py')
-rw-r--r--tests/test_security.py92
1 files changed, 92 insertions, 0 deletions
diff --git a/tests/test_security.py b/tests/test_security.py
index f929bb8..dea165b 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -111,3 +111,95 @@ def test_katex_local_font_assets_are_present() -> None:
assert font_paths
assert not [path for path in font_paths if not (vendor_dir / path).exists()]
+
+
+def test_authenticated_audio_content_supports_range_requests(
+ workspace_tmp: Path, monkeypatch
+) -> None:
+ client = _client(workspace_tmp, monkeypatch)
+ session = client.app.state.session_store.create()
+ audio = client.app.state.session_store.add_file(
+ session_id=session["id"],
+ name="generated.mp3",
+ data=b"0123456789",
+ mime="audio/mpeg",
+ text="",
+ source="mcp__music__generate",
+ )
+ url = f"/api/sessions/{session['id']}/files/{audio.id}/content"
+
+ assert client.get(url).status_code == 401
+ assert client.post("/api/auth/login", json={"pin": "123456"}).status_code == 200
+ response = client.get(url, headers={"Range": "bytes=2-5"})
+
+ assert response.status_code == 206
+ assert response.content == b"2345"
+ assert response.headers["content-type"] == "audio/mpeg"
+ assert response.headers["content-range"] == "bytes 2-5/10"
+ assert response.headers["cache-control"] == "private, no-store"
+ assert response.headers["x-content-type-options"] == "nosniff"
+ assert "media-src 'self'" in response.headers["content-security-policy"]
+
+
+def test_file_content_does_not_inline_non_media(workspace_tmp: Path, monkeypatch) -> None:
+ client = _client(workspace_tmp, monkeypatch)
+ session = client.app.state.session_store.create()
+ text_file = client.app.state.session_store.add_file(
+ session_id=session["id"],
+ name="notes.html",
+ data=b"<script>alert(1)</script>",
+ mime="text/html",
+ text="",
+ )
+ client.post("/api/auth/login", json={"pin": "123456"})
+
+ response = client.get(f"/api/sessions/{session['id']}/files/{text_file.id}/content")
+ assert response.status_code == 415
+
+ disguised_html = client.app.state.session_store.add_file(
+ session_id=session["id"],
+ name="disguised.png",
+ data=b"<script>alert(1)</script>",
+ mime="text/html",
+ text="",
+ )
+ response = client.get(
+ f"/api/sessions/{session['id']}/files/{disguised_html.id}/content"
+ )
+ assert response.status_code == 415
+
+
+def test_uploaded_audio_is_playable_but_excluded_from_attachment_text(
+ workspace_tmp: Path, monkeypatch
+) -> None:
+ from ollama_web.routes.chat import _attachment_context, _has_audio_attachments
+
+ client = _client(workspace_tmp, monkeypatch)
+ login = client.post("/api/auth/login", json={"pin": "123456"})
+ csrf = login.headers["x-csrf-token"]
+ session = client.app.state.session_store.create()
+ response = client.post(
+ f"/api/sessions/{session['id']}/files",
+ headers={"X-CSRF-Token": csrf},
+ files={"files": ("recording.mp3", b"audio-bytes", "audio/mpeg")},
+ )
+
+ assert response.status_code == 200
+ audio = response.json()["files"][0]
+ assert audio["text"] == ""
+ assert _has_audio_attachments(
+ client.app.state.session_store, session["id"], [audio["id"]]
+ )
+ assert (
+ _attachment_context(
+ client.app.state.session_store,
+ session["id"],
+ [audio["id"]],
+ total_limit=4000,
+ language="ja",
+ )
+ == ""
+ )
+ playable = client.get(f"/api/sessions/{session['id']}/files/{audio['id']}/content")
+ assert playable.status_code == 200
+ assert playable.content == b"audio-bytes"