* 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>
310 lines
11 KiB
Python
310 lines
11 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Tests for MaxBodyMiddleware, SecurityHeadersMiddleware, and the /api/health auth gate."""
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import Response
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_BACKEND_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
|
|
|
|
|
@pytest.fixture(scope = "module")
|
|
def main_module():
|
|
import main as _main # noqa: F401
|
|
|
|
return _main
|
|
|
|
|
|
# =====================================================================
|
|
# MaxBodyMiddleware
|
|
# =====================================================================
|
|
|
|
|
|
def _make_protected_app(max_bytes: int, main_module):
|
|
app = FastAPI()
|
|
app.add_middleware(
|
|
main_module.MaxBodyMiddleware,
|
|
max_bytes = max_bytes,
|
|
protected_prefixes = ("/v1/chat/completions", "/api/train"),
|
|
)
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def chat(payload: dict):
|
|
return {"ok": True, "n": len(payload.get("text", ""))}
|
|
|
|
@app.post("/api/other")
|
|
async def other(payload: dict):
|
|
return {"ok": True, "unprotected": True}
|
|
|
|
@app.get("/api/train/status")
|
|
async def status_get():
|
|
return {"ok": True, "get": True}
|
|
|
|
return app
|
|
|
|
|
|
class TestMaxBodyMiddleware:
|
|
def test_small_protected_body_passes(self, main_module):
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
r = c.post("/v1/chat/completions", json = {"text": "x" * 100})
|
|
assert r.status_code == 200
|
|
assert r.json()["n"] == 100
|
|
|
|
def test_large_declared_content_length_rejected(self, main_module):
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
r = c.post("/v1/chat/completions", json = {"text": "x" * 5000})
|
|
assert r.status_code == 413
|
|
assert "too large" in r.json()["detail"].lower()
|
|
|
|
def test_unprotected_prefix_passes_large_body(self, main_module):
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
r = c.post("/api/other", json = {"text": "x" * 5000})
|
|
assert r.status_code == 200
|
|
assert r.json()["unprotected"] is True
|
|
|
|
def test_chunked_upload_over_cap_rejected(self, main_module):
|
|
# Regression: declared-Content-Length-only check could be bypassed
|
|
# by chunked transfer-encoding.
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
|
|
def gen():
|
|
yield b'{"text":"'
|
|
yield b"x" * 800
|
|
yield b'"}'
|
|
yield b"\n" + b"y" * 500
|
|
|
|
r = c.post(
|
|
"/v1/chat/completions",
|
|
content = gen(),
|
|
headers = {"content-type": "application/json"},
|
|
)
|
|
assert r.status_code == 413
|
|
assert "too large" in r.json()["detail"].lower()
|
|
|
|
def test_chunked_upload_under_cap_passes(self, main_module):
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
|
|
def gen():
|
|
yield b'{"text":"'
|
|
yield b"x" * 50
|
|
yield b'"}'
|
|
|
|
r = c.post(
|
|
"/v1/chat/completions",
|
|
content = gen(),
|
|
headers = {"content-type": "application/json"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["n"] == 50
|
|
|
|
def test_get_not_subject_to_cap(self, main_module):
|
|
app = _make_protected_app(1024, main_module)
|
|
c = TestClient(app)
|
|
r = c.get("/api/train/status")
|
|
assert r.status_code == 200
|
|
|
|
|
|
# =====================================================================
|
|
# SecurityHeadersMiddleware / CSP
|
|
# =====================================================================
|
|
|
|
|
|
def _make_csp_app(main_module, attach_nonce: str | None = None):
|
|
app = FastAPI()
|
|
app.add_middleware(main_module.SecurityHeadersMiddleware)
|
|
|
|
@app.get("/plain")
|
|
async def plain():
|
|
return {"ok": True}
|
|
|
|
@app.get("/with-nonce")
|
|
async def with_nonce():
|
|
headers = {}
|
|
if attach_nonce:
|
|
headers[main_module._CSP_SCRIPT_NONCE_HEADER] = attach_nonce
|
|
return Response(
|
|
content = b"<html></html>",
|
|
media_type = "text/html",
|
|
headers = headers,
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
class TestSecurityHeadersMiddleware:
|
|
def test_csp_has_no_unsafe_inline_for_script_src(self, main_module):
|
|
app = _make_csp_app(main_module)
|
|
c = TestClient(app)
|
|
r = c.get("/plain")
|
|
assert r.status_code == 200
|
|
csp = r.headers["content-security-policy"]
|
|
# Parse per-directive so style-src unsafe-inline does not false-match.
|
|
directives = {
|
|
chunk.strip().split(" ", 1)[0]: chunk.strip()
|
|
for chunk in csp.split(";")
|
|
if chunk.strip()
|
|
}
|
|
assert "script-src" in directives
|
|
assert "'unsafe-inline'" not in directives["script-src"]
|
|
# style-src keeps unsafe-inline for Vite-injected styles.
|
|
assert "'unsafe-inline'" in directives["style-src"]
|
|
|
|
def test_default_security_headers_present(self, main_module):
|
|
app = _make_csp_app(main_module)
|
|
c = TestClient(app)
|
|
r = c.get("/plain")
|
|
assert r.headers["x-frame-options"] == "DENY"
|
|
assert r.headers["x-content-type-options"] == "nosniff"
|
|
assert r.headers["referrer-policy"] == "no-referrer"
|
|
assert "camera=()" in r.headers["permissions-policy"]
|
|
assert r.headers["server"] == "unsloth-studio"
|
|
|
|
def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module):
|
|
nonce = "test-nonce-abc"
|
|
app = _make_csp_app(main_module, attach_nonce = nonce)
|
|
c = TestClient(app)
|
|
r = c.get("/with-nonce")
|
|
csp = r.headers["content-security-policy"]
|
|
assert f"'nonce-{nonce}'" in csp
|
|
# Internal handoff header must not leak to clients.
|
|
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
|
|
k.lower() for k in r.headers.keys()
|
|
}
|
|
|
|
def test_build_csp_helper_shape(self, main_module):
|
|
plain = main_module._build_csp()
|
|
assert "script-src 'self';" in plain
|
|
assert "'unsafe-inline'" not in plain.split("script-src", 1)[1].split(";", 1)[0]
|
|
nonced = main_module._build_csp("XYZ")
|
|
assert "script-src 'self' 'nonce-XYZ';" in nonced
|
|
|
|
def test_img_src_allows_google_favicons(self, main_module):
|
|
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
|
|
# this allowlist entry citation favicons fall back to gray initials.
|
|
csp = main_module._build_csp()
|
|
img_directive = next(
|
|
chunk.strip()
|
|
for chunk in csp.split(";")
|
|
if chunk.strip().startswith("img-src ")
|
|
)
|
|
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
|
|
# not read directive-string `in` membership as URL sanitisation.
|
|
img_sources = img_directive.split()
|
|
assert any(src == "https://www.google.com" for src in img_sources)
|
|
# Pre-existing favicon CDNs stay allowed.
|
|
for host in (
|
|
"https://t0.gstatic.com",
|
|
"https://t1.gstatic.com",
|
|
"https://t2.gstatic.com",
|
|
"https://t3.gstatic.com",
|
|
):
|
|
assert any(src == host for src in img_sources)
|
|
|
|
|
|
# =====================================================================
|
|
# /api/health auth gate
|
|
# =====================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def health_app(tmp_path, monkeypatch):
|
|
"""Mount /api/health on a fresh app against an isolated auth db."""
|
|
from auth import storage
|
|
|
|
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
|
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
|
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
|
|
|
import main as _main
|
|
|
|
app = FastAPI()
|
|
app.add_api_route("/api/health", _main.health_check, methods = ["GET"])
|
|
|
|
import secrets as _secrets
|
|
|
|
storage.create_initial_user(
|
|
username = storage.DEFAULT_ADMIN_USERNAME,
|
|
password = "human-password-123",
|
|
jwt_secret = _secrets.token_urlsafe(64),
|
|
must_change_password = False,
|
|
)
|
|
return app
|
|
|
|
|
|
class TestHealthAuthGate:
|
|
# Launcher / frontend bootstrap fields are available unauth so the Tauri
|
|
# watchdog can re-adopt a sibling backend and the SPA can detect chat-only
|
|
# mode before any token exists. Version / device_type still require a bearer.
|
|
LAUNCHER_BITS = (
|
|
"service",
|
|
"studio_root_id",
|
|
"chat_only",
|
|
"desktop_protocol_version",
|
|
"desktop_manageability_version",
|
|
"supports_desktop_auth",
|
|
"supports_desktop_backend_ownership",
|
|
"native_path_leases_supported",
|
|
)
|
|
FINGERPRINT_FIELDS = ("version", "studio_version", "device_type")
|
|
|
|
def test_no_auth_exposes_launcher_bits(self, health_app):
|
|
c = TestClient(health_app)
|
|
r = c.get("/api/health")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "healthy"
|
|
assert "timestamp" in body
|
|
for field in self.LAUNCHER_BITS:
|
|
assert field in body, f"missing launcher bit: {field}"
|
|
assert body["service"] == "Unsloth UI Backend"
|
|
for forbidden in self.FINGERPRINT_FIELDS:
|
|
assert forbidden not in body
|
|
|
|
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
|
|
# Regression: calling the async dep without await made any Bearer header pass.
|
|
c = TestClient(health_app)
|
|
r = c.get(
|
|
"/api/health",
|
|
headers = {"Authorization": "Bearer not-a-real-token"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "healthy"
|
|
for field in self.LAUNCHER_BITS:
|
|
assert field in body
|
|
for forbidden in self.FINGERPRINT_FIELDS:
|
|
assert forbidden not in body
|
|
|
|
def test_valid_bearer_returns_full_payload(self, health_app):
|
|
from auth import storage
|
|
from auth.authentication import create_access_token
|
|
|
|
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
|
|
c = TestClient(health_app)
|
|
r = c.get(
|
|
"/api/health",
|
|
headers = {"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "healthy"
|
|
for field in self.LAUNCHER_BITS + self.FINGERPRINT_FIELDS:
|
|
assert field in body, f"missing: {field}"
|