unsloth/studio/backend/routes/auth.py
Daniel Han 62410bf2af
studio: proxy-aware login rate-limit; allow google favicons in CSP (#5489)
* studio: proxy-aware login rate-limit; allow google favicons in CSP

Two follow-ups to #5375's auth + headers hardening.

Login rate-limit:
The per-IP bucket keyed on request.client.host alone. Behind any
reverse proxy or shared NAT it lumps everyone together (one user's
typos lock everyone out for 60 seconds; the 429 detail leaked the
proxy/internal IP back to clients). The bucket key is now
(client-ip, username.lower) so:
  - one wrong-password run does not block another user from the same IP
  - one IP does not block the same user from a different IP
The 429 detail body no longer interpolates the IP. Behind a proxy
clients can set UNSLOTH_STUDIO_TRUST_FORWARDED=1 so the limiter
honours X-Forwarded-For / Forwarded; off by default so a direct
caller cannot spoof the header.

CSP img-src:
components/assistant-ui/sources.tsx renders citation favicons from
https://www.google.com/s2/favicons. The current img-src allows
t0..t3.gstatic.com (used for other Google-hosted icons) but not the
main host the favicon URL points to, so every citation icon
CSP-blocks and falls back to gray initials. Adding www.google.com to
img-src is the same shape as #5409's connect-src HF allowlist fix.

Tests:
  - test_login_rate_limit.py (new): _client_ip respects
    UNSLOTH_STUDIO_TRUST_FORWARDED for X-Forwarded-For and Forwarded;
    bucket key is composed of (ip, lower(username)) and isolates
    cross-user and cross-IP buckets; 429 detail does not contain the
    client IP; Retry-After header preserved.
  - test_middleware.py: new test_img_src_allows_google_favicons pins
    that www.google.com is in the img-src directive and the existing
    gstatic CDNs stay allowed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: normalise forwarded IPs, IP-wide aggregate cap, unknown-user sentinel

Reviewer follow-ups to the proxy-aware login rate-limit PR.

Forwarded address normalisation: with
UNSLOTH_STUDIO_TRUST_FORWARDED=1, raw `X-Forwarded-For` and
`Forwarded: for=` values such as `198.51.100.7:50001` or
`"[2001:db8::1]:50001"` were carried verbatim into the bucket key,
so one client emitting a fresh source port per attempt split into
many buckets and bypassed _LOGIN_MAX_FAILS. _normalize_forwarded_addr
now strips quotes, optional `[..]:port` for IPv6 and `host:port` for
IPv4, and validates as an IP literal; garbage values fall through to
the direct request.client.host. Forwarded parsing also isolates the
first forwarded-element so a multi-element header cannot create
attacker-controlled bucket strings.

Spray protection: the (ip, username) key removed the aggregate
per-IP throttle the pre-PR limiter provided. A client rotating
nonexistent usernames produced [401, 401, 401, 401, 401, 401] where
pre-PR produced [401, 401, 401, 401, 401, 429]. Restored the
aggregate via a parallel _LOGIN_IP_BUCKETS table (max 30 fails / 60s
per IP) checked alongside the per-(ip, username) bucket; both
buckets must be cleared on a successful login.

Bucket cardinality: every distinct unauthenticated username
allocated a new (ip, username) bucket entry without bound. 1,000
random usernames from one IP produced 1,000 buckets. Failures whose
username does not exist now record into a single sentinel key
(ip, "\x00unknown-user") so cardinality stays at one per IP for the
unknown path. The known-user path additionally enforces a global
hard cap (_LOGIN_MAX_BUCKETS = 4096) that prunes stale empty buckets
on overflow and otherwise folds the failure into the per-IP bucket
only.

Test:
  - python -m pytest studio/backend/tests/test_login_rate_limit.py -q
    -> 19 passed (was 12 before this commit; +5 forwarded-address
       normalisation, +1 sentinel bucket, +1 bucket cap)

CSP comment refreshed to mention `www.google.com` alongside
*.gstatic.com so future readers see why the host is allowlisted.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: tokenise img-src assertion to silence CodeQL substring rule

The new CSP google-favicon test used 'host string in directive string'
which CodeQL flagged as py/incomplete-url-substring-sanitization
(the substring could appear at an arbitrary position in a URL).
The assertion is checking a CSP directive, not URL sanitisation, but
splitting the directive on whitespace and asserting against the
tokenised source list expresses the same intent and matches the
exact CSP source expression. CodeQL no longer treats it as a URL
substring check.

Test: python -m pytest studio/backend/tests/test_middleware.py -q
      -> 14 passed

* studio: use any(src == host) for CSP source asserts

CodeQL's py/incomplete-url-substring-sanitization still flagged the
tokenised "host in img_sources" check. Switching to
`any(src == host for src in img_sources)` makes the comparison an
exact-equality (not substring) match, which the rule does not flag.

Test: python -m pytest studio/backend/tests/test_middleware.py -q
      -> 14 passed

* studio: trim verbose rate-limit + CSP comments

Compress the 6-line constants header on _LOGIN_BUCKETS to 3 lines and
the per-helper docstrings on _trust_forwarded_for / _normalize_forwarded_addr
to one line each. Same code, fewer in-flow tutorials.

Note in the CSP comment that www.google.com is the active favicon host
(used by sources.tsx for s2/favicons citations); *.gstatic.com stays as
legacy faviconV2 coverage but the SPA no longer fetches it.

33 tests in test_login_rate_limit.py + test_middleware.py still pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 00:02:15 -07:00

416 lines
14 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
"""
Authentication API routes
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import ipaddress
import os
import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from models.auth import (
ApiKeyListResponse,
ApiKeyResponse,
AuthLoginRequest,
AuthStatusResponse,
ChangePasswordRequest,
CreateApiKeyRequest,
CreateApiKeyResponse,
DesktopLoginRequest,
RefreshTokenRequest,
)
from models.users import Token
from auth import storage, hashing
from auth.authentication import (
create_access_token,
create_refresh_token,
get_current_subject,
get_current_subject_allow_password_change,
refresh_access_token,
)
router = APIRouter()
# Per-(ip, username) bucket + per-IP aggregate. Account bucket stops one user's
# typos from blocking others; the aggregate stops username-rotation spray.
# Single-process only -- multi-worker deployments need a shared store.
_LOGIN_BUCKETS: dict[tuple[str, str], deque] = {}
_LOGIN_IP_BUCKETS: dict[str, deque] = {}
_LOGIN_BUCKETS_LOCK = threading.Lock()
_LOGIN_WINDOW_SECONDS = 60.0
_LOGIN_MAX_FAILS = 5
_LOGIN_IP_MAX_FAILS = 30
_LOGIN_LOCKOUT_SECONDS = 60
# Bucket-dict cap. On overflow we prune stale entries; if still full the
# failure folds into the per-IP aggregate only.
_LOGIN_MAX_BUCKETS = 4096
# Unrepresentable as a real username (leading NUL); folds unknown-user attempts
# into one slot so attacker cardinality cannot blow the bucket dict.
_UNKNOWN_LOGIN_USER = "\x00unknown-user"
def _trust_forwarded_for() -> bool:
"""Honour X-Forwarded-For only when UNSLOTH_STUDIO_TRUST_FORWARDED is set.
Off by default so a direct caller cannot spoof the header.
"""
return os.environ.get("UNSLOTH_STUDIO_TRUST_FORWARDED", "").lower() in (
"1",
"true",
"yes",
)
def _normalize_forwarded_addr(value: str) -> str:
"""Parse an XFF / Forwarded `for=` value into a bare IP (port-stripped)."""
value = (value or "").strip().strip('"')
if not value or value.lower() == "unknown":
return ""
if value.startswith("["):
# Bracketed IPv6, optionally with port.
end = value.find("]")
if end <= 0:
return ""
host = value[1:end]
elif value.count(":") == 1:
# IPv4:port. Bare IPv6 has multiple colons and takes the else branch.
head, _, tail = value.rpartition(":")
host = head if tail.isdigit() and head else value
else:
host = value
try:
return str(ipaddress.ip_address(host))
except ValueError:
return ""
def _forwarded_for_from_element(element: str) -> str:
"""Pick the `for=` token out of a single ``Forwarded`` element."""
for tok in element.split(";"):
key, sep, val = tok.strip().partition("=")
if sep and key.lower() == "for":
return _normalize_forwarded_addr(val)
return ""
def _client_ip(request: Request | None) -> str:
if request is None:
return "_unknown"
if _trust_forwarded_for():
xff = request.headers.get("x-forwarded-for", "")
if xff:
# First entry is the originating client.
normalized = _normalize_forwarded_addr(xff.split(",", 1)[0])
if normalized:
return normalized
fwd = request.headers.get("forwarded", "")
if fwd:
# First element only -- multi-element headers cannot fork buckets.
normalized = _forwarded_for_from_element(fwd.split(",", 1)[0])
if normalized:
return normalized
return (request.client.host if request.client else None) or "_unknown"
def _bucket_key(request: Request | None, username: str) -> tuple[str, str]:
return (_client_ip(request), (username or "").casefold())
def _unknown_user_key(request: Request | None) -> tuple[str, str]:
return (_client_ip(request), _UNKNOWN_LOGIN_USER)
def _prune_bucket(bucket: deque, now: float) -> None:
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
def _prune_stale_buckets(now: float) -> None:
"""Drop empty / expired account buckets to bound memory under spray."""
stale: list[tuple[str, str]] = []
for key, bucket in _LOGIN_BUCKETS.items():
_prune_bucket(bucket, now)
if not bucket:
stale.append(key)
for key in stale:
_LOGIN_BUCKETS.pop(key, None)
def _record_login_failure(key: tuple[str, str]) -> int:
now = time.monotonic()
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
ip_bucket = _LOGIN_IP_BUCKETS.setdefault(ip, deque())
_prune_bucket(ip_bucket, now)
ip_bucket.append(now)
if key not in _LOGIN_BUCKETS and len(_LOGIN_BUCKETS) >= _LOGIN_MAX_BUCKETS:
_prune_stale_buckets(now)
if key in _LOGIN_BUCKETS or len(_LOGIN_BUCKETS) < _LOGIN_MAX_BUCKETS:
account_bucket = _LOGIN_BUCKETS.setdefault(key, deque())
_prune_bucket(account_bucket, now)
account_bucket.append(now)
return len(account_bucket)
# Bucket dict is at its cap; per-IP cap still applies via ip_bucket.
return len(ip_bucket)
def _blocked_for(bucket: deque | None, now: float, max_fails: int) -> int:
if not bucket:
return 0
_prune_bucket(bucket, now)
if len(bucket) >= max_fails:
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
return 0
def _login_blocked(key: tuple[str, str]) -> int:
"""Return seconds until the next attempt is allowed, or 0."""
now = time.monotonic()
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
return max(
_blocked_for(_LOGIN_BUCKETS.get(key), now, _LOGIN_MAX_FAILS),
_blocked_for(_LOGIN_IP_BUCKETS.get(ip), now, _LOGIN_IP_MAX_FAILS),
)
def _clear_login_bucket(key: tuple[str, str]) -> None:
ip, _username = key
with _LOGIN_BUCKETS_LOCK:
_LOGIN_BUCKETS.pop(key, None)
_LOGIN_IP_BUCKETS.pop(ip, None)
@router.get("/status", response_model = AuthStatusResponse)
async def auth_status() -> AuthStatusResponse:
"""Auth initialization state; ``default_username`` is exposed for first-boot UI prefill only."""
return AuthStatusResponse(
initialized = storage.is_initialized(),
default_username = storage.DEFAULT_ADMIN_USERNAME,
requires_password_change = storage.requires_password_change(
storage.DEFAULT_ADMIN_USERNAME
)
if storage.is_initialized()
else True,
)
@router.post("/login", response_model = Token)
async def login(payload: AuthLoginRequest, request: Request) -> Token:
"""Login with username/password. Per-account + per-IP rate-limited."""
key = _bucket_key(request, payload.username)
unknown_key = _unknown_user_key(request)
blocked_for = max(_login_blocked(key), _login_blocked(unknown_key))
if blocked_for > 0:
raise HTTPException(
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
# IP is intentionally not interpolated into the body; behind a
# proxy or NAT it is either misleading or an info leak.
detail = (
f"Too many failed login attempts. "
f"Try again in {blocked_for} seconds."
),
headers = {"Retry-After": str(blocked_for)},
)
record = storage.get_user_and_secret(payload.username)
if record is None:
# Record under a single sentinel key per IP so attacker-controlled
# username cardinality does not allocate buckets without bound.
_record_login_failure(unknown_key)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
)
salt, pwd_hash, _jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
_record_login_failure(key)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
)
_clear_login_bucket(key)
_clear_login_bucket(unknown_key)
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer",
must_change_password = must_change_password,
)
@router.post("/logout", status_code = status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Response:
"""Revoke refresh tokens for the subject; the access token is stateless and expires on its own."""
try:
storage.revoke_user_refresh_tokens(current_subject)
except Exception:
pass
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
return Response(status_code = status.HTTP_204_NO_CONTENT)
@router.post("/desktop-login", response_model = Token)
async def desktop_login(payload: DesktopLoginRequest) -> Token:
"""Exchange a local desktop secret for normal admin-subject tokens."""
username = storage.validate_desktop_secret(payload.secret)
if username is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Desktop authentication failed",
)
return Token(
access_token = create_access_token(subject = username, desktop = True),
refresh_token = create_refresh_token(subject = username, desktop = True),
token_type = "bearer",
must_change_password = False,
)
@router.post("/refresh", response_model = Token)
async def refresh(payload: RefreshTokenRequest) -> Token:
"""Exchange a refresh token for a new access+refresh pair (single-use)."""
consumed = storage.consume_refresh_token(payload.refresh_token)
if consumed is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired refresh token",
)
username, is_desktop = consumed
new_access_token = create_access_token(subject = username, desktop = is_desktop)
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
return Token(
access_token = new_access_token,
refresh_token = new_refresh_token,
token_type = "bearer",
must_change_password = False
if is_desktop
else storage.requires_password_change(username),
)
@router.post("/change-password", response_model = Token)
async def change_password(
payload: ChangePasswordRequest,
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Token:
"""Allow the authenticated user to replace the default password."""
record = storage.get_user_and_secret(current_subject)
if record is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "User session is invalid",
)
salt, pwd_hash, _jwt_secret, _must_change_password = record
if not hashing.verify_password(payload.current_password, salt, pwd_hash):
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Current password is incorrect",
)
if payload.current_password == payload.new_password:
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = "New password must be different from the current password",
)
storage.update_password(current_subject, payload.new_password)
storage.revoke_user_refresh_tokens(current_subject)
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
access_token = create_access_token(subject = current_subject)
refresh_token = create_refresh_token(subject = current_subject)
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer",
must_change_password = False,
)
# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------
def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
return ApiKeyResponse(
id = row["id"],
name = row["name"],
key_prefix = row["key_prefix"],
created_at = row["created_at"],
last_used_at = row.get("last_used_at"),
expires_at = row.get("expires_at"),
is_active = bool(row["is_active"]),
)
@router.post("/api-keys", response_model = CreateApiKeyResponse)
async def create_api_key(
payload: CreateApiKeyRequest,
current_subject: str = Depends(get_current_subject),
) -> CreateApiKeyResponse:
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
expires_at = None
if payload.expires_in_days is not None:
expires_at = (
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
).isoformat()
raw_key, row = storage.create_api_key(
username = current_subject,
name = payload.name,
expires_at = expires_at,
)
return CreateApiKeyResponse(
key = raw_key,
api_key = _row_to_api_key_response(row),
)
@router.get("/api-keys", response_model = ApiKeyListResponse)
async def list_api_keys(
current_subject: str = Depends(get_current_subject),
) -> ApiKeyListResponse:
"""List all API keys for the authenticated user (raw keys are never exposed)."""
rows = storage.list_api_keys(current_subject)
return ApiKeyListResponse(
api_keys = [_row_to_api_key_response(r) for r in rows],
)
@router.delete("/api-keys/{key_id}")
async def revoke_api_key(
key_id: int,
current_subject: str = Depends(get_current_subject),
) -> dict:
"""Revoke (soft-delete) an API key."""
if not storage.revoke_api_key(current_subject, key_id):
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "API key not found",
)
return {"detail": "API key revoked"}