Compare commits
9 commits
main
...
studio-no-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5517bf39c | ||
|
|
70e4821f36 | ||
|
|
1a2cb62094 | ||
|
|
7e11dc6f36 | ||
|
|
7145d4762f | ||
|
|
777e4abd7a | ||
|
|
3e03c40161 | ||
|
|
55ef7a1fe1 | ||
|
|
6a1d3cfdc0 |
10 changed files with 86 additions and 652 deletions
10
.github/workflows/studio-ui-smoke.yml
vendored
10
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
42
studio/backend/tests/test_index_no_bootstrap_injection.py
Normal file
42
studio/backend/tests/test_index_no_bootstrap_injection.py
Normal 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}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue