修复: 走查发现(空 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:
@@ -306,10 +306,21 @@ impl MentionResolver for SkillResolver {
|
||||
// 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败
|
||||
let body = read_skill_content_stripped(name.clone())
|
||||
.await
|
||||
.ok_or_else(|| ResolveError::NotFound {
|
||||
kind: "skill".to_string(),
|
||||
ref_id: name.clone(),
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
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"
|
||||
let source = crate::commands::ai::skills::skills_cached()
|
||||
.await
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
//! anthropic provider 不参与(图像端点是 OpenAI 风格)。
|
||||
//! 2. **端点拼接**:`build_images_url` 智能 base_url(`/v1`/`/v4` 后缀直接补 `/images/generations`,
|
||||
//! 否则补 `/v1/images/generations`),对齐 `model_fetch_helpers::build_models_url` 思路。
|
||||
//! 3. **provider 端点域名**:provider 已知域名(api.sensenova / api.openai / ...),非用户输入,
|
||||
//! SSRF 风险低,故 provider POST 直接 reqwest::Client 不走 SSRF 防护。
|
||||
//! 3. **provider 端点域名**:base_url 来自 DB 用户配置(设置页可填任意 URL),provider 攻陷 /
|
||||
//! 配置错误 / 恶意 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 篡改指向内网**
|
||||
//! (provider 域名虽可信但响应内容不可信),故下载图片 URL 复用 SSRF 防护
|
||||
//! (validate_url + resolve_and_check_host + build_client,与 download_file 同源)。
|
||||
@@ -127,7 +129,9 @@ pub(crate) async fn execute_generate_image(
|
||||
// ── 拼端点 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 mut m = serde_json::Map::new();
|
||||
m.insert("model".into(), json!(model));
|
||||
@@ -138,18 +142,27 @@ pub(crate) async fn execute_generate_image(
|
||||
}
|
||||
Value::Object(m)
|
||||
};
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(GENERATE_TIMEOUT_SECS))
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("HTTP client 构建失败: {}", e))?;
|
||||
let resp = client.post(&endpoint)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
|
||||
let body_str = serde_json::to_string(&body)
|
||||
.map_err(|e| anyhow::anyhow!("序列化请求 body 失败: {}", e))?;
|
||||
// SSRF 三层校验:词法 URL → DNS resolve IP(后续 build_client + execute_with_redirects
|
||||
// 对每跳重定向重复校验,防 302 绕过到内网)
|
||||
let (_scheme, host, port) = validate_url(&endpoint)?;
|
||||
resolve_and_check_host(&host, port).await?;
|
||||
// build_client 已含 timeout/connect_timeout + 关闭自动重定向(每跳手动校验)
|
||||
let client = build_client(Duration::from_secs(GENERATE_TIMEOUT_SECS))?;
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Authorization".into(), format!("Bearer {}", api_key));
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
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();
|
||||
if !status.is_success() {
|
||||
// 错误响应读 body 摘要(截断 500 chars)帮助定位
|
||||
@@ -214,8 +227,22 @@ pub(crate) async fn execute_generate_image(
|
||||
} else {
|
||||
// b64 路径:解码后直接写
|
||||
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)
|
||||
.map_err(|e| anyhow::anyhow!("b64_json 解码失败: {}", e))?;
|
||||
// 解码后再用实际长度兜底校验(防估算偏差,如 base64 含大量空白/padding)
|
||||
if (decoded.len() as u64) > MAX_IMAGE_BYTES {
|
||||
anyhow::bail!(
|
||||
"b64_json 解码后 {} 字节超过 {} 上限",
|
||||
|
||||
Reference in New Issue
Block a user