diff --git a/apps/ai-game-creator-shell/src-tauri/src/http_client.rs b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs index c4601f1bf..6ee6cfcfc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/http_client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs @@ -3,18 +3,46 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +fn same_origin(initial: &reqwest::Url, next: &reqwest::Url) -> bool { + initial.origin() == next.origin() +} + +fn agc_main_site_redirect_policy() -> reqwest::redirect::Policy { + // Keep reqwest's default same-origin behavior, but fail closed before a + // custom marker can be copied to a different origin. + let default_policy = reqwest::redirect::Policy::default(); + reqwest::redirect::Policy::custom(move |attempt| { + let origin_matches = attempt + .previous() + .first() + .map(|initial| same_origin(initial, attempt.url())) + .unwrap_or(false); + + if origin_matches { + default_policy.redirect(attempt) + } else { + attempt.stop() + } + }) +} + pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder { let mut headers = HeaderMap::new(); headers.insert( HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), ); - reqwest::Client::builder().default_headers(headers) + reqwest::Client::builder() + .default_headers(headers) + .redirect(agc_main_site_redirect_policy()) } #[cfg(test)] mod tests { - use super::{agc_main_site_client_builder, AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE}; + use super::{ + agc_main_site_client_builder, same_origin, AGC_CLIENT_MARKER_HEADER, + AGC_CLIENT_MARKER_VALUE, + }; use reqwest::header::AUTHORIZATION; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -58,6 +86,53 @@ mod tests { }) } + fn spawn_redirect_fixture( + listener: TcpListener, + location: String, + ) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept HTTP redirect request"); + let request = read_http_request(&mut stream); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .expect("write HTTP redirect response"); + request + }) + } + + #[test] + fn main_site_redirect_policy_compares_full_origin() { + let origin_matches = |initial: &str, next: &str| { + let initial = reqwest::Url::parse(initial).expect("parse initial URL"); + let next = reqwest::Url::parse(next).expect("parse next URL"); + same_origin(&initial, &next) + }; + + assert!(origin_matches( + "https://main.example/api/first", + "https://main.example/api/second" + )); + assert!(origin_matches( + "https://main.example", + "https://main.example:443/api/second" + )); + assert!(!origin_matches( + "https://main.example", + "http://main.example/api/second" + )); + assert!(!origin_matches( + "https://main.example", + "https://cdn.example/api/second" + )); + assert!(!origin_matches( + "https://main.example:8443", + "https://main.example:9443/api/second" + )); + } + #[tokio::test] async fn factory_sets_the_agc_marker_as_a_default_header() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); @@ -117,4 +192,183 @@ mod tests { Some("fixture-id".to_string()) ); } + + #[tokio::test] + async fn factory_follows_same_origin_redirects_with_the_agc_marker() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = std::thread::spawn(move || { + let (mut first_stream, _) = listener.accept().expect("accept first HTTP request"); + let first_request = read_http_request(&mut first_stream); + first_stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /api/auth/me/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write same-origin redirect response"); + + let (mut second_stream, _) = listener.accept().expect("accept redirected HTTP request"); + let second_request = read_http_request(&mut second_stream); + second_stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write final HTTP response"); + + (first_request, second_request) + }); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{address}/api/auth/me")) + .send() + .await + .expect("send request"); + let (first_request, second_request) = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + request_header(&first_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert_eq!( + request_header(&second_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + } + + #[tokio::test] + async fn factory_stops_cross_origin_redirects_before_sending_the_marker() { + let source_listener = TcpListener::bind("127.0.0.1:0").expect("bind source fixture"); + let source_address = source_listener + .local_addr() + .expect("read source fixture address"); + let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target fixture"); + target_listener + .set_nonblocking(true) + .expect("configure target fixture"); + let target_address = target_listener + .local_addr() + .expect("read target fixture address"); + let source_fixture = spawn_redirect_fixture( + source_listener, + format!("http://{target_address}/oss/object"), + ); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{source_address}/api/assets/read-url")) + .send() + .await + .expect("send request"); + let source_request = source_fixture.join().expect("join source fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&source_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert!(matches!( + target_listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[tokio::test] + async fn factory_allows_same_origin_then_blocks_cross_origin_redirect_chain() { + let source_listener = TcpListener::bind("127.0.0.1:0").expect("bind source fixture"); + let source_address = source_listener + .local_addr() + .expect("read source fixture address"); + let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target fixture"); + target_listener + .set_nonblocking(true) + .expect("configure target fixture"); + let target_address = target_listener + .local_addr() + .expect("read target fixture address"); + let fixture = std::thread::spawn(move || { + let (mut first_stream, _) = source_listener + .accept() + .expect("accept first source request"); + let first_request = read_http_request(&mut first_stream); + first_stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /api/auth/me/second\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write same-origin redirect response"); + + let (mut second_stream, _) = source_listener + .accept() + .expect("accept second source request"); + let second_request = read_http_request(&mut second_stream); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{target_address}/oss/object\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + second_stream + .write_all(response.as_bytes()) + .expect("write cross-origin redirect response"); + + (first_request, second_request) + }); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{source_address}/api/auth/me")) + .send() + .await + .expect("send request"); + let (first_request, second_request) = fixture.join().expect("join source fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&first_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert_eq!( + request_header(&second_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert!(matches!( + target_listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[tokio::test] + async fn explicit_no_redirect_policy_still_overrides_the_factory_policy() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = + spawn_redirect_fixture(listener, format!("http://{address}/api/auth/me/final")); + let client = agc_main_site_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("build no-redirect AGC main-site client"); + + let response = client + .get(format!("http://{address}/api/auth/me")) + .send() + .await + .expect("send request"); + let request = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + } } diff --git a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md new file mode 100644 index 000000000..927e3b5eb --- /dev/null +++ b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md @@ -0,0 +1,69 @@ +# Issue #226:跨 origin 重定向标记泄漏复审修复 + +更新时间:2026-09-02 +关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计` + +## 1. 修复结论 + +采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。 + +本次修复将工厂默认策略调整为: + +- 目标 URL 与原始请求 URL 的完整 origin(scheme + host + effective port)相同:委托 reqwest 默认 redirect policy。 +- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。 +- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。 + +这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。 + +## 2. 修改范围 + +只修改 AGC Rust HTTP Client factory 和其定向测试: + +- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs` +- 本地实施方案、阶段计划和本复审记录 + +不修改: + +- #225 的主站 tracking、数据库和后台代码。 +- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。 +- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。 + +## 3. 重定向行为契约 + +| 场景 | 处理 | 标记是否到达下一跳 | +|---|---|---:| +| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin | +| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 | +| 调用点显式 `Policy::none()` | 继续不 follow | 否 | + +使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。 + +## 4. 测试证据 + +新增或调整 `http_client` 定向测试,覆盖: + +- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。 +- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。 +- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。 +- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。 + +验证命令及结果: + +```text +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture +6 tests passed +``` + +## 5. 对 #225 的交接 + +本修复不改变 Header 契约: + +```http +X-Genarrative-Client: agc +``` + +主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。 + +## 6. 后续限制 + +`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy。当前已审计生产调用点只有显式 `Policy::none()`,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。 diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md index 5e0b59e2e..9bd8355d3 100644 --- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md +++ b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md @@ -6,12 +6,12 @@ - `#226 添加客户端特殊标识`:本方案实际实施范围 - `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 -状态:阶段 6 已完成(客户端实现、#225 交接与最终门禁已收口)
+状态:阶段 6 已完成;已补齐跨 origin 重定向安全复审修复
本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码 ## 1. 一句话交付结果 -让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持现有认证、幂等、timeout、connect timeout、redirect 和上传/下载安全边界不变;第三方请求不携带该标记。 +让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持同源重定向及现有认证、幂等、timeout、connect timeout 和上传/下载安全边界;跨 origin 重定向不继续请求,第三方请求不携带该标记。 ## 2. Issue 边界 @@ -98,7 +98,7 @@ X-Genarrative-Client: agc ## 4. 方案一的落地结构 -本次采用“统一 Client 工厂 + `default_headers`”方案,不引入 origin-aware RequestBuilder 包装器。 +本次采用“统一 Client 工厂 + `default_headers` + origin-safe redirect policy”方案,不引入 origin-aware RequestBuilder 包装器。 总体结构: @@ -112,6 +112,7 @@ TS 主站请求 Rust 主站请求 → 主站 Client Builder factory → default_headers 注入 X-Genarrative-Client: agc + → 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx → 保留调用方原有 Client 配置 Rust 第三方请求 @@ -156,6 +157,7 @@ Rust 第三方请求 - 创建 `reqwest::ClientBuilder`。 - 设置 `X-Genarrative-Client: agc` 默认 Header。 +- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。 - 不负责 token、API Key、幂等键、请求体、重试或错误解析。 示意结构: @@ -167,7 +169,22 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder { reqwest::header::HeaderName::from_static("x-genarrative-client"), reqwest::header::HeaderValue::from_static("agc"), ); - reqwest::Client::builder().default_headers(headers) + let default_policy = reqwest::redirect::Policy::default(); + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + let same_origin = attempt + .previous() + .first() + .map(|initial| initial.origin() == attempt.url().origin()) + .unwrap_or(false); + if same_origin { + default_policy.redirect(attempt) + } else { + attempt.stop() + } + }); + reqwest::Client::builder() + .default_headers(headers) + .redirect(redirect_policy) } ``` @@ -189,7 +206,7 @@ let client = agc_main_site_client_builder() - connect timeout。 - request timeout。 -- redirect policy。 +- 调用方显式设置的 redirect policy;未显式设置时保留同源默认重定向、默认限制和方法处理,跨 origin 重定向由 factory 停止。 - `no_proxy` 或其他已有网络策略(仅属于原请求的情况下保留)。 - Bearer access token/API Key 的设置方式。 - `Idempotency-Key` 的设置方式和原值。 @@ -233,7 +250,7 @@ let client = agc_main_site_client_builder() - 主站项目、素材库、素材目录、上传凭证、对象确认、资源登记、图片生成/编辑/抠图、视频/动画/音频生成以及 generation 查询请求复用带 `X-Genarrative-Client: agc` 的 Client。 - `assets/read-url` 换签请求使用主站 factory;签名 URL 媒体下载仍使用独立的 `build_external_asset_download_client`,不会携带主站标记。 - OSS multipart 上传、Provider、受控搜索、loopback 工具桥和更新下载没有切换到主站 factory。 -- 保留原有 timeout、connect timeout、redirect、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;未修改 `ExternalEditorBindingAccess` 的路由映射语义。 +- 保留原有 timeout、connect timeout、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;同源重定向保持默认语义,跨 origin 重定向由 factory 阻断;未修改 `ExternalEditorBindingAccess` 的路由映射语义。 - 阶段 3 请求捕获验证:`sync_canvas_project_assets_downloads_external_resources`(1 test passed)确认账号态项目请求和 `assets/read-url` 带标记,签名媒体下载不带标记。 - 阶段 3 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`、`npm run check:encoding` 和 `git diff --check` 均通过。 @@ -379,7 +396,7 @@ Rust 层: - 原有 Authorization 仍被发送。 - 原有 Idempotency-Key 仍被发送且值不变。 - 请求体和 Content-Type 不变。 -- redirect policy 不变。 +- 同源 redirect policy 的默认跟随、限制和方法处理保持不变;跨 origin 3xx 不发起下一跳。 - timeout 和 connect timeout 不被统一 factory 覆盖成单一值。 - 第三方请求没有因共享 Client 改造而意外继承主站 Header。 @@ -428,7 +445,7 @@ Rust 层: |---|---| | TS `clientHttp` 定向测试 | 通过,14 tests passed | | AGC TS 类型检查(含 skill-pack/config 检查) | 通过 | -| Rust `http_client` factory 测试 | 通过,2 tests passed | +| Rust `http_client` factory 测试 | 通过,7 tests passed;覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` | | Rust OSS/签名 URL/Provider/搜索/loopback/更新下载负向测试 | 通过,`omits_agc_marker` 4 tests passed;loopback 认证边界 1 test passed | | Rust 账号态主站 mock 捕获 | 通过,1 test passed | | Rust External Key 态主站 mock 捕获 | 通过,1 test passed | @@ -436,7 +453,7 @@ Rust 层: | Rust 提交响应丢失幂等回归 | 通过,1 test passed | | Rust fmt、编码、diff 空白检查 | 通过 | -本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。 +本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。跨 origin 重定向复审已通过本地双 listener 和链式重定向测试确认不会发起第三方下一跳。 可直接粘贴到 `#225` 的交接评论: diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md index 74f44384c..e0a9eb589 100644 --- a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md +++ b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md @@ -6,7 +6,7 @@ - `#226 添加客户端特殊标识`:本计划全部实施范围 - `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现 -当前状态:阶段 6 已完成;阶段 0 的证据与验收记录见 +当前状态:阶段 6 已完成;跨 origin 重定向复审修复已完成;阶段 0 的证据与验收记录见 [【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。 ## 1. 交付目标 @@ -17,7 +17,7 @@ AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求 X-Genarrative-Client: agc ``` -同时保持现有认证、幂等、timeout、connect timeout、redirect、`no_proxy`、请求体和响应处理语义不变;OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。 +同时保持同源重定向及现有认证、幂等、timeout、connect timeout、`no_proxy`、请求体和响应处理语义;跨 origin 3xx 不继续请求;OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。 ## 2. 明确不做项 @@ -149,11 +149,11 @@ TS 定向测试必须证明: fn agc_main_site_client_builder() -> reqwest::ClientBuilder; ``` -工厂返回的 builder 允许调用方继续设置原有配置: +工厂返回的 builder 允许调用方继续设置原有配置;未显式覆盖时,factory 默认只跟随同 origin 重定向: - connect timeout - request timeout -- redirect policy +- 显式 redirect policy(覆盖 factory 默认策略) - 原有 `no_proxy` 或其他网络策略 ### 6.2 阶段验收 @@ -162,7 +162,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder; - 从 factory build 出的 Client 默认带 `X-Genarrative-Client: agc`。 - 请求级 Authorization 和 Idempotency-Key 仍可正常设置。 -- factory 不自动改变 timeout、redirect 或 proxy 配置。 +- factory 不改变 timeout 或 proxy 配置;默认 redirect 只跟随同 origin,调用方显式 `Policy::none()` 仍可覆盖。 - 不新增完整 `AgcMainSiteClient` 类型。 - 不把第三方下载或 Provider Client 接入该 factory。 @@ -176,7 +176,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder; - 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`。 - factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。 -- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、redirect policy 和 `no_proxy`。 +- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、显式 redirect policy 和 `no_proxy`;未显式配置时跨 origin 重定向不会被跟随。 - 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。 - 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 - 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。 @@ -189,7 +189,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder; - 主站用途的 `reqwest::Client::builder()` 改用 AGC 主站 builder factory。 - 主站用途的 `reqwest::Client::new()` 改为 builder 创建,以便设置默认 Header。 -- 保留每个调用原有 timeout、connect timeout、redirect、认证和幂等设置。 +- 保留每个调用原有 timeout、connect timeout、认证和幂等设置;同源 redirect 语义保留,跨 origin redirect 统一阻断。 重点检查文件: @@ -234,7 +234,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder; - 登录账号态路径带标记。 - 开发者 API Key 态 External v1 路径带标记。 - 生成提交和任务轮询都带标记。 -- 请求体、Bearer/API Key、Idempotency-Key、timeout 和 redirect policy 未变化。 +- 请求体、Bearer/API Key、Idempotency-Key、timeout 和同源 redirect 处理未变化;跨 origin redirect 不发起第二跳。 - 没有为了加标记而修改 `ExternalEditorBindingAccess` 的路由映射语义。 ### 7.4 阶段完成判据 @@ -377,7 +377,7 @@ tracking metadata:client = "agc" ### 10.4 阶段 6 实施记录 -阶段 6 已完成,交给 `#225` 的固定材料已在 +阶段 6 已完成,跨 origin 重定向复审修复已收口;交给 `#225` 的固定材料已在 [【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md) 第 12 节集中确认:Header 为 `X-Genarrative-Client: agc`,tracking metadata 使用 `client: "agc"`,主站按实际 method/path 和真实认证主体记录;缺失/空值/未知 Header 不拒绝请求。该交接不要求 `#226` 再回改客户端,也不要求本阶段实现 `#225` 的主站 tracking、数据库或后台代码。 @@ -385,7 +385,7 @@ tracking metadata:client = "agc" - TS `clientHttp` 定向测试:14 tests passed。 - AGC TS 类型检查:通过。 -- Rust `http_client` factory:2 tests passed。 +- Rust `http_client` factory:7 tests passed,覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()`。 - Rust `omits_agc_marker`:4 tests passed;loopback 认证边界:1 test passed。 - 账号态主站 mock 捕获:1 test passed。 - External Key 态主站 mock 捕获:1 test passed。 diff --git a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md index 9a0c3c762..e9ceecbd8 100644 --- a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md +++ b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md @@ -391,6 +391,6 @@ impl AgcMainSiteHttp { - 原 Authorization 不变。 - 原 Idempotency-Key 不变。 - 原请求 body 不变。 -- 原 timeout 和 redirect policy 不变。 +- 原 timeout 不变;同源 redirect policy 语义不变,跨 origin redirect 由主站 Client factory 阻断。 - 浏览器跨域预检允许 `X-Genarrative-Client`。 - 未识别 marker 时主站业务请求不受影响。 diff --git a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md index 6b9e418eb..c2ecfdd54 100644 --- a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md +++ b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md @@ -3,7 +3,7 @@ 更新时间:`2026-09-02` 实施范围:`#226 添加客户端特殊标识` 交接范围:`#225 添加客户端埋点统计` -执行结论:阶段 6 通过;#226 客户端实现、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。 +执行结论:阶段 6 通过;#226 客户端实现、跨 origin 重定向安全复审、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。 ## 1. 阶段边界 @@ -97,7 +97,7 @@ X-Genarrative-Client: agc |---|---|---| | TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed | | AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 | -| Rust 主站 Client factory | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,2 tests passed | +| Rust 主站 Client factory 与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,7 tests passed;同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` 均覆盖 | | 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed | | loopback 认证/请求边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture` | 通过,1 test passed | | 账号态主站请求捕获 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed | @@ -112,7 +112,7 @@ Rust 测试输出包含仓库既有的 unused/dead-code warning;本任务相 ## 5. 发布环境限制与后续交接 -本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixture;OSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。 +本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixture;OSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获;跨 origin 重定向来自双 listener 和链式重定向 fixture。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。 ## 6. 可直接粘贴到 #225 的评论