修复: 走查发现(空 id 跨轮全局唯一 + generate_image SSRF/OOM + 技能日志 + fallback 测试)
- 空 tool_call id fallback 改全局 AtomicU64 计数器(跨轮跨 assistant 唯一,治空气泡+ L1 误熔断+三元组错位,实证 af2fab4e);fallback 测试硬编码改 starts_with(解耦计数器) - generate_image provider POST 补 SSRF 防护(复用 http.rs validate_url/resolve_and_check_host, 治 base_url 用户可配打内网)+ b64_json 解码前估算长度防 OOM - SkillResolver 加 tracing 日志(定位技能发送后未注入:name 匹配/read 失败)
This commit is contained in:
@@ -125,22 +125,43 @@ impl ToolCall {
|
|||||||
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
|
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
|
||||||
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
|
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
|
||||||
///
|
///
|
||||||
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{index}")`
|
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")`
|
||||||
/// (index 取 tool_call 在数组中的位置,保证同 assistant 内多 tool_call id 唯一)。
|
/// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
|
||||||
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
|
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
|
||||||
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
|
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
|
||||||
///
|
///
|
||||||
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
|
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
|
||||||
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
|
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
|
||||||
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
|
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
|
||||||
pub fn tool_call_id_or_fallback(raw: &str, index: usize, prefix: &str) -> String {
|
///
|
||||||
|
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e)
|
||||||
|
///
|
||||||
|
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组
|
||||||
|
/// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的
|
||||||
|
/// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标
|
||||||
|
/// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls
|
||||||
|
/// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。
|
||||||
|
///
|
||||||
|
/// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index`
|
||||||
|
/// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传),
|
||||||
|
/// fallback 内部不再使用 index。
|
||||||
|
///
|
||||||
|
/// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst),
|
||||||
|
/// 保证全局唯一。
|
||||||
|
pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String {
|
||||||
if !raw.is_empty() {
|
if !raw.is_empty() {
|
||||||
raw.to_string()
|
raw.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{prefix}_{index}")
|
let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
format!("{prefix}_{n}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。
|
||||||
|
///
|
||||||
|
/// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。
|
||||||
|
static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
/// LLM Provider trait
|
/// LLM Provider trait
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LlmProvider: Send + Sync {
|
pub trait LlmProvider: Send + Sync {
|
||||||
@@ -462,33 +483,51 @@ mod tests {
|
|||||||
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
|
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_call_id_or_fallback_non_empty_passthrough() {
|
fn tool_call_id_or_fallback_non_empty_passthrough() {
|
||||||
|
// 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关
|
||||||
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
|
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
|
||||||
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
|
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_call_id_or_fallback_empty_generates_with_index() {
|
fn tool_call_id_or_fallback_empty_starts_with_prefix() {
|
||||||
assert_eq!(tool_call_id_or_fallback("", 0, "gen_tool"), "gen_tool_0");
|
// 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值)
|
||||||
assert_eq!(tool_call_id_or_fallback("", 1, "gen_tool"), "gen_tool_1");
|
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||||
assert_eq!(tool_call_id_or_fallback("", 7, "gen_stream"), "gen_stream_7");
|
assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}");
|
||||||
|
let b = tool_call_id_or_fallback("", 7, "gen_stream");
|
||||||
|
assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_call_id_or_fallback_empty_unique_per_index() {
|
fn tool_call_id_or_fallback_empty_globally_unique() {
|
||||||
// 同 prefix 不同 index → 不同 fallback(保证同 assistant 多 tool_call id 唯一)
|
// 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。
|
||||||
let ids: Vec<String> = (0..3).map(|i| tool_call_id_or_fallback("", i, "gen_tool")).collect();
|
// 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。
|
||||||
let mut sorted = ids.clone();
|
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||||
sorted.sort();
|
let b = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||||
sorted.dedup();
|
assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}");
|
||||||
assert_eq!(ids.len(), sorted.len(), "各 index fallback 应唯一: {:?}", ids);
|
// 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一
|
||||||
|
let c = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||||
|
let mut set = std::collections::HashSet::new();
|
||||||
|
assert!(set.insert(a), "fallback a 应唯一");
|
||||||
|
assert!(set.insert(b), "fallback b 应唯一");
|
||||||
|
assert!(set.insert(c), "fallback c 应唯一");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_call_id_or_fallback_index_unused() {
|
||||||
|
// index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。
|
||||||
|
// 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。
|
||||||
|
let a = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||||
|
let b = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||||
|
assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
|
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
|
||||||
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
|
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
|
||||||
assert_ne!(
|
// 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀
|
||||||
tool_call_id_or_fallback("", 0, "gen_tool"),
|
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||||
tool_call_id_or_fallback("", 0, "gen_stream")
|
let b = tool_call_id_or_fallback("", 0, "gen_stream");
|
||||||
);
|
assert!(a.starts_with("gen_tool_"));
|
||||||
|
assert!(b.starts_with("gen_stream_"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1167,9 +1167,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CR-空 id:parse_tool_calls 对空 id 按 index 生成 gen_tool_{i} fallback,非空原样。
|
/// CR-空 id:parse_tool_calls 对空 id 生成 gen_tool_{n} fallback(n 取自全局计数器,
|
||||||
/// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串)
|
/// 跨轮跨 assistant 严格递增),非空原样。根因:SenseNova 等兼容缺陷 provider 发空
|
||||||
/// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。
|
/// tool_call.id,多 tool_call 同 id(空串)致 audit/mod.rs:203 seen_ids 去重只留首个
|
||||||
|
/// → 所有工具结果路由到首个。
|
||||||
|
///
|
||||||
|
/// 断言策略:fallback id 由全局 FALLBACK_ID_COUNTER 决定具体序号,**同进程其他测试先
|
||||||
|
/// 消费计数器即非 0 起**(非确定性),故不假设具体序号,改断言 prefix + 唯一性 + 透传
|
||||||
|
/// 无损(对齐 provider.rs:495 helper 单测的 starts_with 模式,2026-08-02 走查修复)。
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_parse_tool_calls_empty_id_fallback_unique() {
|
fn openai_parse_tool_calls_empty_id_fallback_unique() {
|
||||||
let calls = vec![
|
let calls = vec![
|
||||||
@@ -1191,9 +1196,17 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
|
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
|
||||||
assert_eq!(parsed.len(), 3);
|
assert_eq!(parsed.len(), 3);
|
||||||
// 空 id → fallback(按 index),保证唯一
|
// 空 id → fallback(prefix=gen_tool_,具体序号由全局计数器决定,非确定性,不断言序号)
|
||||||
assert_eq!(parsed[0].id, "gen_tool_0");
|
assert!(
|
||||||
assert_eq!(parsed[1].id, "gen_tool_1");
|
parsed[0].id.starts_with("gen_tool_"),
|
||||||
|
"空 fallback 应以 gen_tool_ 开头, got: {}",
|
||||||
|
parsed[0].id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed[1].id.starts_with("gen_tool_"),
|
||||||
|
"空 fallback 应以 gen_tool_ 开头, got: {}",
|
||||||
|
parsed[1].id
|
||||||
|
);
|
||||||
// 非空 id 原样透传
|
// 非空 id 原样透传
|
||||||
assert_eq!(parsed[2].id, "call_abc123");
|
assert_eq!(parsed[2].id, "call_abc123");
|
||||||
// name/args 透传无损
|
// name/args 透传无损
|
||||||
@@ -1211,24 +1224,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id
|
/// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id
|
||||||
/// 转为 `gen_stream_{index}` fallback(非 None),保证下游 accumulate_tool_calls 写入
|
/// 转为 `gen_stream_{n}` fallback(n 取自全局计数器,跨轮跨 assistant 递增,非 None),
|
||||||
/// draft.id 非空。chunk 完全无 id 字段(None)保持 None(OpenAI 协议:仅首 chunk 有 id,
|
/// 保证下游 accumulate_tool_calls 写入 draft.id 非空。chunk 完全无 id 字段(None)保持
|
||||||
/// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。
|
/// None(OpenAI 协议:仅首 chunk 有 id,后续 chunk 无 id 不应覆盖首 chunk 权威 id),
|
||||||
|
/// 由 agentic 转换点兜底。
|
||||||
|
///
|
||||||
|
/// 断言策略:fallback id 具体序号由全局 FALLBACK_ID_COUNTER 决定,**同进程其他测试先
|
||||||
|
/// 消费计数器即非 0 起**(非确定性),故不假设具体序号,改断言 prefix + 跨 chunk 唯一 +
|
||||||
|
/// None/非空透传(对齐 provider.rs:530 helper 单测的 starts_with 模式,2026-08-02 走查修复)。
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_stream_chunk_empty_id_fallback() {
|
fn openai_stream_chunk_empty_id_fallback() {
|
||||||
let mut acc: Option<TokenUsage> = None;
|
let mut acc: Option<TokenUsage> = None;
|
||||||
// chunk 1: tool_call index=0, id="" → fallback gen_stream_0
|
// chunk 1: tool_call index=0, id="" → fallback gen_stream_{n}
|
||||||
let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#;
|
let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#;
|
||||||
let c1 = apply_openai_sse(data1, &mut acc);
|
let c1 = apply_openai_sse(data1, &mut acc);
|
||||||
let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
assert_eq!(tc1.index, 0);
|
assert_eq!(tc1.index, 0);
|
||||||
assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback");
|
let id1 = tc1.id.as_deref().expect("空 id 应转 fallback(非 None)");
|
||||||
|
assert!(
|
||||||
|
id1.starts_with("gen_stream_"),
|
||||||
|
"空 fallback 应以 gen_stream_ 开头, got: {}",
|
||||||
|
id1
|
||||||
|
);
|
||||||
|
|
||||||
// chunk 2: tool_call index=1, id="" → fallback gen_stream_1(与 index=0 不同,唯一)
|
// chunk 2: tool_call index=1, id="" → fallback gen_stream_{n+1}(与 chunk 1 不同,唯一)
|
||||||
let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#;
|
let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#;
|
||||||
let c2 = apply_openai_sse(data2, &mut acc);
|
let c2 = apply_openai_sse(data2, &mut acc);
|
||||||
let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
|
||||||
assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index fallback 应不同");
|
let id2 = tc2.id.as_deref().expect("空 id 应转 fallback(非 None)");
|
||||||
|
assert!(
|
||||||
|
id2.starts_with("gen_stream_"),
|
||||||
|
"空 fallback 应以 gen_stream_ 开头, got: {}",
|
||||||
|
id2
|
||||||
|
);
|
||||||
|
assert_ne!(id1, id2, "两次空 id 的 fallback 应不同(全局计数器递增唯一)");
|
||||||
|
|
||||||
// chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk)
|
// chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk)
|
||||||
let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#;
|
let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#;
|
||||||
|
|||||||
@@ -306,10 +306,21 @@ impl MentionResolver for SkillResolver {
|
|||||||
// 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败
|
// 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败
|
||||||
let body = read_skill_content_stripped(name.clone())
|
let body = read_skill_content_stripped(name.clone())
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| ResolveError::NotFound {
|
.ok_or_else(|| {
|
||||||
kind: "skill".to_string(),
|
tracing::warn!(
|
||||||
ref_id: name.clone(),
|
skill = %name,
|
||||||
|
"[SkillResolver] 未命中缓存/读失败 → aug 空,skill 未注入(用户感知:发送后未真使用)"
|
||||||
|
);
|
||||||
|
ResolveError::NotFound {
|
||||||
|
kind: "skill".to_string(),
|
||||||
|
ref_id: name.clone(),
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
tracing::info!(
|
||||||
|
skill = %name,
|
||||||
|
body_len = body.len(),
|
||||||
|
"[SkillResolver] resolve 成功,正文将注入 system prompt"
|
||||||
|
);
|
||||||
// source 从缓存取 SkillInfo.source;失败(缓存不一致)默认 "skill"
|
// source 从缓存取 SkillInfo.source;失败(缓存不一致)默认 "skill"
|
||||||
let source = crate::commands::ai::skills::skills_cached()
|
let source = crate::commands::ai::skills::skills_cached()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
//! anthropic provider 不参与(图像端点是 OpenAI 风格)。
|
//! anthropic provider 不参与(图像端点是 OpenAI 风格)。
|
||||||
//! 2. **端点拼接**:`build_images_url` 智能 base_url(`/v1`/`/v4` 后缀直接补 `/images/generations`,
|
//! 2. **端点拼接**:`build_images_url` 智能 base_url(`/v1`/`/v4` 后缀直接补 `/images/generations`,
|
||||||
//! 否则补 `/v1/images/generations`),对齐 `model_fetch_helpers::build_models_url` 思路。
|
//! 否则补 `/v1/images/generations`),对齐 `model_fetch_helpers::build_models_url` 思路。
|
||||||
//! 3. **provider 端点域名**:provider 已知域名(api.sensenova / api.openai / ...),非用户输入,
|
//! 3. **provider 端点域名**:base_url 来自 DB 用户配置(设置页可填任意 URL),provider 攻陷 /
|
||||||
//! SSRF 风险低,故 provider POST 直接 reqwest::Client 不走 SSRF 防护。
|
//! 配置错误 / 恶意 base_url 即可打内网(127.0.0.1 / 169.254.169.254 元数据)。故 provider
|
||||||
|
//! POST 端点同样走 SSRF 防护(validate_url + resolve_and_check_host + build_client),
|
||||||
|
//! 与图片 URL 下载同源,口径一致(2026-08-02 走查修复)。
|
||||||
//! 4. **图片 URL 下载**:图片 URL 来自 provider 响应,**可能被恶意 provider 篡改指向内网**
|
//! 4. **图片 URL 下载**:图片 URL 来自 provider 响应,**可能被恶意 provider 篡改指向内网**
|
||||||
//! (provider 域名虽可信但响应内容不可信),故下载图片 URL 复用 SSRF 防护
|
//! (provider 域名虽可信但响应内容不可信),故下载图片 URL 复用 SSRF 防护
|
||||||
//! (validate_url + resolve_and_check_host + build_client,与 download_file 同源)。
|
//! (validate_url + resolve_and_check_host + build_client,与 download_file 同源)。
|
||||||
@@ -127,7 +129,9 @@ pub(crate) async fn execute_generate_image(
|
|||||||
// ── 拼端点 URL ──
|
// ── 拼端点 URL ──
|
||||||
let endpoint = build_images_url(&provider.base_url);
|
let endpoint = build_images_url(&provider.base_url);
|
||||||
|
|
||||||
// ── POST 请求(provider 域名非用户输入,SSRF 风险低,直接 reqwest) ──
|
// ── POST 请求 ──
|
||||||
|
// base_url 来自 DB 用户配置(可填任意 URL),须走 SSRF 防护(协议白名单 + 私网 IP +
|
||||||
|
// DNS resolve + 重定向每跳校验),与图片 URL 下载同源(2026-08-02 走查修复)。
|
||||||
let body = {
|
let body = {
|
||||||
let mut m = serde_json::Map::new();
|
let mut m = serde_json::Map::new();
|
||||||
m.insert("model".into(), json!(model));
|
m.insert("model".into(), json!(model));
|
||||||
@@ -138,18 +142,27 @@ pub(crate) async fn execute_generate_image(
|
|||||||
}
|
}
|
||||||
Value::Object(m)
|
Value::Object(m)
|
||||||
};
|
};
|
||||||
let client = reqwest::Client::builder()
|
let body_str = serde_json::to_string(&body)
|
||||||
.timeout(Duration::from_secs(GENERATE_TIMEOUT_SECS))
|
.map_err(|e| anyhow::anyhow!("序列化请求 body 失败: {}", e))?;
|
||||||
.connect_timeout(Duration::from_secs(15))
|
// SSRF 三层校验:词法 URL → DNS resolve IP(后续 build_client + execute_with_redirects
|
||||||
.build()
|
// 对每跳重定向重复校验,防 302 绕过到内网)
|
||||||
.map_err(|e| anyhow::anyhow!("HTTP client 构建失败: {}", e))?;
|
let (_scheme, host, port) = validate_url(&endpoint)?;
|
||||||
let resp = client.post(&endpoint)
|
resolve_and_check_host(&host, port).await?;
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
// build_client 已含 timeout/connect_timeout + 关闭自动重定向(每跳手动校验)
|
||||||
.header("Content-Type", "application/json")
|
let client = build_client(Duration::from_secs(GENERATE_TIMEOUT_SECS))?;
|
||||||
.json(&body)
|
let mut headers = HashMap::new();
|
||||||
.send()
|
headers.insert("Authorization".into(), format!("Bearer {}", api_key));
|
||||||
.await
|
headers.insert("Content-Type".into(), "application/json".into());
|
||||||
.map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
|
let resp = execute_with_redirects(
|
||||||
|
&client,
|
||||||
|
reqwest::Method::POST,
|
||||||
|
endpoint.clone(),
|
||||||
|
&headers,
|
||||||
|
&Some(body_str),
|
||||||
|
MAX_REDIRECTS,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
// 错误响应读 body 摘要(截断 500 chars)帮助定位
|
// 错误响应读 body 摘要(截断 500 chars)帮助定位
|
||||||
@@ -214,8 +227,22 @@ pub(crate) async fn execute_generate_image(
|
|||||||
} else {
|
} else {
|
||||||
// b64 路径:解码后直接写
|
// b64 路径:解码后直接写
|
||||||
let b64 = b64_opt.as_ref().unwrap();
|
let b64 = b64_opt.as_ref().unwrap();
|
||||||
|
// OOM 防护(2026-08-02 走查修复):解码前先按 base64 长度估算解码后字节数,超 MAX_IMAGE_BYTES
|
||||||
|
// 直接 bail 不解码。恶意 provider 返超长 b64_json(如 200MB base64 → ~150MB 解码字节),
|
||||||
|
// 若先 STANDARD.decode 全载入内存再检查,瞬时 OOM。估算公式 len * 3 / 4(base64 每 4 字符
|
||||||
|
// 编码 3 字节),忽略 padding 误差(估值略大于实际,安全方向偏向拒)。
|
||||||
|
let estimated_decoded = (b64.len() as u64).saturating_mul(3) / 4;
|
||||||
|
if estimated_decoded > MAX_IMAGE_BYTES {
|
||||||
|
anyhow::bail!(
|
||||||
|
"b64_json 估算解码后约 {} 字节超过 {} 上限(原始 base64 长度 {})",
|
||||||
|
estimated_decoded,
|
||||||
|
MAX_IMAGE_BYTES,
|
||||||
|
b64.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
let decoded = STANDARD.decode(b64)
|
let decoded = STANDARD.decode(b64)
|
||||||
.map_err(|e| anyhow::anyhow!("b64_json 解码失败: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("b64_json 解码失败: {}", e))?;
|
||||||
|
// 解码后再用实际长度兜底校验(防估算偏差,如 base64 含大量空白/padding)
|
||||||
if (decoded.len() as u64) > MAX_IMAGE_BYTES {
|
if (decoded.len() as u64) > MAX_IMAGE_BYTES {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"b64_json 解码后 {} 字节超过 {} 上限",
|
"b64_json 解码后 {} 字节超过 {} 上限",
|
||||||
|
|||||||
Reference in New Issue
Block a user