Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
danielhanchen
f5517bf39c Merge remote-tracking branch 'origin/main' into r7140
# Conflicts:
#	studio/backend/main.py
2026-07-15 14:31:41 +00:00
danielhanchen
70e4821f36 Point to bootstrap password on restart before first login for PR #7140 2026-07-15 14:26:15 +00:00
danielhanchen
1a2cb62094 Update IME auth-flow guard tests for the non-autofill flow for PR #7140 2026-07-15 14:26:15 +00:00
danielhanchen
7e11dc6f36 Fill current password in UI smoke tests after removing autofill for PR #7140 2026-07-15 14:04:00 +00:00
danielhanchen
7145d4762f Fix stale docstring comment to match file-based seed delivery for PR #7140 2026-07-15 13:42:16 +00:00
danielhanchen
777e4abd7a Point to .bootstrap_password file instead of printing the seed for PR #7140 2026-07-15 13:39:35 +00:00
danielhanchen
3e03c40161 Print bootstrap password at startup for PR #7140 2026-07-15 13:19:17 +00:00
pre-commit-ci[bot]
55ef7a1fe1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-15 12:43:08 +00:00
danielhanchen
6a1d3cfdc0 Do not embed the seeded bootstrap password in the served page 2026-07-15 12:41:05 +00:00
10 changed files with 86 additions and 652 deletions

View file

@ -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'

View file

@ -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'<script nonce="{nonce}">window.__UNSLOTH_BOOTSTRAP__={payload}</script>'
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 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("/")

View file

@ -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

View file

@ -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

View file

@ -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,<script>alert(1)</script>")
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

View file

@ -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("<html><head></head><body>ok</body></html>")
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}

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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