* Studio: stop leaking seeded admin pw to cross-origin callers
The "/" SPA fallback serves index.html with an inline
``window.__UNSLOTH_BOOTSTRAP__`` script containing the seeded admin
password while a password change is pending. Default web mode runs
``CORSMiddleware`` with ``allow_origins=["*"]`` + ``allow_credentials=
True``, which reflects an attacker-controlled ``Origin`` back on every
request and sets Access-Control-Allow-Credentials true. The combination
let any cross-origin page ``fetch('/')`` with credentials and read the
bootstrap admin password out of the HTML body. The API smoke
``CORS: GET / leaks bootstrap pw to cross-origin caller`` audit already
tracked this (tests/studio/studio_api_smoke.py:224) but did not gate CI.
Gate ``_inject_bootstrap`` on a same-origin check: legitimate top-level
navigations omit ``Origin`` on most engines, so the absence of the
header is treated as same-origin; when the header IS present and does
not match ``request.url.scheme://request.url.netloc`` exactly, we now
skip injecting the bootstrap tag. ``Vary: Origin`` is added so an
intermediary cache cannot serve a same-origin response (with bootstrap)
to a later cross-origin caller (and vice versa).
Coverage: ``test_index_bootstrap_origin.py`` exercises the helper with
missing / matching / evil / scheme-mismatch / port-mismatch origins.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: tighten comments on bootstrap cross-origin helper
* Studio: canonicalise Origin before same-origin gate
A plain string-compare between the Origin header and request.url.netloc
misclassifies legitimate same-origin requests as cross-origin in three
scenarios:
- Browser strips the default port from Origin (https://example.com)
but Starlette's netloc keeps it (example.com:443). Per RFC 6454 the
default port is dropped on the wire, so the strings will not match
even though the requests share an origin.
- Host case differs (Origin: http://Example.com vs netloc:
example.com). Per RFC 3986 host comparison is case-insensitive.
- Scheme case differs (HTTP:// vs http://). Per RFC 3986 the scheme
is also case-insensitive.
These are usability degradations rather than security gaps (legitimate
user denied the bootstrap injection, no attacker gain), but worth
shipping so non-default Studio deployments keep the change-password
auto-fill.
Adds _canonical_origin(scheme, netloc) -> (scheme, host, port) and
compares the canonical tuples. Default-port lookup covers
http/https/ws/wss; userinfo (user:pass@) is stripped per RFC 3986
since Origin never carries credentials. Origin: "null" (sandboxed
iframes, file:// pages) and unparseable values collapse to cross-
origin so the bootstrap pw is never leaked through those paths either.
Tests: 14 cases (was 5). Covers the original same/missing/evil/
scheme/port matrix plus default-port stripping in both directions,
host + scheme case folding, Origin: null, garbage values, and
userinfo-in-netloc.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix IPv6 netloc parsing for PR #5739
The canonical-origin helper used ``netloc.partition(":")`` which
mis-parses bracketed IPv6 hosts (``[::1]:8902`` -> host=``[``,
port-str=``:1]:8902``). The int() then raises and the canonicaliser
returns None, so every IPv6 same-origin request is misclassified as
cross-origin and Studio refuses to inject the bootstrap pw on a
legitimate top-level nav when launched with ``unsloth studio -H ::1``.
Bracket-aware split per RFC 3986 §3.2.2, plus extra regression tests
for IPv6, opaque (data:/blob:/file:), comma-joined multi-Origin and
localhost-vs-127 cases.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Guard urlparse ValueError in same-origin gate
urlparse raises ValueError on malformed bracketed Origin values
(unclosed [, invalid IPv6 hex, text after ]) and on a few NFKC
edge cases since Py 3.8. Without a guard, a request carrying
Origin: http://[malformed surfaced as HTTP 500 from the SPA
handler rather than being treated as cross-origin per the
docstring's safer-default rule. Wrap both urlparse calls in
try/except ValueError and return False on parse failure.
Also distinguish a missing Origin header (top-level same-document
GET, treat as same-origin) from an explicit empty string (not a
valid serialised origin per RFC 6454 §6.1, treat as cross-origin).
Four new regression tests pinned down by the PR audit: malformed
IPv6 bracket, invalid IPv6 hex, bracket with trailing garbage,
and the empty Origin header.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Shorten origin-gate comments for PR #5739
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
144 lines
5 KiB
Python
144 lines
5 KiB
Python
# 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; Starlette's netloc may still
|
|
carry ``:443``. 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():
|
|
"""Garbage values without a host fall to cross-origin; a malformed header
|
|
must not 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
|