mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-26 19:04:18 +02:00
* fix(auth): derive the session cookie Secure flag from the request scheme SECURE_COOKIES only marked the login cookie Secure when it was explicitly set to true, so an HTTPS login on an install that never set it handed out a session cookie the browser is happy to send back in cleartext. Unset now derives the flag from the request: the connection scheme, which uvicorn's proxy-headers middleware rewrites for the proxies it trusts, or X-Forwarded-Proto for a terminator that is not on a trusted address. That is the same test core/middleware.py already applies before sending HSTS, so the two stop disagreeing about whether a request arrived over TLS. An explicit true still forces the flag on and an explicit false turns it off for an install still answering on both HTTP and HTTPS. Strictly more Secure flags than before and never fewer. Empty counts as unset, because docker-compose pinned SECURE_COOKIES=false for every container; the compose files now pass the variable through unset, the way FASTEMBED_CACHE_PATH already does. The helper and its decision order come from #3799, which was closed for being too large to review and whose six replacement PRs dropped this fix. Part of #3803. * docs(setup): flag the leftover SECURE_COOKIES=false on upgrades The old default was false, so an install set up before scheme derivation can still carry an explicit SECURE_COOKIES=false in its own .env. That value stays authoritative, so HTTPS logins keep getting a non-Secure session cookie even after the tracked compose defaults are updated by a pull. Say so where people look: the security notes and the variable's own comment in .env.example. * docs(setup): align TLS guidance with scheme-derived cookies --------- Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
118 lines
4.7 KiB
Python
118 lines
4.7 KiB
Python
"""Pin that the login handler keeps bcrypt off the event loop.
|
|
|
|
`/api/auth/login` is an `async def` and is reachable unauthenticated. bcrypt
|
|
(`checkpw`/`hashpw`) is deliberately CPU-expensive (~100-300 ms). Running it
|
|
directly in the coroutine blocks the single event loop for that whole window,
|
|
freezing every other in-flight request (chat streams, polling, ...). Because
|
|
the endpoint is unauthenticated and rate-limited only per-IP, a burst of login
|
|
attempts serializes the whole server — a cheap DoS-amplification vector.
|
|
|
|
The fix offloads the bcrypt-bearing AuthManager calls via asyncio.to_thread.
|
|
This test asserts those calls run on a worker thread, not the loop thread; it
|
|
fails if they are awaited inline again.
|
|
"""
|
|
import os
|
|
import sys
|
|
import types
|
|
import asyncio
|
|
import pytest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
# Stub `core.auth` / `core.database` before importing the route module.
|
|
# `routes.auth_routes` does `from core.auth import AuthManager`, and importing
|
|
# any `core.*` submodule first runs `core/__init__.py`, which transitively
|
|
# imports `src.llm_core` (hangs at import under the project venv) and the
|
|
# SQLAlchemy declarative models (metaclass blows up on a bare `core.database`
|
|
# import / under the conftest's `sqlalchemy.*` MagicMock stubs). We only need
|
|
# `AuthManager` as a type hint here — the handler is exercised with a MagicMock
|
|
# — so stub the heavy modules out. Same trick as test_auth_regressions.py /
|
|
# test_null_owner_gates.py.
|
|
def _ensure_stub(name: str, **attrs):
|
|
"""Create or augment a stub module, wiring it onto a stubbed parent package.
|
|
|
|
Augments existing entries because an earlier-run test may have already
|
|
stubbed the same module with a different attribute set. The parent package
|
|
gets `__path__` pointed at the real on-disk dir so genuinely-unstubbed
|
|
submodules still load normally, while `core/__init__.py` itself is bypassed
|
|
(the package is already in `sys.modules`)."""
|
|
if "." in name:
|
|
parent_name, _, child_name = name.rpartition(".")
|
|
if parent_name not in sys.modules:
|
|
parent = types.ModuleType(parent_name)
|
|
real_path = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
*parent_name.split("."),
|
|
)
|
|
parent.__path__ = [real_path] if os.path.isdir(real_path) else []
|
|
sys.modules[parent_name] = parent
|
|
else:
|
|
parent = sys.modules[parent_name]
|
|
else:
|
|
parent = None
|
|
child_name = None
|
|
|
|
mod = sys.modules.get(name)
|
|
if mod is None:
|
|
mod = types.ModuleType(name)
|
|
sys.modules[name] = mod
|
|
for k, v in attrs.items():
|
|
if not hasattr(mod, k):
|
|
setattr(mod, k, v)
|
|
if parent is not None and not hasattr(parent, child_name):
|
|
setattr(parent, child_name, mod)
|
|
return mod
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _event_loop_stubs(monkeypatch):
|
|
db = _ensure_stub("core.database", SessionLocal=MagicMock())
|
|
auth = _ensure_stub("core.auth", AuthManager=MagicMock())
|
|
monkeypatch.setitem(sys.modules, "core.database", db)
|
|
monkeypatch.setitem(sys.modules, "core.auth", auth)
|
|
|
|
|
|
from routes.auth_routes import setup_auth_routes, LoginRequest
|
|
|
|
|
|
def _login_endpoint(auth_manager):
|
|
router = setup_auth_routes(auth_manager)
|
|
for r in router.routes:
|
|
if getattr(r, "path", None) == "/api/auth/login" and "POST" in getattr(r, "methods", set()):
|
|
return r.endpoint
|
|
raise AssertionError("login route not found on the auth router")
|
|
|
|
|
|
def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
|
calls = []
|
|
auth = MagicMock()
|
|
|
|
async def fake_to_thread(fn, *args, **kwargs):
|
|
calls.append(fn)
|
|
return fn(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
|
|
auth.verify_password.return_value = True
|
|
auth.totp_enabled.return_value = False
|
|
auth.create_session_trusted.return_value = "tok-123"
|
|
|
|
login = _login_endpoint(auth)
|
|
|
|
request = SimpleNamespace(
|
|
client=SimpleNamespace(host="203.0.113.7"),
|
|
cookies={},
|
|
url=SimpleNamespace(scheme="http"),
|
|
headers={},
|
|
)
|
|
response = MagicMock()
|
|
body = LoginRequest(username="alice", password="hunter2", remember=True)
|
|
|
|
result = asyncio.run(login(body=body, request=request, response=response))
|
|
|
|
assert result["ok"] is True
|
|
auth.verify_password.assert_called_once()
|
|
auth.create_session_trusted.assert_called_once()
|
|
# The whole point: the expensive bcrypt-bearing calls go through
|
|
# asyncio.to_thread rather than running inline in the request coroutine.
|
|
assert calls == [auth.verify_password, auth.create_session_trusted]
|