unsloth/tests/studio/playwright_tauri_python_tool_images.py
oobabooga 0ca37e57ad
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Local Agent Guides CI / connection (codex) (push) Waiting to run
Local Agent Guides CI / connection (hermes) (push) Waiting to run
Local Agent Guides CI / connection (openclaw) (push) Waiting to run
Local Agent Guides CI / connection (opencode) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Local Agent Guides CI / connection (claude) (push) Waiting to run
Local Agent Guides CI / connection (pi) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Lockfile supply-chain audit / lockfile supply-chain audit (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Desktop: block HTTP loopback requests from untrusted images (#8046)
2026-08-07 18:56:26 -03:00

222 lines
8 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Browser regression for the desktop Python tool image boundary.
Runs as a standalone script. It serves a page with the exact Tauri CSP, then
checks that trusted code can fetch an authenticated sandbox image into a blob
URL while an allowed HTTPS image redirect cannot reach the HTTP Studio backend.
The same policy intentionally continues to allow ordinary HTTPS images,
including HTTPS loopback, which is outside this PR's HTTP-backend boundary.
"""
from __future__ import annotations
import base64
import json
import os
import sys
import threading
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Iterator
from playwright.sync_api import sync_playwright
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _playwright_robust import chromium_launch_args # noqa: E402
REPO = Path(__file__).resolve().parents[2]
TAURI_CONFIG = REPO / "studio/src-tauri/tauri.conf.json"
REDIRECT_URL = "https://redirect.invalid/attacker.png"
HTTPS_LOOPBACK_URL = "https://127.0.0.1:9443/sensitive/direct.png"
SANDBOX_PATH = "/api/inference/sandbox/session%20id/loss%20curve.png"
SENSITIVE_PATH = "/sensitive/redirect.png"
AUTHORIZATION = "Bearer browser-test-token"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class ProbeState:
def __init__(self) -> None:
self.paths: list[str] = []
self.authorization: str | None = None
class ProbeServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, handler: type[BaseHTTPRequestHandler], state: ProbeState):
super().__init__(("127.0.0.1", 0), handler)
self.state = state
class TargetHandler(BaseHTTPRequestHandler):
server: ProbeServer
def log_message(self, format: str, *args: object) -> None:
del format, args
def _cors(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Authorization")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
def do_OPTIONS(self) -> None:
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self) -> None:
self.server.state.paths.append(self.path)
if self.path == SANDBOX_PATH:
self.server.state.authorization = self.headers.get("Authorization")
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(PNG)))
self._cors()
self.end_headers()
self.wfile.write(PNG)
return
self.send_response(204)
self.end_headers()
def page_handler(csp: str, target_origin: str) -> type[BaseHTTPRequestHandler]:
class PageHandler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None:
del format, args
def do_GET(self) -> None:
if self.path == "/app.js":
script = f"""
const image = document.querySelector("#sandbox");
window.sandboxResult = (async () => {{
const response = await fetch("{target_origin}{SANDBOX_PATH}", {{
headers: {{ Authorization: "{AUTHORIZATION}" }},
}});
if (!response.ok) throw new Error(`sandbox fetch failed: ${{response.status}}`);
const blob = await response.blob();
window.sandboxObjectUrl = URL.createObjectURL(blob);
await new Promise((resolve, reject) => {{
image.addEventListener("load", resolve, {{ once: true }});
image.addEventListener("error", reject, {{ once: true }});
image.src = window.sandboxObjectUrl;
}});
return {{ width: image.naturalWidth, height: image.naturalHeight }};
}})();
window.revokeSandbox = () => URL.revokeObjectURL(window.sandboxObjectUrl);
"""
body = script.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/javascript; charset=utf-8")
else:
body = f"""<!doctype html>
<html>
<body>
<img id="redirect" src="{REDIRECT_URL}" alt="remote">
<img id="https-loopback" src="{HTTPS_LOOPBACK_URL}" alt="https loopback">
<img id="sandbox" alt="loss curve.png">
<script src="/app.js"></script>
</body>
</html>
""".encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Security-Policy", csp)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return PageHandler
@contextmanager
def running_server(server: ThreadingHTTPServer) -> Iterator[ThreadingHTTPServer]:
thread = threading.Thread(target = server.serve_forever, daemon = True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout = 5)
def main() -> None:
config = json.loads(TAURI_CONFIG.read_text(encoding = "utf-8"))
csp = config["app"]["security"]["csp"]
state = ProbeState()
target = ProbeServer(TargetHandler, state)
target_origin = f"http://127.0.0.1:{target.server_port}"
page_server = ProbeServer(page_handler(csp, target_origin), ProbeState())
page_origin = f"http://127.0.0.1:{page_server.server_port}"
with running_server(target), running_server(page_server), sync_playwright() as p:
launch_options: dict[str, object] = {
"headless": True,
"args": chromium_launch_args(),
}
channel = os.environ.get("STUDIO_PLAYWRIGHT_CHANNEL")
if channel:
launch_options["channel"] = channel
browser = p.chromium.launch(**launch_options)
try:
context = browser.new_context()
redirect_requests = 0
https_loopback_requests = 0
def redirect(route) -> None:
nonlocal redirect_requests
redirect_requests += 1
route.fulfill(
status = 302,
headers = {"Location": f"{target_origin}{SENSITIVE_PATH}"},
)
def https_loopback(route) -> None:
nonlocal https_loopback_requests
https_loopback_requests += 1
route.fulfill(status = 200, content_type = "image/png", body = PNG)
context.route(REDIRECT_URL, redirect)
context.route(HTTPS_LOOPBACK_URL, https_loopback)
page = context.new_page()
page.goto(page_origin, wait_until = "domcontentloaded")
dimensions = page.evaluate("window.sandboxResult")
page.wait_for_timeout(500)
assert dimensions == {"width": 1, "height": 1}
assert state.authorization == AUTHORIZATION
assert SANDBOX_PATH in state.paths
assert redirect_requests == 1
assert SENSITIVE_PATH not in state.paths
assert https_loopback_requests == 1
assert page.locator("#https-loopback").evaluate(
"image => image.complete && image.naturalWidth === 1"
)
assert page.locator("#sandbox").get_attribute("src").startswith("blob:")
object_url = page.evaluate("window.sandboxObjectUrl")
image_loads = """url => new Promise((resolve) => {
const image = new Image();
image.addEventListener("load", () => resolve(true), { once: true });
image.addEventListener("error", () => resolve(false), { once: true });
image.src = url;
})"""
assert page.evaluate(image_loads, object_url)
page.evaluate("window.revokeSandbox()")
assert not page.evaluate(image_loads, object_url)
finally:
browser.close()
print("desktop Python tool image browser regression: PASS")
if __name__ == "__main__":
main()