diff --git a/CHANGELOG.md b/CHANGELOG.md index 7150e0c628..04832b6000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1397,6 +1397,14 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Fixed +- **auth:** The public auth routes (`/register`, `/setup-status`, + `/providers`, `/oauth/{provider}` and `/callback/{provider}`) no longer read + `config.yaml` and `extensions_config.json` on the event loop. Each request + checked both files (a `stat` and a full sha256 of each), and reparsed + `config.yaml` there after an edit, stalling other requests and streams + meanwhile. The read now runs in a worker, as `/login/local` already did. + ([#7576]) + - **sandbox:** Stopping the Gateway no longer destroys AIO or E2B sandboxes before in-flight runs drain. When the first sandbox use built the provider on the event-loop thread, the provider's own SIGTERM/SIGINT handler destroyed @@ -13136,3 +13144,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7530]: https://github.com/bytedance/deer-flow/pull/7530 [#7557]: https://github.com/bytedance/deer-flow/pull/7557 [#7560]: https://github.com/bytedance/deer-flow/pull/7560 +[#7576]: https://github.com/bytedance/deer-flow/pull/7576 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 193128ba4a..ec9e0ef8e6 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1112,6 +1112,12 @@ ### 修复 +- **鉴权:** 公开的认证路由(`/register`、`/setup-status`、`/providers`、 + `/oauth/{provider}` 和 `/callback/{provider}`)不再在事件循环上读取 `config.yaml` 和 + `extensions_config.json`。每个请求都会检查这两个文件(各做一次 `stat` 和完整的 + sha256),编辑后还会在循环上重新解析 `config.yaml`,期间其他请求和流式输出都会 + 停顿。现在这次读取与 `/login/local` 一样在工作线程中执行。([#7576]) + - **沙箱:** 停止 Gateway 时,AIO 和 E2B 沙箱不再在进行中的运行收尾之前被销毁。此前 如果第一次使用沙箱时在事件循环线程上构造了提供方,提供方自己的 SIGTERM/SIGINT 处理器会在信号到达时同步销毁所有沙箱:整个销毁期间事件循环冻结,正在执行的工具 @@ -10485,3 +10491,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7530]: https://github.com/bytedance/deer-flow/pull/7530 [#7557]: https://github.com/bytedance/deer-flow/pull/7557 [#7560]: https://github.com/bytedance/deer-flow/pull/7560 +[#7576]: https://github.com/bytedance/deer-flow/pull/7576 diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 6a9501b99c..e793055e30 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -88,3 +88,8 @@ State updates synchronize titles into thread metadata only for the root checkpoi namespace. Nonroot writes preserve the conversation title, branch-title sequence and activity timestamp. `tests/test_threads_router.py` checks metadata/search isolation and root title synchronization in full and delta modes. + +Unauthenticated auth routes (`/register`, `/setup-status`, `/providers`, +`/oauth/{provider}`, `/callback/{provider}`, the `/login/local` throttle) read +`get_app_config()` through `asyncio.to_thread`: each read stats and hashes the +config files. Coverage: `tests/blocking_io/test_auth_router_config_reads.py`. diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index 7765ce3df2..2e099ab5fd 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -39,7 +39,7 @@ from app.gateway.auth.user_provisioning import get_or_provision_oidc_user from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_csrf_cookie_settings, generate_csrf_token, is_secure_request from app.gateway.deps import get_current_user_from_request, get_local_provider from app.gateway.utils import constant_time_equals -from deerflow.config.auth_config import OIDCProviderConfig +from deerflow.config.auth_config import OIDCAuthConfig, OIDCProviderConfig from deerflow.persistence.login_throttle import LoginThrottleStore logger = logging.getLogger(__name__) @@ -474,6 +474,8 @@ def _local_registration_enabled() -> bool: ``/register`` reads this fresh on every request (``get_app_config`` reloads on file change); ``/setup-status`` may serve it up to 60s stale via its per-IP result cache. + Both are unauthenticated and ``get_app_config`` stats and re-hashes the config + files on every call, so they run this through ``asyncio.to_thread``. """ from deerflow.config.app_config import get_app_config @@ -492,7 +494,7 @@ async def register(request: Request, response: Response, body: RegisterRequest): Returns 403 when ``auth.local.allow_registration`` is false. """ - if not _local_registration_enabled(): + if not await asyncio.to_thread(_local_registration_enabled): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=AuthErrorResponse(code=AuthErrorCode.REGISTRATION_DISABLED, message="Self-registration is disabled on this deployment").model_dump(), @@ -784,7 +786,8 @@ async def setup_status(request: Request): async def _compute_setup_status() -> dict: admin_count = await get_local_provider().count_admin_users() - return {"needs_setup": admin_count == 0, "registration_enabled": _local_registration_enabled()} + registration_enabled = await asyncio.to_thread(_local_registration_enabled) + return {"needs_setup": admin_count == 0, "registration_enabled": registration_enabled} task = asyncio.create_task(_compute_setup_status()) _SETUP_STATUS_INFLIGHT[client_ip] = task @@ -890,6 +893,21 @@ def _set_csrf_cookie(response: Response, request: Request) -> None: ) +def _live_oidc_config() -> OIDCAuthConfig: + from deerflow.config.app_config import get_app_config + + return get_app_config().auth.oidc + + +async def _oidc_config() -> OIDCAuthConfig: + """The live ``auth.oidc`` config, read off the event loop. + + The SSO routes are unauthenticated, and ``get_app_config`` stats and + re-hashes the config files on every call. + """ + return await asyncio.to_thread(_live_oidc_config) + + def _resolve_oidc_redirect_uri(request: Request, provider_id: str, provider_config: OIDCProviderConfig) -> str: """Resolve the redirect URI for an OIDC provider. @@ -916,10 +934,7 @@ async def list_auth_providers(): Returns only safe frontend metadata — no secrets, endpoints, or internal configuration. """ - from deerflow.config.app_config import get_app_config - - app_config = get_app_config() - oidc_config = app_config.auth.oidc + oidc_config = await _oidc_config() if not oidc_config.enabled: return {"providers": []} @@ -949,10 +964,7 @@ async def oauth_login( and PKCE parameters. The ``next`` query parameter specifies where to redirect after successful login (default: /workspace). """ - from deerflow.config.app_config import get_app_config - - app_config = get_app_config() - oidc_config = app_config.auth.oidc + oidc_config = await _oidc_config() if not oidc_config.enabled: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSO authentication is not enabled") @@ -1031,10 +1043,7 @@ async def oauth_callback( the ID token, provisions/links the DeerFlow user, and sets the session cookie. """ - from deerflow.config.app_config import get_app_config - - app_config = get_app_config() - oidc_config = app_config.auth.oidc + oidc_config = await _oidc_config() # ── Provider error ─────────────────────────────────────────────── if error: diff --git a/backend/tests/blocking_io/test_auth_router_config_reads.py b/backend/tests/blocking_io/test_auth_router_config_reads.py new file mode 100644 index 0000000000..41d6299b71 --- /dev/null +++ b/backend/tests/blocking_io/test_auth_router_config_reads.py @@ -0,0 +1,134 @@ +"""Regression anchors: the public auth routes must read the live config off the loop. + +``get_app_config()`` stats and re-hashes ``config.yaml`` and +``extensions_config.json`` on every call, and reparses them after an edit. +``/register``, ``/setup-status``, ``/providers``, ``/oauth/{provider}`` and +``/callback/{provider}`` are unauthenticated and each read the live ``auth`` +section, so the read runs in a worker, as ``/login/local``'s throttle policy +does (``test_login_local_throttle.py``). + +Each request runs against real config files and asserts an answer only the +file on disk produces (registration closed, the configured SSO provider), so an +anchor cannot pass because the route stopped reading the config. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from pathlib import Path +from unittest.mock import AsyncMock + +import httpx +import pytest +import pytest_asyncio +from blockbuster import BlockingError +from fastapi import FastAPI + +from app.gateway.routers import auth as auth_router +from deerflow.config.app_config import get_app_config, reset_app_config +from deerflow.config.extensions_config import reset_extensions_config + +pytestmark = pytest.mark.asyncio + +_CONFIG_YAML = """sandbox: + use: deerflow.sandbox.local:LocalSandboxProvider +auth: + local: + allow_registration: false + oidc: + enabled: true + frontend_base_url: https://deerflow.example.com + providers: + corp: + display_name: Corp SSO + issuer: https://sso.example.com + client_id: deerflow +""" + + +def _write_configs(directory: Path) -> tuple[Path, Path]: + config_path = directory / "config.yaml" + extensions_path = directory / "extensions_config.json" + config_path.write_text(_CONFIG_YAML, encoding="utf-8") + extensions_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8") + return config_path, extensions_path + + +@pytest.fixture +def local_provider(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: + provider = AsyncMock() + provider.count_admin_users.return_value = 0 + monkeypatch.setattr(auth_router, "get_local_provider", lambda: provider) + return provider + + +@pytest_asyncio.fixture +async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, local_provider: AsyncMock) -> AsyncIterator[httpx.AsyncClient]: + config_path, extensions_path = await asyncio.to_thread(_write_configs, tmp_path) + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False) + monkeypatch.setattr(auth_router, "_SETUP_STATUS_CACHE", {}) + monkeypatch.setattr(auth_router, "_SETUP_STATUS_INFLIGHT", {}) + reset_app_config() + reset_extensions_config() + # Load once off the loop: each request then only pays the per-call + # revalidation, which is the cost every unauthenticated request carries. + await asyncio.to_thread(get_app_config) + + app = FastAPI() + app.include_router(auth_router.router) + transport = httpx.ASGITransport(app=app) + try: + async with httpx.AsyncClient(transport=transport, base_url="http://gateway.test") as http_client: + yield http_client + finally: + reset_app_config() + reset_extensions_config() + + +async def test_register_reads_registration_gate_off_the_loop(client: httpx.AsyncClient, local_provider: AsyncMock) -> None: + response = await client.post("/api/v1/auth/register", json={"email": "new@example.com", "password": "Str0ng!Passw0rd#2026"}) + + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "registration_disabled" + local_provider.create_user.assert_not_called() + + +async def test_setup_status_reads_registration_gate_off_the_loop(client: httpx.AsyncClient) -> None: + response = await client.get("/api/v1/auth/setup-status") + + assert response.status_code == 200 + assert response.json() == {"needs_setup": True, "registration_enabled": False} + + +async def test_providers_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None: + response = await client.get("/api/v1/auth/providers") + + assert response.status_code == 200 + assert response.json() == {"providers": [{"id": "corp", "display_name": "Corp SSO", "type": "oidc"}]} + + +async def test_oauth_login_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None: + # An unconfigured provider is rejected right after the config read, before + # any discovery request to the IdP. + response = await client.get("/api/v1/auth/oauth/other") + + assert response.status_code == 400 + assert response.json()["detail"] == "Unknown SSO provider: other" + + +async def test_oauth_callback_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None: + response = await client.get("/api/v1/auth/callback/corp", params={"error": "access_denied"}) + + assert response.status_code == 302 + assert response.headers["location"].startswith("https://deerflow.example.com/") + + +@pytest.mark.parametrize("read", [auth_router._local_registration_enabled, get_app_config], ids=["registration", "app-config"]) +async def test_blocking_probe_inline_config_read_actually_trips_the_gate(client: httpx.AsyncClient, read) -> None: + """Meta-check: the same reads inline on the loop are caught by the gate.""" + with pytest.raises(BlockingError): + read()