修复 SuperAgent Open API CSRF 请求头

This commit is contained in:
andy
2026-07-13 09:59:45 +08:00
parent 20eac61cb6
commit dec98b0632
6 changed files with 42 additions and 6 deletions

View File

@@ -179,6 +179,7 @@
| `SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT` | 否 | SuperAgent Open API 建连超时,默认 `15s`。 | | `SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT` | 否 | SuperAgent Open API 建连超时,默认 `15s`。 |
| `SUPERAGENT_DEBUG_EML_READ_TIMEOUT` | 否 | 旧版 RestClient 读取超时兼容变量;当前 JDK SSE 客户端不设置整段 SSE 固定读取超时,断流恢复由 run/events 机制处理。 | | `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_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_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_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`。 | | `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 结果,不创建订单、不创建任务、不调用任务结果通知接口。 - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。
- AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 - AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现,不能把 Debug EML 链路等同于生产实时自动处理链路。
- Debug EML 和 AgentBus 自动分发复用同一个 SuperAgent Open API SSE 稳定客户端;上线前必须验证 `run.completed + end + final answer` 严格成功条件和 EOF 后 `/events` 恢复。 - 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. 数据库上线注意事项 ## 4. 数据库上线注意事项

View File

@@ -143,14 +143,16 @@ POST /api/open/agent-sessions
2026-07-12 Open API 文档中Java SSE 调用使用 `Authorization: Bearer <DEERFLOW_OPEN_API_KEY>` 2026-07-12 Open API 文档中Java SSE 调用使用 `Authorization: Bearer <DEERFLOW_OPEN_API_KEY>`
`X-DeerFlow-Open-API-Key` 鉴权,并通过 `X-Request-ID``idempotency_key` 和 metadata 做调用关联。 `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 ```text
Authorization: Bearer <DEERFLOW_OPEN_API_KEY> Authorization: Bearer <DEERFLOW_OPEN_API_KEY>
X-Request-ID: <stable-request-id> X-Request-ID: <stable-request-id>
X-CSRF-Token: <temporary-random-token>
Cookie: csrf_token=<same-temporary-random-token>
``` ```
如果 SuperAgent 服务端后续重新要求 CSRF double-submit应先更新本文和 Open API client再开启 M007 worker Debug EML 和 M007 AgentBus 自动分发复用同一个 Open API client因此两条链路都会携带上述 CSRF header / cookie
### 4.3 Session 请求示例 ### 4.3 Session 请求示例
@@ -680,7 +682,7 @@ SuperAgent 返回的是 Provider 输出。即使未来返回结构化 JSON
接入 SuperAgent 前确认: 接入 SuperAgent 前确认:
- [ ] 已获得 Open API Key 和允许访问的 Base URL。 - [ ] 已获得 Open API Key 和允许访问的 Base URL。
- [ ] 已确认当前 Open API 鉴权方式;如需 CSRF double-submit,已同步更新后端 client - [ ] 已确认当前 Open API 鉴权方式;当前后端 client 会自动发送临时 CSRF double-submit header / cookie
- [ ] 已确认 Session、Message、Run 的生命周期。 - [ ] 已确认 Session、Message、Run 的生命周期。
- [ ] 已确认 SSE 最终答案或结构化结果所在字段。 - [ ] 已确认 SSE 最终答案或结构化结果所在字段。
- [ ] 已定义 `AgentCapabilityPort` 和调用审计表。 - [ ] 已定义 `AgentCapabilityPort` 和调用审计表。

View File

@@ -268,7 +268,7 @@ POST /api/open/agent-sessions/{sessionId}/messages/stream
第一版建议: 第一版建议:
- 后端使用 `DEERFLOW_BASE_URL``DEERFLOW_OPEN_API_KEY` 调用 SuperAgent。 - 后端使用 `DEERFLOW_BASE_URL``DEERFLOW_OPEN_API_KEY` 调用 SuperAgent。
- 状态变更请求使用 CSRF double-submit`X-CSRF-Token``Cookie: csrf_token=<same-token>` - 状态变更请求使用 CSRF double-submit`X-CSRF-Token``Cookie: csrf_token=<same-token>`;当前共享 Open API client 已自动生成临时随机 token 并同时写入 header / cookie
- 创建 session 时使用 `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` 作为 `external_subject_id` - 创建 session 时使用 `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` 作为 `external_subject_id`
- `idempotency_key` 使用 `debug_run_id` 派生,保证同一次 Debug 运行不会重复创建不可追溯请求。 - `idempotency_key` 使用 `debug_run_id` 派生,保证同一次 Debug 运行不会重复创建不可追溯请求。
- 发送消息时把 AgentBus-like payload 序列化为 JSON 文本,并附加中文指令,要求 SuperAgent 输出结构化 JSON。 - 发送消息时把 AgentBus-like payload 序列化为 JSON 文本,并附加中文指令,要求 SuperAgent 输出结构化 JSON。

View File

@@ -56,6 +56,7 @@ AgentBus WebSocket 收到邮件
2026-07-12 导入的 `OPEN_AGENT_API_JAVA_SSE_CLIENT.md` 对 Java SSE 调用提出新的强约束。本项目后续 Open API client 必须满足: 2026-07-12 导入的 `OPEN_AGENT_API_JAVA_SSE_CLIENT.md` 对 Java SSE 调用提出新的强约束。本项目后续 Open API client 必须满足:
- 请求 `messages/stream?include_trace=true` 时生成稳定 `X-Request-ID` - 请求 `messages/stream?include_trace=true` 时生成稳定 `X-Request-ID`
- Open API 请求必须携带 CSRF double-submit`X-CSRF-Token``Cookie: csrf_token=<same-token>` 使用后端临时随机值,不写入配置文件或环境变量。
- 同一业务 SourceMessage 的 `idempotency_key` 在所有尝试中保持不变。 - 同一业务 SourceMessage 的 `idempotency_key` 在所有尝试中保持不变。
- 初始 POST 成功后保存响应头 `Content-Location`,解析并保存 SuperAgent `run_id` - 初始 POST 成功后保存响应头 `Content-Location`,解析并保存 SuperAgent `run_id`
- SSE 必须按帧解析 `event:``data:``id:` 和 heartbeat comment。 - SSE 必须按帧解析 `event:``data:``id:` 和 heartbeat comment。

View File

@@ -11,10 +11,12 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; 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.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -27,6 +29,11 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { 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 SuperAgentOpenApiProperties properties;
private final SuperAgentOpenApiSseParser sseParser; private final SuperAgentOpenApiSseParser sseParser;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -198,15 +205,27 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
* 构造 SuperAgent Open API 通用请求头,不记录或返回 API Key。 * 构造 SuperAgent Open API 通用请求头,不记录或返回 API Key。
*/ */
private HttpRequest.Builder baseRequest(URI uri, String requestId) { private HttpRequest.Builder baseRequest(URI uri, String requestId) {
String csrfToken = newCsrfToken();
HttpRequest.Builder builder = HttpRequest.newBuilder(uri) HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.header("Authorization", "Bearer " + properties.getApiKey()) .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)) { if (!blank(requestId)) {
builder.header("X-Request-ID", requestId); builder.header("X-Request-ID", requestId);
} }
return builder; 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。 * 校验 HTTP 状态码,错误摘要不包含 Secret。
*/ */

View File

@@ -42,7 +42,13 @@ class SuperAgentOpenApiClientImplTest {
void shouldRequestStreamingMessagesWithIncludeTraceAndEmitPublicTraceEvents() throws Exception { void shouldRequestStreamingMessagesWithIncludeTraceAndEmitPublicTraceEvents() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
AtomicReference<String> streamQuery = new AtomicReference<>(); AtomicReference<String> streamQuery = new AtomicReference<>();
AtomicReference<String> sessionCsrfToken = new AtomicReference<>();
AtomicReference<String> sessionCookie = new AtomicReference<>();
AtomicReference<String> streamCsrfToken = new AtomicReference<>();
AtomicReference<String> streamCookie = new AtomicReference<>();
server.createContext("/api/open/agent-sessions", exchange -> { 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); byte[] response = "{\"session_id\":\"session-http-001\"}".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, response.length); exchange.sendResponseHeaders(200, response.length);
@@ -52,6 +58,8 @@ class SuperAgentOpenApiClientImplTest {
}); });
server.createContext("/api/open/agent-sessions/session-http-001/messages/stream", exchange -> { server.createContext("/api/open/agent-sessions/session-http-001/messages/stream", exchange -> {
streamQuery.set(exchange.getRequestURI().getRawQuery()); streamQuery.set(exchange.getRequestURI().getRawQuery());
streamCsrfToken.set(exchange.getRequestHeaders().getFirst("X-CSRF-Token"));
streamCookie.set(exchange.getRequestHeaders().getFirst("Cookie"));
byte[] response = """ byte[] response = """
id: 1 id: 1
event: trace event: trace
@@ -98,6 +106,10 @@ class SuperAgentOpenApiClientImplTest {
Map.of("source", "unit-test")), traceEvents::add); Map.of("source", "unit-test")), traceEvents::add);
assertThat(streamQuery.get()).isEqualTo("include_trace=true"); 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.sessionId()).isEqualTo("session-http-001");
assertThat(result.runId()).isEqualTo("run-http-001"); assertThat(result.runId()).isEqualTo("run-http-001");
assertThat(result.lastEventId()).isEqualTo("4"); assertThat(result.lastEventId()).isEqualTo("4");