From dec98b063259a940749182d4a87b83bac8333a6a Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 13 Jul 2026 09:59:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20SuperAgent=20Open=20API=20?= =?UTF-8?q?CSRF=20=E8=AF=B7=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/go-live-notes.md | 2 ++ ...gent-agentbus-project-integration-guide.md | 8 ++++--- .../M004-debug-eml-superagent-upload-v1.md | 2 +- ...07-agentbus-superagent-auto-dispatch-v1.md | 1 + .../impl/SuperAgentOpenApiClientImpl.java | 23 +++++++++++++++++-- .../impl/SuperAgentOpenApiClientImplTest.java | 12 ++++++++++ 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index cab88a0..7c9ffdd 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -179,6 +179,7 @@ | `SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT` | 否 | SuperAgent Open API 建连超时,默认 `15s`。 | | `SUPERAGENT_DEBUG_EML_READ_TIMEOUT` | 否 | 旧版 RestClient 读取超时兼容变量;当前 JDK SSE 客户端不设置整段 SSE 固定读取超时,断流恢复由 run/events 机制处理。 | | `SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS` | 否 | SSE EOF 后通过 `/runs/{run_id}/events` 恢复的最大尝试次数,默认 `5`。 | +| SuperAgent Open API CSRF | 否 | 不需要额外环境变量;后端每次请求自动生成临时 `X-CSRF-Token`,并用同值 `csrf_token` Cookie 做 double-submit。 | | `ALIYUN_OSS_DEV_ENDPOINT` / `ALIYUN_OSS_TEST_ENDPOINT` / `ALIYUN_OSS_PROD_ENDPOINT` | 否 | 阿里云 OSS Endpoint,未配置时可兜底 `ALIYUN_OSS_ENDPOINT`。 | | `ALIYUN_OSS_DEV_BUCKET` / `ALIYUN_OSS_TEST_BUCKET` / `ALIYUN_OSS_PROD_BUCKET` | 否 | 阿里云 OSS Bucket,未配置时可兜底 `ALIYUN_OSS_BUCKET`。 | | `ALIYUN_OSS_DEV_ACCESS_KEY_ID` / `ALIYUN_OSS_TEST_ACCESS_KEY_ID` / `ALIYUN_OSS_PROD_ACCESS_KEY_ID` | 是 | 阿里云 OSS AccessKey ID,未配置时可兜底 `ALIYUN_OSS_ACCESS_KEY_ID`。 | @@ -197,6 +198,7 @@ - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 - AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 - Debug EML 和 AgentBus 自动分发复用同一个 SuperAgent Open API SSE 稳定客户端;上线前必须验证 `run.completed + end + final answer` 严格成功条件和 EOF 后 `/events` 恢复。 +- 当前共享 Open API client 会自动携带临时 CSRF double-submit header / cookie;如果测试机仍返回 `CSRF token missing`,优先检查部署包版本和反向代理是否转发 `X-CSRF-Token`、`Cookie`。 ## 4. 数据库上线注意事项 diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index c0788db..d7387e6 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -143,14 +143,16 @@ POST /api/open/agent-sessions 2026-07-12 Open API 文档中,Java SSE 调用使用 `Authorization: Bearer ` 或 `X-DeerFlow-Open-API-Key` 鉴权,并通过 `X-Request-ID`、`idempotency_key` 和 metadata 做调用关联。 -当前 TH Hotel 后端 `SuperAgentOpenApiClientImpl` 不发送 CSRF double-submit。 +当前 TH Hotel 后端 `SuperAgentOpenApiClientImpl` 已发送 CSRF double-submit。CSRF token 由后端每次请求临时生成,不走环境变量,不作为长期 Secret 保存。 ```text Authorization: Bearer X-Request-ID: +X-CSRF-Token: +Cookie: csrf_token= ``` -如果 SuperAgent 服务端后续重新要求 CSRF double-submit,应先更新本文和 Open API client,再开启 M007 worker。 +Debug EML 和 M007 AgentBus 自动分发复用同一个 Open API client,因此两条链路都会携带上述 CSRF header / cookie。 ### 4.3 Session 请求示例 @@ -680,7 +682,7 @@ SuperAgent 返回的是 Provider 输出。即使未来返回结构化 JSON,也 接入 SuperAgent 前确认: - [ ] 已获得 Open API Key 和允许访问的 Base URL。 -- [ ] 已确认当前 Open API 鉴权方式;如需 CSRF double-submit,已同步更新后端 client。 +- [ ] 已确认当前 Open API 鉴权方式;当前后端 client 会自动发送临时 CSRF double-submit header / cookie。 - [ ] 已确认 Session、Message、Run 的生命周期。 - [ ] 已确认 SSE 最终答案或结构化结果所在字段。 - [ ] 已定义 `AgentCapabilityPort` 和调用审计表。 diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md index 774edb8..492e2f8 100644 --- a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -268,7 +268,7 @@ POST /api/open/agent-sessions/{sessionId}/messages/stream 第一版建议: - 后端使用 `DEERFLOW_BASE_URL` 和 `DEERFLOW_OPEN_API_KEY` 调用 SuperAgent。 -- 状态变更请求使用 CSRF double-submit:`X-CSRF-Token` 和 `Cookie: csrf_token=`。 +- 状态变更请求使用 CSRF double-submit:`X-CSRF-Token` 和 `Cookie: csrf_token=`;当前共享 Open API client 已自动生成临时随机 token 并同时写入 header / cookie。 - 创建 session 时使用 `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` 作为 `external_subject_id`。 - `idempotency_key` 使用 `debug_run_id` 派生,保证同一次 Debug 运行不会重复创建不可追溯请求。 - 发送消息时把 AgentBus-like payload 序列化为 JSON 文本,并附加中文指令,要求 SuperAgent 输出结构化 JSON。 diff --git a/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md b/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md index 9ba8dbe..e53f850 100644 --- a/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md +++ b/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md @@ -56,6 +56,7 @@ AgentBus WebSocket 收到邮件 2026-07-12 导入的 `OPEN_AGENT_API_JAVA_SSE_CLIENT.md` 对 Java SSE 调用提出新的强约束。本项目后续 Open API client 必须满足: - 请求 `messages/stream?include_trace=true` 时生成稳定 `X-Request-ID`。 +- Open API 请求必须携带 CSRF double-submit:`X-CSRF-Token` 与 `Cookie: csrf_token=` 使用后端临时随机值,不写入配置文件或环境变量。 - 同一业务 SourceMessage 的 `idempotency_key` 在所有尝试中保持不变。 - 初始 POST 成功后保存响应头 `Content-Location`,解析并保存 SuperAgent `run_id`。 - SSE 必须按帧解析 `event:`、`data:`、`id:` 和 heartbeat comment。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java index 6d05049..a78e7d8 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java @@ -11,10 +11,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Consumer; @@ -27,6 +29,11 @@ import org.springframework.stereotype.Service; @Service public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { + private static final String CSRF_HEADER_NAME = "X-CSRF-Token"; + private static final String CSRF_COOKIE_NAME = "csrf_token"; + private static final int CSRF_TOKEN_BYTES = 32; + private static final SecureRandom CSRF_RANDOM = new SecureRandom(); + private final SuperAgentOpenApiProperties properties; private final SuperAgentOpenApiSseParser sseParser; private final ObjectMapper objectMapper; @@ -198,15 +205,27 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { * 构造 SuperAgent Open API 通用请求头,不记录或返回 API Key。 */ private HttpRequest.Builder baseRequest(URI uri, String requestId) { + String csrfToken = newCsrfToken(); HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .header("Authorization", "Bearer " + properties.getApiKey()) - .header("Cache-Control", "no-cache"); + .header("Cache-Control", "no-cache") + .header(CSRF_HEADER_NAME, csrfToken) + .header("Cookie", CSRF_COOKIE_NAME + "=" + csrfToken); if (!blank(requestId)) { builder.header("X-Request-ID", requestId); } return builder; } + /** + * 生成临时 CSRF double-submit token。该值只用于当前 HTTP 请求,不作为长期 Secret 保存或输出。 + */ + private String newCsrfToken() { + byte[] bytes = new byte[CSRF_TOKEN_BYTES]; + CSRF_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + /** * 校验 HTTP 状态码,错误摘要不包含 Secret。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java index 65a221a..14040aa 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java @@ -42,7 +42,13 @@ class SuperAgentOpenApiClientImplTest { void shouldRequestStreamingMessagesWithIncludeTraceAndEmitPublicTraceEvents() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); AtomicReference streamQuery = new AtomicReference<>(); + AtomicReference sessionCsrfToken = new AtomicReference<>(); + AtomicReference sessionCookie = new AtomicReference<>(); + AtomicReference streamCsrfToken = new AtomicReference<>(); + AtomicReference streamCookie = new AtomicReference<>(); server.createContext("/api/open/agent-sessions", exchange -> { + sessionCsrfToken.set(exchange.getRequestHeaders().getFirst("X-CSRF-Token")); + sessionCookie.set(exchange.getRequestHeaders().getFirst("Cookie")); byte[] response = "{\"session_id\":\"session-http-001\"}".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, response.length); @@ -52,6 +58,8 @@ class SuperAgentOpenApiClientImplTest { }); server.createContext("/api/open/agent-sessions/session-http-001/messages/stream", exchange -> { streamQuery.set(exchange.getRequestURI().getRawQuery()); + streamCsrfToken.set(exchange.getRequestHeaders().getFirst("X-CSRF-Token")); + streamCookie.set(exchange.getRequestHeaders().getFirst("Cookie")); byte[] response = """ id: 1 event: trace @@ -98,6 +106,10 @@ class SuperAgentOpenApiClientImplTest { Map.of("source", "unit-test")), traceEvents::add); assertThat(streamQuery.get()).isEqualTo("include_trace=true"); + assertThat(sessionCsrfToken.get()).isNotBlank(); + assertThat(sessionCookie.get()).contains("csrf_token=" + sessionCsrfToken.get()); + assertThat(streamCsrfToken.get()).isNotBlank(); + assertThat(streamCookie.get()).contains("csrf_token=" + streamCsrfToken.get()); assertThat(result.sessionId()).isEqualTo("session-http-001"); assertThat(result.runId()).isEqualTo("run-http-001"); assertThat(result.lastEventId()).isEqualTo("4");