diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index 297a585430..51d6d6ee1d 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -255,17 +255,21 @@ jobs: jq -e '.status == "healthy"' /tmp/health3.json - name: Pass bootstrap pw for IME / i18n test - # IME smoke does the change-password against the bootstrap that - # Studio's frontend injects into the page, so it only needs the - # NEW password. + # IME smoke does its own change-password through the UI. The page no + # longer autofills the seed, so the test needs the current (bootstrap) + # password to fill the Current password field plus the NEW password. run: | + OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password) NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" + echo "::add-mask::$OLD" echo "::add-mask::$NEW" + echo "STUDIO_IME_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV" - name: Drive IME + multilingual paste regression with Playwright env: BASE_URL: http://127.0.0.1:18896 + STUDIO_OLD_PW: ${{ env.STUDIO_IME_OLD_PW }} STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }} PW_ART_DIR: logs/playwright_ime STUDIO_UI_STRICT: '1' diff --git a/studio/backend/main.py b/studio/backend/main.py index e64048dc00..61bb9c80a9 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -234,14 +234,12 @@ 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 import warnings from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError, version as package_version -from urllib.parse import urlparse _STUDIO_INSTALL_ID_RE = _re.compile(r"^[0-9a-f]{64}$") @@ -573,12 +571,16 @@ async def lifespan(app: FastAPI): print("DEFAULT ADMIN ACCOUNT CREATED") print(f" username: {storage.DEFAULT_ADMIN_USERNAME}") print(f" password saved to: {bootstrap_path}") - print(" Open the Studio UI to sign in and change it.") + print(" open that file to read the password, then sign in and change it.") print("=" * 60 + "\n") else: - app.state.bootstrap_password = ( - None if _suppress_bootstrap else storage.get_bootstrap_password() - ) + bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password() + app.state.bootstrap_password = bootstrap_pw + # A restart before first login skips the creation banner above; still + # point the operator to the seed file while the bootstrap pw is unrotated. + if bootstrap_pw: + bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password" + print(f"\nAdmin password change still required. Read it from: {bootstrap_path}\n") _lifespan_log.info( "lifespan startup completed in %.1fms", @@ -1297,180 +1299,6 @@ def _strip_crossorigin(html_bytes: bytes) -> bytes: return html.encode("utf-8") -def _inject_bootstrap(html_bytes: bytes, app: FastAPI): - """Inject bootstrap credentials when password change is pending. - Returns ``(html_bytes, script_nonce_or_None)``; callers forward the nonce - via ``_CSP_SCRIPT_NONCE_HEADER`` so CSP allows the inline script. - """ - import json as _json - import secrets as _secrets - - if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME): - return html_bytes, None - - bootstrap_pw = getattr(app.state, "bootstrap_password", None) - if not bootstrap_pw: - return html_bytes, None - - payload = _json.dumps( - { - "username": storage.DEFAULT_ADMIN_USERNAME, - "password": bootstrap_pw, - } - ) - nonce = _secrets.token_urlsafe(16) - tag = f'' - html = html_bytes.decode("utf-8") - html = html.replace("", f"{tag}", 1) - return html.encode("utf-8"), nonce - - -_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443} - - -def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int]]: - """Canonicalise an Origin to ``(scheme, host, port)`` for equality. - Browsers strip default ports (RFC 6454 sec 6.1) and scheme/host are - case-insensitive (RFC 3986), so a bare string compare misclassifies - same-origin requests as cross-origin. Returns ``None`` on unparseable input - so callers fall to the safer cross-origin default. - """ - scheme = (scheme or "").strip().lower() - if not scheme or not netloc: - return None - # Strip userinfo (RFC 3986); Origin never carries credentials. - if "@" in netloc: - netloc = netloc.rsplit("@", 1)[1] - # IPv6 hosts use brackets (RFC 3986 sec 3.2.2): ``[::1]:8902``. Bare - # ``partition(":")`` mis-parses these, breaking ``unsloth studio -H ::1``. - if netloc.startswith("["): - close = netloc.find("]") - if close == -1: - return None - host = netloc[1:close] - rest = netloc[close + 1 :] - if rest.startswith(":"): - port_str = rest[1:] - elif rest == "": - port_str = "" - else: - return None - else: - host, _, port_str = netloc.partition(":") - host = host.strip().lower() - if not host: - return None - if port_str: - try: - port = int(port_str) - except ValueError: - return None - else: - port = _DEFAULT_PORTS.get(scheme, 0) - 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. - - Missing Origin counts as same-origin (top-level GETs omit it). Both sides - are canonicalised via :func:`_canonical_origin`; callers must emit - ``Vary: Origin``. - """ - origin = request.headers.get("origin") - if origin is None: - # Missing header: top-level same-document GETs omit Origin. - return True - # Empty string is not a valid serialised origin (RFC 6454 sec 6.1). - if not origin: - return False - # "null" token (sandboxed iframes, file:// pages) is never same-origin. - if origin == "null": - return False - # ``urlparse`` raises ``ValueError`` on malformed IPv6 brackets; swallow - # so a garbage Origin doesn't 500 the SPA handler. - try: - parsed = urlparse(origin) - except ValueError: - return False - origin_canon = _canonical_origin(parsed.scheme, parsed.netloc) - if origin_canon is None: - return False - try: - self_canon = _canonical_origin(request.url.scheme, request.url.netloc) - except ValueError: - return False - if self_canon is None: - return False - 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(): @@ -1481,25 +1309,15 @@ def setup_frontend(app: FastAPI, build_path: Path): app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets") def _build_index_response(request: Request) -> Response: - content = (build_path / "index.html").read_bytes() - content = _strip_crossorigin(content) - # 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 - headers = { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Vary": "Origin", - } - if nonce: - headers[_CSP_SCRIPT_NONCE_HEADER] = nonce + # The seeded bootstrap password is never embedded in the served page: a + # loopback reverse proxy is indistinguishable from a genuine local client, + # so it cannot be scoped safely. It is saved to .bootstrap_password (0600); + # the operator reads it from there on first login. + content = _strip_crossorigin((build_path / "index.html").read_bytes()) return Response( content = content, media_type = "text/html", - headers = headers, + headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}, ) @app.get("/") diff --git a/studio/backend/tests/test_index_bootstrap_loopback.py b/studio/backend/tests/test_index_bootstrap_loopback.py deleted file mode 100644 index 87abace22c..0000000000 --- a/studio/backend/tests/test_index_bootstrap_loopback.py +++ /dev/null @@ -1,123 +0,0 @@ -# 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 diff --git a/studio/backend/tests/test_index_bootstrap_origin.py b/studio/backend/tests/test_index_bootstrap_origin.py deleted file mode 100644 index b9d7f58867..0000000000 --- a/studio/backend/tests/test_index_bootstrap_origin.py +++ /dev/null @@ -1,131 +0,0 @@ -# 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 the bootstrap-pw cross-origin leak (PR 5739). - -``_is_same_origin_request`` gates ``_inject_bootstrap`` so the seeded admin -password only ships to same-origin callers. -""" - -import os -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest - - -def _build_request( - host: str, - origin: str | None, - scheme: str = "http", -) -> MagicMock: - request = MagicMock() - request.url.scheme = scheme - request.url.netloc = host - request.headers = {"origin": origin} if origin is not None else {} - return request - - -def test_is_same_origin_request_missing_origin_is_same_origin(monkeypatch): - from main import _is_same_origin_request - req = _build_request("127.0.0.1:8888", origin = None) - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_matching_origin_is_same_origin(): - from main import _is_same_origin_request - req = _build_request("127.0.0.1:8888", origin = "http://127.0.0.1:8888") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_evil_origin_is_cross_origin(): - from main import _is_same_origin_request - req = _build_request("127.0.0.1:8888", origin = "https://evil.example") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_scheme_mismatch_is_cross_origin(): - # https origin against an http listener is not same-origin. - from main import _is_same_origin_request - req = _build_request("127.0.0.1:8888", origin = "https://127.0.0.1:8888") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_port_mismatch_is_cross_origin(): - # Same host different port is not same-origin per the web platform. - from main import _is_same_origin_request - req = _build_request("127.0.0.1:8888", origin = "http://127.0.0.1:5173") - assert _is_same_origin_request(req) is False - - -# ── Canonicalisation: default-port stripping + case folding ───────── - - -def test_is_same_origin_request_https_default_port_stripped_on_origin(): - """RFC 6454 strips default ports on Origin; canonicalise both sides so this stays same-origin.""" - from main import _is_same_origin_request - - req = _build_request("example.com:443", origin = "https://example.com", scheme = "https") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_http_default_port_stripped_on_origin(): - from main import _is_same_origin_request - req = _build_request("example.com:80", origin = "http://example.com") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_default_port_present_on_origin(): - """Mirror case: Origin carries the default port, netloc doesn't. Same-origin.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "https://example.com:443", scheme = "https") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_host_case_insensitive(): - """Host portion is case-insensitive per RFC 3986.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "http://EXAMPLE.com") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_scheme_case_insensitive(): - """Scheme portion is case-insensitive per RFC 3986.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "HTTP://example.com") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_null_origin_is_cross_origin(): - """Sandboxed iframes / file:// pages send ``Origin: null``; cross-origin.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "null") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_unparseable_origin_is_cross_origin(): - """Hostless garbage falls to cross-origin so a malformed header can't leak the bootstrap.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "not-a-url") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_userinfo_in_netloc_ignored(): - """``user:pass@host:port`` netlocs (RFC 3986) must compare equal to the credentials-less Origin.""" - from main import _is_same_origin_request - - req = _build_request("user:pass@example.com:80", origin = "http://example.com") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_explicit_non_default_port_still_mismatch(): - """Canonicalisation does NOT collapse non-default ports to default.""" - from main import _is_same_origin_request - - req = _build_request("example.com", origin = "https://example.com:9999", scheme = "https") - assert _is_same_origin_request(req) is False diff --git a/studio/backend/tests/test_index_bootstrap_origin_extra.py b/studio/backend/tests/test_index_bootstrap_origin_extra.py deleted file mode 100644 index feda88c14c..0000000000 --- a/studio/backend/tests/test_index_bootstrap_origin_extra.py +++ /dev/null @@ -1,189 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -"""Extra edge-case coverage for the bootstrap-pw cross-origin gate. -Companion to ``test_index_bootstrap_origin.py``: IPv6 netlocs, opaque origins -(``data:``, ``blob:``), comma-joined multi-Origin headers, and the -``localhost`` vs ``127.0.0.1`` distinct-origin rule. -""" - -from unittest.mock import MagicMock - - -def _build_request( - host: str, - origin, - scheme: str = "http", -) -> MagicMock: - request = MagicMock() - request.url.scheme = scheme - request.url.netloc = host - request.headers = {"origin": origin} if origin is not None else {} - return request - - -# ── IPv6 ──────────────────────────────────────────────────────────── - - -def test_is_same_origin_request_ipv6_loopback_same_origin(): - """Studio supports ``-H ::1`` binds; netloc is ``[::1]:8902``. Bare - ``partition(":")`` mis-parses the bracketed form and would refuse the - bootstrap on legitimate same-origin navigation. - """ - from main import _is_same_origin_request - - req = _build_request("[::1]:8902", origin = "http://[::1]:8902") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_ipv6_full_address_same_origin(): - from main import _is_same_origin_request - req = _build_request( - "[2001:db8::1]:8443", - origin = "https://[2001:db8::1]:8443", - scheme = "https", - ) - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_ipv6_default_port_stripped(): - """Browser drops :80 on ``http://[::1]``.""" - from main import _is_same_origin_request - - req = _build_request("[::1]:80", origin = "http://[::1]") - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_ipv6_case_insensitive(): - """Hex digits in IPv6 are case-insensitive per RFC 5952.""" - from main import _is_same_origin_request - - req = _build_request( - "[2001:DB8::1]:8443", - origin = "https://[2001:db8::1]:8443", - scheme = "https", - ) - assert _is_same_origin_request(req) is True - - -def test_is_same_origin_request_ipv6_different_host_cross_origin(): - from main import _is_same_origin_request - req = _build_request("[::1]:8902", origin = "http://[2001:db8::1]:8902") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_ipv6_port_mismatch_cross_origin(): - from main import _is_same_origin_request - req = _build_request("[::1]:8902", origin = "http://[::1]:9999") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_ipv6_userinfo_stripped(): - from main import _is_same_origin_request - req = _build_request("user:pass@[::1]:8902", origin = "http://[::1]:8902") - assert _is_same_origin_request(req) is True - - -# ── Opaque origins (data:, blob:) ─────────────────────────────────── - - -def test_is_same_origin_request_data_url_origin_is_cross_origin(): - """``data:`` URLs are opaque origins (HTML living standard); no host, never same-origin.""" - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "data:text/html,") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_blob_url_origin_is_cross_origin(): - """``blob:`` URLs carry the inner origin only in non-canonical form; the canonical comparison rejects them.""" - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "blob:http://127.0.0.1:8902/uuid") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_file_url_origin_is_cross_origin(): - """``file://`` pages usually send ``Origin: null``; older engines sent - ``Origin: file://``. Neither is same-origin vs an http listener. - """ - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "file://") - assert _is_same_origin_request(req) is False - - -# ── Multi-Origin header (comma-joined by Starlette) ──────────────── - - -def test_is_same_origin_request_comma_joined_origins_cross_origin(): - """Starlette joins repeated headers with ``, ``; the canonical parser can't - safely split this, so it falls to cross-origin. - """ - from main import _is_same_origin_request - - req = _build_request( - "127.0.0.1:8902", - origin = "http://127.0.0.1:8902, http://evil.example", - ) - assert _is_same_origin_request(req) is False - - -# ── localhost vs 127.0.0.1 (distinct origins per web platform) ────── - - -def test_is_same_origin_request_localhost_vs_127_is_cross_origin(): - """Browsers treat ``localhost`` and ``127.0.0.1`` as distinct origins; the - canonical comparison must not DNS-collapse them. - """ - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "http://localhost:8902") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_127_vs_localhost_is_cross_origin(): - from main import _is_same_origin_request - req = _build_request("localhost:8902", origin = "http://127.0.0.1:8902") - assert _is_same_origin_request(req) is False - - -# ── urlparse ValueError robustness ───────────────────────────────── - - -def test_is_same_origin_request_malformed_ipv6_bracket_is_cross_origin(): - """``urlparse`` raises ``ValueError('Invalid IPv6 URL')`` on unclosed - brackets (CVE-2024-11168 hardening). The gate must swallow it and fall to - cross-origin rather than 500 the SPA handler. - """ - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "http://[malformed") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_invalid_ipv6_address_is_cross_origin(): - """Bracketed but invalid IPv6 (e.g. ``[::g]``) also raises - ``ValueError`` inside ``urlparse``.""" - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "http://[::g]:8902") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_bracket_with_trailing_garbage_is_cross_origin(): - """Text after the closing bracket also raises inside ``urlparse``.""" - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "http://[2001:db8::1]extra:8902") - assert _is_same_origin_request(req) is False - - -def test_is_same_origin_request_empty_origin_header_is_cross_origin(): - """Explicit empty ``Origin:`` is not a valid serialised origin and must not - be conflated with a missing header; cross-origin, bootstrap withheld. - """ - from main import _is_same_origin_request - - req = _build_request("127.0.0.1:8902", origin = "") - assert _is_same_origin_request(req) is False diff --git a/studio/backend/tests/test_index_no_bootstrap_injection.py b/studio/backend/tests/test_index_no_bootstrap_injection.py new file mode 100644 index 0000000000..f1536cdd06 --- /dev/null +++ b/studio/backend/tests/test_index_no_bootstrap_injection.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""The seeded bootstrap password is never embedded in the served index.html. + +The seed is delivered only via the startup log and ``.bootstrap_password``; the +served page must never carry it, for any caller. +""" + +from fastapi import FastAPI +from starlette.testclient import TestClient + + +def _client(tmp_path, monkeypatch): + import main + + # Force the conditions that previously triggered injection so this proves the + # seed is withheld even with a pending password change and a seed present. + monkeypatch.setattr(main.storage, "requires_password_change", lambda *a, **k: True) + build = tmp_path / "build" + build.mkdir() + (build / "index.html").write_text("ok") + app = FastAPI() + app.state.bootstrap_password = "SEED-DO-NOT-LEAK" + assert main.setup_frontend(app, build) is True + return TestClient(app) + + +def test_index_never_contains_bootstrap_seed(tmp_path, monkeypatch): + client = _client(tmp_path, monkeypatch) + # root, SPA fallback, and a same-origin request all get a clean page. + for path, headers in ( + ("/", {}), + ("/some/spa/route", {}), + ("/", {"origin": "http://testserver"}), + ): + r = client.get(path, headers = headers) + assert r.status_code == 200, (path, r.status_code) + assert "SEED-DO-NOT-LEAK" not in r.text, path + assert "__UNSLOTH_BOOTSTRAP__" not in r.text, path + # no per-request injection means no Origin-varying and no script nonce + assert "x-internal-script-nonce" not in {k.lower() for k in r.headers} diff --git a/tests/studio/playwright_chat_ime_i18n.py b/tests/studio/playwright_chat_ime_i18n.py index 9c01e95fd4..3189ff475c 100644 --- a/tests/studio/playwright_chat_ime_i18n.py +++ b/tests/studio/playwright_chat_ime_i18n.py @@ -28,6 +28,7 @@ from _playwright_robust import ( # noqa: E402 ) BASE = os.environ["BASE_URL"] +OLD = os.environ["STUDIO_OLD_PW"] NEW = os.environ["STUDIO_NEW_PW"] ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_ime") ART = Path(ART_DIR) @@ -161,6 +162,10 @@ with sync_playwright() as p: pass pw_field = page.locator("#new-password") pw_field.wait_for(state = "visible", timeout = 60_000) + # Served page no longer autofills the seed; fill Current password when shown. + cur_pw = page.locator("#current-password") + if cur_pw.count(): + cur_pw.fill(OLD, timeout = 60_000) pw_field.fill(NEW, timeout = 60_000) page.fill("#confirm-password", NEW, timeout = 60_000) shoot("01-change-password-filled") diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 065ba7a745..4d995aaebd 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -236,6 +236,10 @@ with sync_playwright() as p: pass # best-effort -- proceed even if network never idles pw_field = page.locator("#new-password") pw_field.wait_for(state = "visible", timeout = 60_000) + # Served page no longer autofills the seed; fill Current password when shown. + cur_pw = page.locator("#current-password") + if cur_pw.count(): + cur_pw.fill(OLD, timeout = 60_000) # Do NOT shoot() between wait_for and fill -- the screenshot's # font-load wait can let a background poll detach the form. pw_field.fill(NEW, timeout = 60_000) diff --git a/tests/studio/playwright_extra_ui.py b/tests/studio/playwright_extra_ui.py index 209a8a06f1..f6ada49f00 100644 --- a/tests/studio/playwright_extra_ui.py +++ b/tests/studio/playwright_extra_ui.py @@ -134,6 +134,10 @@ with sync_playwright() as p: pass pw_field = page.locator("#new-password") pw_field.wait_for(state = "visible", timeout = 60_000) + # Served page no longer autofills the seed; fill Current password when shown. + cur_pw = page.locator("#current-password") + if cur_pw.count(): + cur_pw.fill(OLD, timeout = 60_000) pw_field.fill(NEW, timeout = 60_000) page.fill("#confirm-password", NEW, timeout = 60_000) # Click submit AND wait for the POST response together so a server-side reject diff --git a/tests/studio/test_composer_rtl_bidi_attribute.py b/tests/studio/test_composer_rtl_bidi_attribute.py index defbfab86c..c8a67c36d8 100644 --- a/tests/studio/test_composer_rtl_bidi_attribute.py +++ b/tests/studio/test_composer_rtl_bidi_attribute.py @@ -1,5 +1,5 @@ """RTL bidi contract on chat composers: all three need dir="auto", and the IME -smoke must drop the dead STUDIO_OLD_PW env var.""" +smoke must supply STUDIO_OLD_PW now that the served page no longer autofills the seed.""" from __future__ import annotations @@ -40,36 +40,36 @@ def test_compare_composer_has_dir_auto(): assert 'dir="auto"' in block, 'compare composer is missing dir="auto"' -def test_ime_workflow_step_does_not_set_studio_old_pw(): +def test_ime_workflow_step_sets_studio_old_pw(): yml = WORKFLOW_YML.read_text() drive_idx = yml.find("Drive IME + multilingual paste regression") assert drive_idx != -1, "IME drive step not found in workflow" next_step_idx = yml.find("- name:", drive_idx + 1) drive_block = yml[drive_idx : next_step_idx if next_step_idx != -1 else None] assert ( - "STUDIO_OLD_PW" not in drive_block - ), "IME drive step still passes dead STUDIO_OLD_PW env var" + "STUDIO_OLD_PW" in drive_block + ), "IME drive step must pass STUDIO_OLD_PW now the page no longer autofills the seed" assert "STUDIO_NEW_PW" in drive_block, "IME drive step missing STUDIO_NEW_PW" -def test_ime_pass_password_step_does_not_export_old_pw(): +def test_ime_pass_password_step_exports_old_pw(): yml = WORKFLOW_YML.read_text() pass_idx = yml.find("Pass bootstrap pw for IME / i18n test") assert pass_idx != -1, "IME password setup step not found" next_step_idx = yml.find("- name:", pass_idx + 1) pass_block = yml[pass_idx : next_step_idx if next_step_idx != -1 else None] assert ( - "STUDIO_IME_OLD_PW" not in pass_block - ), "IME password setup still exports dead STUDIO_IME_OLD_PW" + "STUDIO_IME_OLD_PW" in pass_block + ), "IME password setup must export STUDIO_IME_OLD_PW for the non-autofill flow" assert "STUDIO_IME_NEW_PW" in pass_block -def test_ime_playwright_script_does_not_read_studio_old_pw(): +def test_ime_playwright_script_reads_studio_old_pw(): src = IME_PY.read_text() code_only = re.sub(r'""".*?"""', "", src, flags = re.DOTALL) assert ( - "STUDIO_OLD_PW" not in code_only - ), "IME Playwright script still references dead STUDIO_OLD_PW env var" + 'os.environ["STUDIO_OLD_PW"]' in code_only + ), "IME Playwright script must read STUDIO_OLD_PW to fill the current-password field" assert 'os.environ["STUDIO_NEW_PW"]' in code_only