From d8094335b70ceb69bab74c5d59ac44906d825f34 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Wed, 15 Jul 2026 09:19:37 -0300 Subject: [PATCH] Studio: scope the seeded bootstrap password auto-fill to loopback clients (#7131) * Studio: scope the seeded bootstrap password auto-fill to loopback clients * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: block bootstrap injection through Cloudflare tunnels * Studio: require loopback host for bootstrap injection * Studio: add regression test for unparseable Host in bootstrap loopback gate * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope bootstrap auto-fill to a direct-loopback client (block proxy/tunnel headers and malformed Host) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reject scope-id addresses in loopback check (fail closed on ::1%zone Host) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reject malformed bracketed Host in loopback check (e.g. [::1]evil) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han Co-authored-by: danielhanchen <23090290+danielhanchen@users.noreply.github.com> --- studio/backend/main.py | 73 ++++++++++- .../tests/test_index_bootstrap_loopback.py | 123 ++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 studio/backend/tests/test_index_bootstrap_loopback.py diff --git a/studio/backend/main.py b/studio/backend/main.py index 332f4e7d1f..8062b7b073 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -234,6 +234,7 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT: os.environ.setdefault("UNSLOTH_IS_PRESENT", "1") import hashlib +import ipaddress import mimetypes import re as _re import shutil @@ -1363,6 +1364,61 @@ def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int] return (scheme, host, port) +def _is_loopback_ip(host: Optional[str]) -> bool: + """Return whether ``host`` is a loopback IP, including IPv4-mapped IPv6.""" + if not host or "%" in host: # a scope id (::1%eth0) is never a plain loopback + return False + try: + ip = ipaddress.ip_address(host) + except (TypeError, ValueError): + return False + mapped = getattr(ip, "ipv4_mapped", None) + return ip.is_loopback or (mapped is not None and mapped.is_loopback) + + +# A loopback peer carrying any of these is a proxy/tunnel relaying a remote +# client, so the peer is the proxy, not the caller: cloudflared sets +# cf-connecting-ip, reverse proxies set the rest (uvicorn only consumes +# x-forwarded-for, so the others survive to here). +_PROXIED_CLIENT_HEADERS = ( + "cf-connecting-ip", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-real-ip", +) + + +def _host_header_is_loopback(host_header: Optional[str]) -> bool: + """Loopback/localhost check on the raw Host header. + + Reads the header directly so a malformed or absent Host cannot fall back to + ``request.url.hostname``'s (loopback) ASGI server address. + """ + if not host_header: + return False + host = host_header.strip() + if host.startswith("["): # [IPv6] or [IPv6]:port + end = host.find("]") + if end == -1 or (host[end + 1 :] and not host[end + 1 :].startswith(":")): + return False # unclosed bracket or junk after ] (e.g. [::1]evil) + host = host[1:end] + elif host.count(":") == 1: # host:port + host = host.split(":", 1)[0] + host = host.lower().rstrip(".") + return host == "localhost" or _is_loopback_ip(host) + + +def _is_local_bootstrap_request(request: Request) -> bool: + """Allow bootstrap injection only through a direct loopback authority.""" + client = request.client + if client is None or not _is_loopback_ip(client.host): + return False + if any(request.headers.get(h) is not None for h in _PROXIED_CLIENT_HEADERS): + return False + return _host_header_is_loopback(request.headers.get("host")) + + def _is_same_origin_request(request: Request) -> bool: """True when Origin is missing or matches request's scheme://host:port. @@ -1398,6 +1454,17 @@ def _is_same_origin_request(request: Request) -> bool: return origin_canon == self_canon +def _should_inject_bootstrap(request: Request) -> bool: + """Whether to embed the seeded bootstrap password in index.html.""" + if not _is_same_origin_request(request): + return False + if _IS_COLAB: + # Single-user notebook proxy: allow autofill, but never a public + # shareable tunnel (a Colab Cloudflare link sets cf-connecting-ip). + return request.headers.get("cf-connecting-ip") is None + return _is_local_bootstrap_request(request) + + def setup_frontend(app: FastAPI, build_path: Path): """Mount frontend static files (optional)""" if not build_path.exists(): @@ -1410,8 +1477,10 @@ def setup_frontend(app: FastAPI, build_path: Path): def _build_index_response(request: Request) -> Response: content = (build_path / "index.html").read_bytes() content = _strip_crossorigin(content) - # Bootstrap pw is same-origin only; Vary: Origin keeps caches honest. - if _is_same_origin_request(request): + # Bootstrap pw goes only to a same-origin, direct-loopback client (or + # Colab's single-user notebook proxy): a wildcard bind must not serve it + # in-page to a LAN or proxied peer. Vary: Origin keeps caches honest. + if _should_inject_bootstrap(request): content, nonce = _inject_bootstrap(content, app) else: nonce = None diff --git a/studio/backend/tests/test_index_bootstrap_loopback.py b/studio/backend/tests/test_index_bootstrap_loopback.py new file mode 100644 index 0000000000..87abace22c --- /dev/null +++ b/studio/backend/tests/test_index_bootstrap_loopback.py @@ -0,0 +1,123 @@ +# 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 coverage for bootstrap password exposure to remote clients.""" + +from types import SimpleNamespace + + +def _request( + client_host, + request_host = "127.0.0.1", + headers = None, +): + """Build a minimal request; ``None`` models an unresolved peer / absent Host.""" + client = None if client_host is None else SimpleNamespace(host = client_host, port = 0) + hdrs = {} + if request_host is not None: + hdrs["host"] = request_host + hdrs.update(headers or {}) + return SimpleNamespace(client = client, headers = hdrs, url = SimpleNamespace(hostname = request_host)) + + +def test_loopback_peers_are_local(): + from main import _is_local_bootstrap_request + cases = ( + ("127.0.0.1", "127.0.0.1"), + ("::1", "::1"), + ("::ffff:127.0.0.1", "::ffff:127.0.0.1"), + ("127.0.0.1", "localhost"), + ) + for peer, host in cases: + assert _is_local_bootstrap_request(_request(peer, host)) is True, (peer, host) + + +def test_non_loopback_peers_are_remote(): + from main import _is_local_bootstrap_request + + # ::1%eth0 is a scope-id'd address, which ipaddress treats as loopback on + # 3.9+; it must not count as a direct local peer. + for host in ("192.168.1.10", "::ffff:192.168.1.10", "::1%eth0"): + assert _is_local_bootstrap_request(_request(host)) is False, host + + +def test_absent_or_unparseable_peer_fails_safe(): + from main import _is_local_bootstrap_request + for host in (None, "localhost"): + assert _is_local_bootstrap_request(_request(host)) is False, host + + +def test_cloudflare_tunnel_clients_are_remote_despite_loopback_peer(): + from main import _is_local_bootstrap_request + for client_ip in ("203.0.113.7", ""): + request = _request("127.0.0.1", headers = {"cf-connecting-ip": client_ip}) + assert _is_local_bootstrap_request(request) is False, client_ip + + +def test_dns_rebinding_host_is_remote_despite_loopback_peer(): + from main import _is_local_bootstrap_request + for host in ("attacker.example", "192.168.1.10", None): + assert _is_local_bootstrap_request(_request("127.0.0.1", host)) is False, host + + +def test_unparseable_request_host_fails_safe(): + """A Host that makes ``request.url.hostname`` raise must fall to remote.""" + from main import _is_local_bootstrap_request + + class _RaisingURL: + @property + def hostname(self): + raise ValueError("malformed host") + + request = SimpleNamespace( + client = SimpleNamespace(host = "127.0.0.1", port = 0), headers = {}, url = _RaisingURL() + ) + assert _is_local_bootstrap_request(request) is False + + +def test_reverse_proxy_forwarded_headers_are_remote(): + """A loopback proxy relaying a remote client (non-Cloudflare headers) is remote.""" + from main import _is_local_bootstrap_request + for header in ("forwarded", "x-forwarded-for", "x-forwarded-host", "x-real-ip"): + request = _request("127.0.0.1", "localhost", headers = {header: "203.0.113.7"}) + assert _is_local_bootstrap_request(request) is False, header + + +def test_malformed_or_absent_host_is_remote(): + """A malformed/absent/scope-id Host must not fall back to the loopback server address.""" + from main import _is_local_bootstrap_request + + # incl. bracket smuggling: [::1]evil / unclosed [::1 must not reduce to ::1 + for host in ( + "e_vil", + "[malformed", + "", + None, + "[::1%25eth0]:8888", + "[::1]attacker", + "[::1]evil.com", + "[::1", + "[::1]x", + ): + assert _is_local_bootstrap_request(_request("127.0.0.1", host)) is False, host + + +def test_colab_allows_notebook_proxy_but_not_shareable_tunnel(monkeypatch): + """Colab autofills its single-user proxy, but not a public Cloudflare link.""" + import main + + monkeypatch.setattr(main, "_IS_COLAB", True) + # In-notebook proxy: same-origin, no tunnel header, injects off-loopback too. + assert main._should_inject_bootstrap(_request("10.0.0.2", "colab.proxy")) is True + # Shareable Cloudflare link marks visitors with cf-connecting-ip; withhold. + tunnel = _request("127.0.0.1", "localhost", headers = {"cf-connecting-ip": "203.0.113.7"}) + assert main._should_inject_bootstrap(tunnel) is False + + +def test_non_colab_gate_requires_local_client(monkeypatch): + """Outside Colab the gate injects only for a direct loopback client.""" + import main + + monkeypatch.setattr(main, "_IS_COLAB", False) + assert main._should_inject_bootstrap(_request("127.0.0.1", "localhost")) is True + assert main._should_inject_bootstrap(_request("192.168.1.10", "localhost")) is False