From e7242a89f7f1faacbe984713a5ad211bd7bcac2b Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 12:28:52 +0000 Subject: [PATCH] fix(auth): bound asynchronous login handling --- dashboard/auth_server.py | 17 +++-- tests/test_dashboard_auth_server.py | 107 +++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py index 6dc7186..825ab93 100644 --- a/dashboard/auth_server.py +++ b/dashboard/auth_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import secrets import time from urllib.parse import parse_qs, urlsplit @@ -91,13 +92,15 @@ async def login(request: Request) -> Response: if content_type != "application/x-www-form-urlencoded": authentication.log_auth_event("login_request_rejected", correlation_id) return PlainTextResponse("Bad Request", status_code=400) - body = await request.body() - if len(body) > _MAX_LOGIN_BODY_BYTES: - authentication.log_auth_event("login_request_rejected", correlation_id) - return PlainTextResponse("Bad Request", status_code=400) + body = bytearray() + async for chunk in request.stream(): + if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES: + authentication.log_auth_event("login_request_rejected", correlation_id) + return PlainTextResponse("Bad Request", status_code=400) + body.extend(chunk) try: values = parse_qs( - body.decode("utf-8"), + bytes(body).decode("utf-8"), keep_blank_values=True, strict_parsing=True, max_num_fields=3, @@ -121,7 +124,9 @@ async def login(request: Request) -> Response: if not authentication.verify_credentials( settings.credentials, submitted_id, submitted_password ): - authentication.enforce_failed_login_delay(started_at=started_at) + await asyncio.to_thread( + authentication.enforce_failed_login_delay, started_at=started_at + ) authentication.log_auth_event("login_failed", correlation_id) return _redirect("/?auth_error=invalid") diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py index f90de4e..c68ebec 100644 --- a/tests/test_dashboard_auth_server.py +++ b/tests/test_dashboard_auth_server.py @@ -1,18 +1,22 @@ from __future__ import annotations +import asyncio import sys +import threading +from collections.abc import Callable from http.cookies import SimpleCookie from pathlib import Path import pytest from starlette.testclient import TestClient +from starlette.requests import Request ROOT_DIR = Path(__file__).resolve().parents[1] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) from dashboard import authentication -from dashboard.auth_server import create_auth_routes +from dashboard.auth_server import create_auth_routes, login ADMIN_ID = "admin" @@ -218,3 +222,104 @@ def test_cookie_token_round_trip(client: TestClient) -> None: assert authentication.verify_persistent_token( authentication.load_auth_settings(), token ) is not None + + +def _streaming_login_request( + chunks: list[bytes], +) -> tuple[Request, Callable[[], int]]: + messages = [ + { + "type": "http.request", + "body": chunk, + "more_body": index < len(chunks) - 1, + } + for index, chunk in enumerate(chunks) + ] + calls = 0 + + async def receive(): + nonlocal calls + calls += 1 + return messages.pop(0) + + scope = { + "type": "http", + "method": "POST", + "scheme": "https", + "path": "/api/auth/login", + "raw_path": b"/api/auth/login", + "query_string": b"", + "headers": [ + (b"host", b"testserver"), + (b"origin", b"https://testserver"), + (b"content-type", b"application/x-www-form-urlencoded"), + ], + "client": ("testclient", 50000), + "server": ("testserver", 443), + } + return Request(scope, receive), lambda: calls + + +def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None: + entered = threading.Event() + release = threading.Event() + probe_ran = threading.Event() + probe_before_release = threading.Event() + + def blocking_delay(*, started_at): + entered.set() + if probe_ran.wait(timeout=1): + probe_before_release.set() + release.set() + + monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay) + + async def exercise() -> None: + request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"]) + task = asyncio.create_task(login(request)) + + async def probe() -> None: + probe_ran.set() + + asyncio.create_task(probe()) + assert await asyncio.to_thread(entered.wait, 1) + release.set() + response = await task + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=invalid" + assert "set-cookie" not in response.headers + + asyncio.run(exercise()) + assert probe_before_release.is_set() + + +def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None: + def unexpected_verification(*_args, **_kwargs): + raise AssertionError("oversized body reached credential verification") + + monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification) + request, calls = _streaming_login_request([b"x" * 4097]) + response = asyncio.run(login(request)) + + assert response.status_code == 400 + assert "set-cookie" not in response.headers + assert calls() == 1 + + +def test_login_accepts_body_at_streaming_limit() -> None: + prefix = b"admin_id=admin&admin_password=" + body = prefix + (b"x" * (4096 - len(prefix))) + assert len(body) == 4096 + request, calls = _streaming_login_request([body]) + response = asyncio.run(login(request)) + + assert response.status_code == 303 + assert calls() == 1 + + +def test_login_stops_reading_stream_after_limit_is_exceeded() -> None: + request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"]) + response = asyncio.run(login(request)) + + assert response.status_code == 400 + assert calls() == 2