diff --git a/CHANGELOG.md b/CHANGELOG.md index c851ec1af0..d31f843928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -804,6 +804,12 @@ This release closes that milestone with **439 merged pull requests**. ### Fixed +- **channels:** `/goal ` and `/goal clear` from an IM channel work + again when Gateway auth is enabled (the default). Both sent their write with + the internal auth token alone, and the Gateway's CSRF check, which does not + exempt internal auth, answered 403, so the channel replied "Failed to set + goal." or "Failed to clear goal." They now send the same CSRF cookie and + header pair as the channel's SDK client. `/goal` status was unaffected. ([#6537]) - **auth:** In the Docker stack, five wrong passwords from one client no longer lock every user out of login for five minutes. Failed logins are counted per client IP, and the Gateway honors `X-Real-IP` only from a peer listed in @@ -9311,3 +9317,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6506]: https://github.com/bytedance/deer-flow/pull/6506 [#6512]: https://github.com/bytedance/deer-flow/pull/6512 [#6519]: https://github.com/bytedance/deer-flow/pull/6519 +[#6537]: https://github.com/bytedance/deer-flow/pull/6537 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 7552d70b61..075e23bd35 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -694,6 +694,10 @@ ### 修复 +- **渠道:** 在启用 Gateway 认证(默认配置)时,从 IM 渠道发送的 `/goal <目标>` 和 + `/goal clear` 恢复正常。此前这两个写请求只携带内部认证令牌,而 Gateway 的 CSRF 检查不会 + 豁免内部认证,因此返回 403,渠道回复“Failed to set goal.”或“Failed to clear goal.”。 + 现在它们会发送与渠道 SDK 客户端相同的 CSRF Cookie 和请求头。`/goal` 状态查询不受影响。([#6537]) - **认证:** 在 Docker 部署中,某个客户端输错 5 次密码不会再让所有用户 5 分钟内无法登录。登录失败按客户端 IP 计数,而 Gateway 只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,compose 文件却从未设置该变量;所有浏览器请求都经由 `nginx` 容器到达 Gateway,因此所有登录共用 nginx 的地址和同一个锁定。现在 `AUTH_TRUSTED_PROXIES` 也接受主机名,在事件循环外解析并 @@ -7551,3 +7555,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6506]: https://github.com/bytedance/deer-flow/pull/6506 [#6512]: https://github.com/bytedance/deer-flow/pull/6512 [#6519]: https://github.com/bytedance/deer-flow/pull/6519 +[#6537]: https://github.com/bytedance/deer-flow/pull/6537 diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index 51406add1b..4ee0f40cba 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -5,7 +5,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk WeChat `_read_outbound_bytes` reads cap+1 off-loop; overflow returns `None` before encryption/upload; caps <=0 are unlimited. Cursor writes drain before cancellation; token expiry drains cursor/auth clearing under `_auth_lock`. -**Architecture**: Channels use `langgraph-sdk` HTTP to reach Gateway; Gateway owns thread lifecycle. The internal client adds process-local auth and matching CSRF cookie/header for state-changing requests, without browser cookies. +**Architecture**: Channels use `langgraph-sdk` HTTP to reach Gateway; Gateway owns thread lifecycle. The SDK client and raw-httpx `/goal` calls send process-local auth plus `_csrf_headers()`; internal auth is not CSRF-exempt. **Components**: - `message_bus.py` - Inbound queue → dispatcher; outbound uses a dispatch-start listener snapshot. Unsubscribe neither cancels nor drains captured callbacks. diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 3fcb09067a..787c227b77 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -1818,6 +1818,17 @@ class ChannelManager: # -- LangGraph SDK client (lazy) ---------------------------------------- + def _csrf_headers(self) -> dict[str, str]: + """Return the double-submit CSRF pair for state-changing Gateway calls. + + Internal auth does not exempt a request from ``CSRFMiddleware``, so every + Gateway mutation the manager sends must carry this pair. + """ + return { + CSRF_HEADER_NAME: self._csrf_token, + "Cookie": f"{CSRF_COOKIE_NAME}={self._csrf_token}", + } + def _get_client(self): """Return the ``langgraph_sdk`` async client, creating it on first use.""" if self._client is None: @@ -1825,11 +1836,7 @@ class ChannelManager: self._client = get_client( url=self._langgraph_url, - headers={ - **create_internal_auth_headers(), - CSRF_HEADER_NAME: self._csrf_token, - "Cookie": f"{CSRF_COOKIE_NAME}={self._csrf_token}", - }, + headers={**create_internal_auth_headers(), **self._csrf_headers()}, ) return self._client @@ -2910,7 +2917,7 @@ class ChannelManager: ) -> dict[str, Any]: async with httpx.AsyncClient() as http: request = getattr(http, method.lower()) - kwargs: dict[str, Any] = {"timeout": 10, "headers": headers} + kwargs: dict[str, Any] = {"timeout": 10, "headers": {**headers, **self._csrf_headers()}} if json is not None: kwargs["json"] = json response = await request(f"{self._gateway_url}/api/threads/{quote(thread_id, safe='')}/goal", **kwargs) diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 103370131b..32ccb227f4 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -11777,6 +11777,58 @@ class TestHandleGoalCommand: _run(go()) + @pytest.mark.parametrize( + ("args", "method", "expected_reply"), + [("finish the work", "PUT", None), ("clear", "DELETE", "Goal cleared.")], + ) + def test_goal_mutations_pass_gateway_auth_and_csrf_middleware(self, monkeypatch, args, method, expected_reply): + """Raw-httpx goal writes must carry the double-submit pair the SDK client sends.""" + from dataclasses import replace + + import httpx + from fastapi import FastAPI + + from app.gateway.auth_middleware import AuthMiddleware + from app.gateway.csrf_middleware import CSRFMiddleware + from deerflow.runtime.user_context import get_effective_user_id + + # Same order as app.gateway.app: AuthMiddleware inside CSRFMiddleware. + gateway = FastAPI() + gateway.add_middleware(AuthMiddleware) + gateway.add_middleware(CSRFMiddleware) + received = [] + + @gateway.put("/api/threads/{thread_id}/goal") + async def put_goal(thread_id: str): + received.append(("PUT", get_effective_user_id())) + return {"goal": None} + + @gateway.delete("/api/threads/{thread_id}/goal") + async def delete_goal(thread_id: str): + received.append(("DELETE", get_effective_user_id())) + return {"goal": None} + + real_async_client = httpx.AsyncClient + monkeypatch.setattr( + "app.channels.manager.httpx.AsyncClient", + lambda *a, **kw: real_async_client(*a, transport=httpx.ASGITransport(app=gateway), **kw), + ) + + async def go(): + manager = self._make_manager(monkeypatch, thread_id="t-1") + + async def _handle_chat(msg, **kwargs): + return None + + monkeypatch.setattr(manager, "_handle_chat", _handle_chat) + + msg = replace(self._msg(f"/goal {args}"), owner_user_id="owner-1") + reply = await manager._handle_goal_command(msg, args) + assert reply == expected_reply + assert received == [(method, "owner-1")] + + _run(go()) + # --------------------------------------------------------------------------- # _merge_stream_text regression: CJK reduplication, repeated tokens, suffix