Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion bin/blockcell/src/commands/gateway/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ fn base64_upload_within_decoded_limit(content: &str, limit: usize) -> bool {
decoded_upper_bound.saturating_sub(padding) <= limit
}

fn resolve_workspace_upload_target(
workspace: &std::path::Path,
relative: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
let resolved_workspace = blockcell_core::path_policy::resolve_for_policy(workspace);
let resolved_target =
blockcell_core::path_policy::resolve_for_policy(&workspace.join(relative));
if !resolved_target.starts_with(&resolved_workspace) {
return Err("Access denied: upload path escapes workspace".to_string());
}
Ok(resolved_target)
}

async fn reject_if_file_too_large(path: &std::path::Path, limit: u64) -> Option<Response> {
match tokio::fs::metadata(path).await {
Ok(meta) if !file_size_within_limit(meta.len(), limit) => Some(
Expand Down Expand Up @@ -551,7 +564,16 @@ pub(super) async fn handle_files_upload(
}

let workspace = state.paths.for_agent(&agent_id).workspace();
let target = workspace.join(&rel);
let target = match resolve_workspace_upload_target(&workspace, &rel) {
Ok(target) => target,
Err(error) => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": error })),
)
.into_response()
}
};
let path_echo = rel.to_string_lossy().to_string();
let content = content.to_string();
let encoding = encoding.to_string();
Expand Down Expand Up @@ -597,6 +619,34 @@ pub(super) async fn handle_files_upload(
mod tests {
use super::*;

#[cfg(unix)]
#[test]
fn gateway_upload_rejects_symlink_escape() {
use std::os::unix::fs::symlink;

let root = std::env::temp_dir().join(format!(
"blockcell-gateway-upload-test-{}",
uuid::Uuid::new_v4().simple()
));
let workspace = root.join("workspace");
let outside = root.join("outside");
let real = workspace.join("real");
std::fs::create_dir_all(&outside).expect("create outside dir");
std::fs::create_dir_all(&real).expect("create real dir");
symlink(&outside, workspace.join("link")).expect("create symlink");

let escaped =
resolve_workspace_upload_target(&workspace, std::path::Path::new("link/new.txt"));
assert!(escaped.is_err(), "symlink escape must be rejected");

let allowed =
resolve_workspace_upload_target(&workspace, std::path::Path::new("real/new.txt"))
.expect("real workspace target should be allowed");
assert!(allowed.starts_with(std::fs::canonicalize(&workspace).unwrap()));

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn file_size_limit_rejects_values_above_limit() {
assert!(file_size_within_limit(
Expand Down
98 changes: 76 additions & 22 deletions crates/channels/src/wecom/webhook.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
use super::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WecomVerificationDiagnostics {
timestamp_present: bool,
nonce_len: usize,
signature_present: bool,
encrypted_payload_len: usize,
token_configured: bool,
aes_key_configured: bool,
}

impl WecomVerificationDiagnostics {
fn new(
callback_token: &str,
encoding_aes_key: &str,
timestamp: &str,
nonce: &str,
signature: &str,
encrypted_payload: &str,
) -> Self {
Self {
timestamp_present: !timestamp.is_empty(),
nonce_len: nonce.len(),
signature_present: !signature.is_empty(),
encrypted_payload_len: encrypted_payload.len(),
token_configured: !callback_token.is_empty(),
aes_key_configured: !encoding_aes_key.is_empty(),
}
}
}

pub(crate) fn build_mixed_summary(mixed: &LongConnMixed) -> String {
let parts: Vec<String> = mixed
.items
Expand Down Expand Up @@ -338,21 +368,25 @@ pub async fn process_webhook(
let echostr_enc_owned = percent_decode(echostr_raw);
let echostr_enc = echostr_enc_owned.as_str();

// ── 原始数据诊断日志(INFO级别,方便复制调试)──────────────────────
let diagnostics = WecomVerificationDiagnostics::new(
&wecom_cfg.callback_token,
&wecom_cfg.encoding_aes_key,
timestamp,
nonce,
msg_signature,
echostr_enc,
);
tracing::info!(
token = %wecom_cfg.callback_token,
timestamp = %timestamp,
nonce = %nonce,
msg_signature= %msg_signature,
echostr = %echostr_enc,
echostr_len = echostr_enc.len(),
encoding_aes_key = %wecom_cfg.encoding_aes_key,
encoding_aes_key_len = wecom_cfg.encoding_aes_key.len(),
"WeCom GET 原始参数"
timestamp_present = diagnostics.timestamp_present,
nonce_len = diagnostics.nonce_len,
signature_present = diagnostics.signature_present,
encrypted_payload_len = diagnostics.encrypted_payload_len,
token_configured = diagnostics.token_configured,
aes_key_configured = diagnostics.aes_key_configured,
"WeCom GET verification parameters received"
);

if !wecom_cfg.callback_token.is_empty() {
// 计算签名并打印,方便对比
let mut parts = [
wecom_cfg.callback_token.as_str(),
timestamp,
Expand All @@ -362,20 +396,10 @@ pub async fn process_webhook(
parts.sort_unstable();
let combined = parts.join("");
let computed = sha1_hex(combined.as_bytes());
tracing::info!(
computed_signature = %computed,
expected_signature = %msg_signature,
sort_input = %combined,
"WeCom GET 签名计算"
);

// 4-param signature: sort(token, timestamp, nonce, msg_encrypt)
if computed != msg_signature {
tracing::warn!(
computed = %computed,
expected = %msg_signature,
"WeCom webhook: GET 签名不匹配"
);
tracing::warn!("WeCom webhook: GET signature verification failed");
return (403, "Forbidden: invalid signature".to_string());
}
}
Expand Down Expand Up @@ -659,3 +683,33 @@ pub async fn process_webhook(

(200, "success".to_string())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn wecom_verification_diagnostics_redact_secrets() {
let diagnostics = WecomVerificationDiagnostics::new(
"callback-secret",
"aes-secret",
"1700000000",
"nonce-secret",
"signature-secret",
"encrypted-secret",
);
let rendered = format!("{diagnostics:?}");

for secret in [
"callback-secret",
"aes-secret",
"nonce-secret",
"signature-secret",
"encrypted-secret",
] {
assert!(!rendered.contains(secret), "diagnostics leaked {secret}");
}
assert!(rendered.contains("token_configured: true"));
assert!(rendered.contains("aes_key_configured: true"));
}
}
36 changes: 35 additions & 1 deletion crates/providers/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,15 @@ impl ProviderPool {
idx, model = %model, provider = %provider_name,
weight, priority, "ProviderPool: entry loaded"
);
let built_idx = entries.len();
entries.push(BuiltEntry {
model,
provider_name,
weight,
priority,
provider: Arc::from(p),
});
health_map.insert(idx, EntryHealth::Healthy);
health_map.insert(built_idx, EntryHealth::Healthy);
}
Err(e) => {
warn!(idx, model = %model, error = %e, "ProviderPool: failed to build entry, skipping");
Expand Down Expand Up @@ -722,6 +723,39 @@ mod tests {
assert_eq!(status[0].weight, 2);
}

#[test]
fn pool_skips_invalid_entry_without_health_index_gap() {
let mut config = Config::default();
config.agents.defaults.model_pool = vec![
blockcell_core::config::ModelEntry {
model: "missing/model".to_string(),
provider: "provider-that-does-not-exist".to_string(),
weight: 1,
priority: 1,
input_price: None,
output_price: None,
temperature: None,
tool_call_mode: blockcell_core::config::ToolCallMode::Native,
},
blockcell_core::config::ModelEntry {
model: "ollama/qwen3.6".to_string(),
provider: "ollama".to_string(),
weight: 1,
priority: 2,
input_price: None,
output_price: None,
temperature: None,
tool_call_mode: blockcell_core::config::ToolCallMode::Native,
},
];

let pool = ProviderPool::from_config(&config).expect("valid second entry should build");
let status = pool.status_summary();
assert_eq!(status.len(), 1);
assert_eq!(status[0].health, "healthy");
assert_eq!(pool.acquire().map(|(idx, _)| idx), Some(0));
}

#[test]
fn test_report_transient_fails_then_cooling() {
let mut config = Config::default();
Expand Down
Loading
Loading