fix(channels): send the CSRF pair on IM /goal writes (#6537)

* fix(channels): send the CSRF pair on IM /goal writes

`/goal <objective>` and `/goal clear` from an IM channel sent their
PUT/DELETE to the Gateway with only the internal auth headers. The
Gateway's CSRF middleware does not exempt internal auth, so with auth
enabled (the default) both writes were rejected with 403 and the channel
replied "Failed to set goal." / "Failed to clear goal.".

`_goal_request` now sends the same double-submit cookie/header pair as
the channel's LangGraph SDK client; both build it from one helper,
`ChannelManager._csrf_headers()`.

* docs(changelog): reference #6537

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-09 18:58:09 +09:00 committed by GitHub
parent 42ad18f9b9
commit b842e06be1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 78 additions and 7 deletions

View File

@ -804,6 +804,12 @@ This release closes that milestone with **439 merged pull requests**.
### Fixed
- **channels:** `/goal <objective>` 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

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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