fix(preview): fetch ceiling tracks the widest kind cap, not a flat 10 MB

Review feedback (PR #800): the URL lane hard-capped fetched bodies at
10 MB before kind resolution, making the 32 MiB pdf cap unreachable for
URL targets while path targets honored it. The flat pre-check is gone;
the guarded fetch's max_bytes now tracks max(PREVIEW_SIZE_CAPS.values())
- mirroring the path lane's stat pre-check - and the per-kind caps after
resolution stay authoritative.

Also drops a redundant function-local asyncio import in test_console.py.
This commit is contained in:
Patrick Buckley
2026-07-07 08:15:31 -07:00
parent 29a4bbf876
commit bbe92faca1
3 changed files with 27 additions and 6 deletions
-1
View File
@@ -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
+23
View File
@@ -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"
+4 -5
View File
@@ -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