mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-10 00:08:58 +00:00
* Studio: set the admin password before exposing it on the network On first run Studio seeds the default `unsloth` admin with a random bootstrap password and embeds it into index.html (window.__UNSLOTH_BOOTSTRAP__) so the local user can change it without typing it. A request with no Origin header counts as same-origin, which is what a normal top-level GET sends, so the page hands out the password to whoever loads it. That is harmless on the default 127.0.0.1 bind, but `--secure` (public Cloudflare tunnel) and `--host 0.0.0.0` (raw port reachable on the network) would serve the plaintext admin password to remote visitors during the bootstrap window. Fix this at the source: when launching a network-exposed web UI, prompt the operator in the terminal for a real admin password (with confirmation) before the socket binds or the tunnel opens, and persist it via update_password (which clears must_change_password and deletes the .bootstrap_password file). After that there is no bootstrap secret to leak. Non-interactive launches can supply it via UNSLOTH_STUDIO_ADMIN_PASSWORD. The masked reader echoes '*' per character and works on Linux, macOS, and Windows (PowerShell/cmd). Loopback binds, --api-only (no web UI), and Colab are unaffected. As defense in depth, the index handler now embeds the bootstrap object only for a direct local navigation: same-origin AND a loopback TCP peer with no proxy/tunnel forwarding headers (cf-ray, cf-connecting-ip, x-forwarded-for, x-forwarded-host, x-real-ip, forwarded). Colab stays exempt. This keeps the password off the wire even when the prompt is skipped (no TTY and no env var). Adds unit coverage for the prompt/confirm/decision logic, an integration test that provisioning clears the bootstrap state, and regression tests for the local-direct gate (loopback/IPv6/mapped/localhost peers, LAN/public peers, missing client, each forwarding header, spoofed XFF, and the Colab exemption). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fail fast on an explicitly empty admin-password env var resolve_admin_password_source treated UNSLOTH_STUDIO_ADMIN_PASSWORD="" like the var was unset and fell back to the bootstrap backstop. Treat any set value (including empty) as the env source so it reaches the minimum-length guard and refuses to expose the server instead of silently keeping the seeded password. * Studio: apply repo kwarg-spacing format to the secure-admin-password files * Studio: drop the pre-exposure password prompt; keep the local-direct gate Per review, the blocking prompt added friction for --secure / 0.0.0.0 first-run launches without extra security: the local-direct injection gate in main.py already keeps the bootstrap password off the network for any remote request. Remove the prompt module and its tests; the gate plus the existing must_change_password first-login flow are the fix. * Studio: shut down an exposed first-run instance if the admin password is never changed The local-direct gate keeps the seeded bootstrap password off the network, but it stays a valid credential until first login changes it. For an exposed web UI (--secure / 0.0.0.0, not --api-only, not Colab), arm a daemon timer: if the password is still the seeded one after the deadline (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT, default 3600s, 0 disables), print a message and shut Studio down via the existing graceful-shutdown path; if it was changed, leave Studio running. * Studio: revert the local-direct injection gate; keep the 1-hour auto-shutdown Per maintainer decision, keep the first-run auto-fill behavior unchanged (the bootstrap password still seeds the login form for convenience) and rely on the exposed-instance auto-shutdown to bound the window: an exposed web UI that never changes the seeded admin password is torn down after UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT (default 1h). Restores studio/backend/main.py and its origin test to upstream. * Studio: render the bootstrap-timeout shutdown message with a human duration The message hardcoded 'minute(s)' via timeout//60, so a sub-minute timeout (e.g. a 30s test value) printed 'within 1 minute(s)'. Add _format_duration so it reads '30 seconds' / '1 minute 30 seconds' / '60 minutes' as appropriate. The default 3600s still renders '60 minutes'. * Studio: drop stale local-direct gate reference from bootstrap_timeout docstring The gate was reverted (timer-only), so the module docstring should not describe a main.py gate that no longer exists. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
145 lines
4.7 KiB
Python
145 lines
4.7 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
|
|
|
|
"""Auto-shutdown for an exposed first-run Studio whose admin password is unchanged.
|
|
|
|
On a fresh install the seeded bootstrap admin password stays a valid login
|
|
credential until first login changes it. When the web UI is put on the network
|
|
(``--secure`` / ``0.0.0.0``) and nobody completes that first-login change within
|
|
a deadline, tear Studio down so a fresh, unconfigured instance does not stay
|
|
publicly reachable indefinitely. If the password was changed, Studio keeps
|
|
running.
|
|
|
|
Scope: web UI launches only (never ``--api-only``, which authenticates by API
|
|
key rather than the admin password, and never Colab). Configurable via
|
|
``UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT`` (seconds; default 3600; ``0`` disables).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
|
|
BOOTSTRAP_TIMEOUT_ENV_VAR = "UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT"
|
|
DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS = 3600
|
|
|
|
|
|
def bootstrap_timeout_seconds(env = None) -> int:
|
|
"""Resolve the deadline in seconds. ``0`` (or invalid/negative) disables it.
|
|
|
|
A malformed value falls back to the default rather than disabling, so a typo
|
|
cannot silently remove the protection.
|
|
"""
|
|
env = os.environ if env is None else env
|
|
raw = env.get(BOOTSTRAP_TIMEOUT_ENV_VAR)
|
|
if raw is None or raw.strip() == "":
|
|
return DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
return DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS
|
|
return value if value > 0 else 0
|
|
|
|
|
|
def _is_exposed_bind(host: str, secure: bool) -> bool:
|
|
"""True when this launch puts the web UI on the network (tunnel or non-loopback)."""
|
|
if secure:
|
|
return True
|
|
if host in ("0.0.0.0", "::"):
|
|
return True
|
|
try:
|
|
from utils.host_policy import is_external_host
|
|
except Exception:
|
|
return False
|
|
return bool(is_external_host(host))
|
|
|
|
|
|
def should_arm_bootstrap_timeout(
|
|
*,
|
|
host: str,
|
|
secure: bool,
|
|
api_only: bool,
|
|
frontend_served: bool,
|
|
is_colab: bool,
|
|
requires_change: bool,
|
|
timeout_seconds: int,
|
|
) -> bool:
|
|
"""Whether to arm the deadline: only for an exposed web UI whose seeded admin
|
|
password is still unchanged. Pure decision (no I/O) for cheap unit testing."""
|
|
if timeout_seconds <= 0:
|
|
return False
|
|
if api_only or not frontend_served or is_colab:
|
|
return False
|
|
if not requires_change:
|
|
return False
|
|
return _is_exposed_bind(host, secure)
|
|
|
|
|
|
def _format_duration(seconds: int) -> str:
|
|
"""Human-friendly duration for the shutdown message (seconds under a minute)."""
|
|
|
|
def _plural(n: int, unit: str) -> str:
|
|
return f"{n} {unit}{'' if n == 1 else 's'}"
|
|
|
|
if seconds < 60:
|
|
return _plural(seconds, "second")
|
|
minutes, rem = divmod(seconds, 60)
|
|
label = _plural(minutes, "minute")
|
|
if rem:
|
|
label += f" {_plural(rem, 'second')}"
|
|
return label
|
|
|
|
|
|
def enforce_bootstrap_password_deadline(
|
|
storage,
|
|
trigger_shutdown,
|
|
*,
|
|
timeout_seconds: int,
|
|
logger = None,
|
|
) -> bool:
|
|
"""Deadline handler: shut down iff the seeded admin password is still unchanged.
|
|
|
|
Returns True if it shut Studio down, False if it left it running (the
|
|
password was changed in time).
|
|
"""
|
|
try:
|
|
still_default = storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME)
|
|
except Exception:
|
|
return False
|
|
if not still_default:
|
|
return False # password changed in time -> leave Studio running
|
|
|
|
message = (
|
|
"\nUnsloth Studio was exposed on the network but its default admin "
|
|
f"password was not changed within {_format_duration(timeout_seconds)}. "
|
|
"Shutting down to avoid leaving an unsecured public instance running.\n"
|
|
"Next time, sign in and change the password on first login, or set "
|
|
f"{BOOTSTRAP_TIMEOUT_ENV_VAR}=0 to disable this timeout."
|
|
)
|
|
if logger is not None:
|
|
logger.warning(message)
|
|
print(message, file = sys.stderr, flush = True)
|
|
try:
|
|
trigger_shutdown()
|
|
except Exception as e: # shutdown is best-effort; never raise from the timer
|
|
if logger is not None:
|
|
logger.warning("Bootstrap-timeout shutdown failed: %s", e)
|
|
return True
|
|
|
|
|
|
def arm_bootstrap_timeout(
|
|
storage,
|
|
trigger_shutdown,
|
|
*,
|
|
timeout_seconds: int,
|
|
logger = None,
|
|
) -> "threading.Timer":
|
|
"""Start a daemon timer that enforces the deadline. Returns the Timer."""
|
|
timer = threading.Timer(
|
|
timeout_seconds,
|
|
enforce_bootstrap_password_deadline,
|
|
args = (storage, trigger_shutdown),
|
|
kwargs = {"timeout_seconds": timeout_seconds, "logger": logger},
|
|
)
|
|
timer.daemon = True
|
|
timer.start()
|
|
return timer
|