diff --git a/tests/test_console.py b/tests/test_console.py index 65b06eca..b5ffabcc 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -2699,7 +2699,6 @@ class TestProxyGetHeaderPassThrough: finding, preview-pane branch).""" def test_security_headers_forwarded(self, monkeypatch): - import asyncio from types import SimpleNamespace from unittest.mock import MagicMock diff --git a/tests/test_open_preview_tool.py b/tests/test_open_preview_tool.py index 6256f8f8..d0a556b9 100644 --- a/tests/test_open_preview_tool.py +++ b/tests/test_open_preview_tool.py @@ -183,6 +183,29 @@ class TestExecOpenPreview: assert msg.startswith("Error:") assert "too large" in msg + def test_url_pdf_over_10mb_previews_to_kind_cap(self, monkeypatch): + # Review finding (PR #800): a flat 10 MB URL pre-check rejected PDFs + # the 32 MiB pdf kind cap allows — the fetch ceiling must track the + # widest kind cap and leave the per-kind caps as the authority. + from turnstone.core.preview import PREVIEW_SIZE_CAPS + + s = _make_session() + body = b"%PDF-1.7\n" + b"a" * (12 * 1024 * 1024) + seen = {} + + def _capture(url, **kw): + seen.update(kw) + return _fake_response(url, body, "application/pdf") + + monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _capture) + item = s._prepare_open_preview("c1", {"target": "https://acme.com/report.pdf"}) + _, msg = s._exec_open_preview(item) + assert not msg.startswith("Error:") + descriptor, _ = s._tool_previews["c1"] + assert descriptor["kind"] == "pdf" + assert descriptor["size"] == len(body) + assert seen["max_bytes"] == max(PREVIEW_SIZE_CAPS.values()) + def test_path_image(self, tmp_path): s = _make_session() p = tmp_path / "chart.png" diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 1ac2c790..68e9df81 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -16159,11 +16159,14 @@ class ChatSession: try: # Every redirect hop is SSRF-screened BEFORE its request goes # out — the pre-approval screen covers only the URL the model - # named, not where it 302s. + # named, not where it 302s. The fetch ceiling tracks the most + # permissive kind cap (like the path lane's stat pre-check); + # the per-kind cap after resolution is the authority. resp = fetch_with_ssrf_guard( url, timeout=self.tool_timeout, allow_private_origin=item.get("allow_private_origin", False), + max_bytes=max(PREVIEW_SIZE_CAPS.values()), ) resp.raise_for_status() except httpx.HTTPStatusError as e: @@ -16177,10 +16180,6 @@ class ChatSession: if resp.url.username or resp.url.password: final_url = str(resp.url.copy_with(username=None, password=None)) body = resp.content - if len(body) > 10 * 1024 * 1024: - return _fail( - f"Error: response too large to preview ({len(body):,} bytes; cap 10 MB)" - ) mime_hint = resp.headers.get("content-type", "") name_hint = final_url source = final_url