"""Security regression tests."""
from __future__ import annotations
import re
import shutil
import uuid
from pathlib import Path
import pytest
from starlette.testclient import TestClient
from ollama_web.app import create_app
from ollama_web.config import settings
@pytest.fixture
def workspace_tmp() -> Path:
path = Path("data/.pytest-security") / uuid.uuid4().hex
path.mkdir(parents=True, exist_ok=True)
try:
yield path
finally:
shutil.rmtree(path, ignore_errors=True)
def _client(workspace_tmp: Path, monkeypatch) -> TestClient:
monkeypatch.setattr(settings, "data_dir", str(workspace_tmp))
monkeypatch.setattr(settings, "pin", "123456")
monkeypatch.setattr(settings, "secret_key", "test-secret")
monkeypatch.setattr(settings, "allowed_origins", [])
return TestClient(create_app())
def test_api_requires_login(workspace_tmp: Path, monkeypatch) -> None:
client = _client(workspace_tmp, monkeypatch)
res = client.get("/api/sessions")
assert res.status_code == 401
def test_login_and_csrf_protect_state_changes(workspace_tmp: Path, monkeypatch) -> None:
client = _client(workspace_tmp, monkeypatch)
assert client.post("/api/auth/login", json={"pin": "bad"}).status_code == 401
login = client.post("/api/auth/login", json={"pin": "123456"})
assert login.status_code == 200
csrf = login.headers["x-csrf-token"]
assert client.post("/api/sessions").status_code == 403
created = client.post("/api/sessions", headers={"X-CSRF-Token": csrf})
assert created.status_code == 200
assert created.json()["id"]
def test_cors_wildcard_is_not_enabled_by_default(workspace_tmp: Path, monkeypatch) -> None:
client = _client(workspace_tmp, monkeypatch)
res = client.get("/api/auth/status", headers={"Origin": "https://evil.example"})
assert res.headers.get("access-control-allow-origin") != "*"
def test_invalid_session_id_is_rejected_after_login(workspace_tmp: Path, monkeypatch) -> None:
client = _client(workspace_tmp, monkeypatch)
login = client.post("/api/auth/login", json={"pin": "123456"})
csrf = login.headers["x-csrf-token"]
res = client.delete("/api/sessions/../x", headers={"X-CSRF-Token": csrf})
assert res.status_code in {400, 404}
def test_private_urls_are_blocked() -> None:
from ollama_web.tools.helper.safe_http import UnsafeURL, validate_public_http_url
for url in (
"http://127.0.0.1:11434",
"http://localhost:8000",
"http://192.168.1.1",
"http://10.0.0.1",
"http://169.254.169.254/latest/meta-data",
):
try:
validate_public_http_url(url)
except UnsafeURL:
continue
raise AssertionError(f"URL should have been blocked: {url}")
def test_pdf_to_text_does_not_read_local_paths(workspace_tmp: Path) -> None:
from ollama_web.tools.pdf import pdf_to_text
secret = workspace_tmp / "secret.pdf"
secret.write_bytes(b"%PDF-1.4\n")
assert pdf_to_text(str(secret)) == "Only http(s) PDF URLs are supported."
def test_frontend_uses_local_sanitizer_and_no_cdn() -> None:
html = Path("src/ollama_web/templates/index.html").read_text(encoding="utf-8")
app_js = Path("src/ollama_web/static/app.js").read_text(encoding="utf-8")
assert "cdnjs.cloudflare.com" not in html
assert "vendor/purify.min.js" in html
assert "DOMPurify.sanitize" in app_js
def test_katex_local_font_assets_are_present() -> None:
css_path = Path("src/ollama_web/static/vendor/katex.min.css")
vendor_dir = css_path.parent
css = css_path.read_text(encoding="utf-8")
font_paths = sorted(set(re.findall(r"fonts/KaTeX_[^)'\"\s]+", css)))
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"",
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"",
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"