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 <danielhanchen@gmail.com> Co-authored-by: danielhanchen <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
parent
ee73bcb209
commit
d8094335b7
2 changed files with 194 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
123
studio/backend/tests/test_index_bootstrap_loopback.py
Normal file
123
studio/backend/tests/test_index_bootstrap_loopback.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue