diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 8d8e7ef3dd..01ed9affe8 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -1381,6 +1381,12 @@ _MAX_PAGE_CHARS = 16000 # cap fetched page text (after HTML-to-MD conversion) # sections stripped during conversion; 512 KB reaches article content even # where
alone is ~200 KB. _MAX_FETCH_BYTES = 512 * 1024 +# A decoded page is treated as binary when more than 1/_BINARY_REPLACEMENT_DIVISOR +# (12.5%) of its chars are U+FFFD, but always tolerating up to +# _MIN_REPLACEMENT_CHARS stray bad bytes so minor encoding glitches don't drop a +# real page. +_MIN_REPLACEMENT_CHARS = 16 +_BINARY_REPLACEMENT_DIVISOR = 8 _USER_AGENTS = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", @@ -1482,6 +1488,26 @@ def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str return True, "", first_ip +def _is_texty_content_type(content_type: str) -> bool: + """True for MIME types safe to decode as text (HTML pages, plain text, + JSON/XML feeds). Binary types (PDF, images, archives, octet-stream) return + False so the fetcher never decodes them into a flood of U+FFFD replacement + chars that poison the model context (unslothai/unsloth#7084). + + A missing Content-Type coerces to ``text/plain`` upstream, so unlabeled + bodies pass here and are caught instead by the replacement-char fallback. + """ + ct = (content_type or "").lower() + if not ct: + return True # unlabeled: let the replacement-char fallback decide + if ct.startswith("text/"): + return True + if ct.startswith("application/"): + # "xml" also covers xhtml+xml / *+xml; "json" covers *+json. + return any(marker in ct for marker in ("json", "xml", "javascript", "csv")) + return False + + def _fetch_page_text( url: str, max_chars: int = _MAX_PAGE_CHARS, @@ -1562,8 +1588,29 @@ def _fetch_page_text( else: return "Failed to fetch URL: too many redirects." + # Reject binary bodies (PDF, image, archive): decoding them as text + # floods the model context with U+FFFD replacement chars (#7084). + content_type = resp.headers.get_content_type() + if not _is_texty_content_type(content_type): + # Trim to a clean MIME token: get_content_type() can echo control + # chars from an obs-folded header, and this string is returned to + # the model. + m = re.match(r"[\w.+-]+/[\w.+-]+", content_type or "") + safe_type = m.group(0) if m else "unknown type" + return ( + f"(non-text content: {safe_type}, {len(raw_bytes)} bytes; " + "not readable as text)" + ) + charset = resp.headers.get_content_charset() or "utf-8" raw_html = raw_bytes.decode(charset, errors = "replace") + + # Fallback for binary mislabeled as text/* or sent with no Content-Type: + # a real text page has only a few replacement chars, if any. + if raw_html.count("\ufffd") > max( + _MIN_REPLACEMENT_CHARS, len(raw_html) // _BINARY_REPLACEMENT_DIVISOR + ): + return f"(binary content, {len(raw_bytes)} bytes; not readable as text)" except _HTTPError as e: return f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}" except Exception as e: diff --git a/studio/backend/tests/test_web_fetch_binary_guard.py b/studio/backend/tests/test_web_fetch_binary_guard.py new file mode 100644 index 0000000000..2f77d5da45 --- /dev/null +++ b/studio/backend/tests/test_web_fetch_binary_guard.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Regression for unslothai/unsloth#7084: the web_search fetcher must not decode +a binary body (PDF, image, octet-stream) into a flood of U+FFFD replacement +chars that poison the model context. It rejects non-text Content-Types up front +and, for binary mislabeled as text/* or sent unlabeled, falls back to a +replacement-char ratio check. Real HTML pages are unaffected. +""" + +from __future__ import annotations + +import sys +from email.message import Message +from pathlib import Path + +import pytest + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +from core.inference import tools + + +class _FakeResp: + def __init__(self, body: bytes, content_type: str | None): + self._body = body + self.headers = Message() + if content_type is not None: + self.headers["Content-Type"] = content_type + + def read(self, n: int | None = None) -> bytes: + return self._body if n is None else self._body[:n] + + +class _FakeOpener: + def __init__(self, resp): + self._resp = resp + + def open(self, req, timeout=None): + return self._resp + + +def _fetch_with(monkeypatch, body: bytes, content_type: str | None) -> str: + # Pass SSRF validation and skip real DNS/network. + monkeypatch.setattr( + tools, "_validate_and_resolve_host", lambda host, port: (True, "", "93.184.216.34") + ) + monkeypatch.setattr( + tools.urllib.request, "build_opener", lambda *a, **k: _FakeOpener(_FakeResp(body, content_type)) + ) + return tools._fetch_page_text("https://example.com/thing", timeout=5) + + +# ── content-type classifier ── + + +@pytest.mark.parametrize( + "content_type,expected", + [ + ("text/html", True), + ("text/plain; charset=utf-8", True), + ("application/json", True), + ("application/xml", True), + ("application/xhtml+xml", True), + ("application/ld+json", True), + ("application/pdf", False), + ("image/png", False), + ("image/svg+xml", False), # SVG source isn't extracted downstream; reject + ("application/octet-stream", False), + ("application/zip", False), + ("", True), # unlabeled: defer to the replacement-char fallback + (None, True), + ], +) +def test_is_texty_content_type(content_type, expected): + assert tools._is_texty_content_type(content_type) is expected + + +# ── fetcher end-to-end (mocked network) ── + + +def test_pdf_rejected_by_content_type(monkeypatch): + out = _fetch_with(monkeypatch, b"%PDF-1.7\n\xff\xd8\xff\x00\x89PNG" * 200, "application/pdf") + assert "�" not in out + assert "non-text content" in out and "application/pdf" in out + + +def test_image_rejected_by_content_type(monkeypatch): + out = _fetch_with(monkeypatch, b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4, "image/png") + assert "�" not in out + assert "non-text content" in out and "image/png" in out + + +def test_binary_mislabeled_as_text_caught_by_fallback(monkeypatch): + # A server sends binary but labels it text/plain -> the type check passes, + # so the replacement-char fallback must catch it. + out = _fetch_with(monkeypatch, bytes(range(256)) * 20, "text/plain") + assert "�" not in out + assert "binary content" in out + + +def test_binary_unlabeled_caught_by_fallback(monkeypatch): + # No Content-Type coerces to text/plain upstream; the fallback still catches it. + out = _fetch_with(monkeypatch, bytes(range(256)) * 20, None) + assert "�" not in out + assert "binary content" in out + + +def test_html_page_unaffected(monkeypatch): + html = b"Real text content here.
" + out = _fetch_with(monkeypatch, html, "text/html; charset=utf-8") + assert "Hello" in out + assert "non-text content" not in out and "binary content" not in out + + +def test_content_type_sanitized_in_message(monkeypatch): + # An obs-folded Content-Type can smuggle control chars into get_content_type(); + # the returned message must be trimmed to a clean MIME token. + out = _fetch_with(monkeypatch, b"\x00\x01\x02" * 500, "application/octet-stream\r\n data: injected") + assert "\n" not in out and "\r" not in out + assert "injected" not in out + assert "application/octet-stream" in out + + +@pytest.mark.parametrize( + "n_bad,n_total,expect_binary", + [ + # Straddle the 12.5% ratio (well above the 16-char floor): just under vs + # just over len//8. Locks the divisor so it can't silently drift. + (120, 1000, False), # 120 <= 1000//8 (125) -> kept + (130, 1000, True), # 130 > 1000//8 (125) -> binary + ], +) +def test_replacement_ratio_boundary(monkeypatch, n_bad, n_total, expect_binary): + # Body of n_total chars: n_bad undecodable bytes + ASCII filler. Labeled + # text/plain so only the ratio fallback (not the type check) can fire. + body = b"\xff" * n_bad + b"a" * (n_total - n_bad) + out = _fetch_with(monkeypatch, body, "text/plain") + assert ("binary content" in out) is expect_binary + + +def test_text_with_a_few_stray_replacement_chars_kept(monkeypatch): + # A mostly-clean page with a handful of bad bytes stays (below the floor), + # so we don't drop legitimate pages over minor encoding glitches. + body = ("Real article text. " * 200).encode() + b"\xff\xfe\xff" + out = _fetch_with(monkeypatch, body, "text/html") + assert "Real article text." in out + assert "binary content" not in out