From 8b60275981940d09dd8b15b757b60fb7411f59ee Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 8 May 2026 13:34:26 -0500 Subject: [PATCH] chore(release): prepare v0.8.22 Validate redirected fetch targets before following them and prepare v0.8.22. --- Cargo.lock | 28 ++-- Cargo.toml | 2 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +-- crates/cli/Cargo.toml | 14 +- crates/config/Cargo.toml | 2 +- crates/core/Cargo.toml | 16 +-- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/Cargo.toml | 4 +- crates/tui/src/prompts.rs | 4 +- crates/tui/src/tools/fetch_url.rs | 218 +++++++++++++++++++----------- npm/deepseek-tui/package.json | 4 +- 14 files changed, 190 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baa5b5fa..a7df3c84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.21" +version = "0.8.22" dependencies = [ "deepseek-config", "serde", @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "axum", @@ -1182,7 +1182,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "deepseek-secrets", @@ -1194,7 +1194,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "chrono", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "deepseek-protocol", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "async-trait", @@ -1235,7 +1235,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "serde", @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.21" +version = "0.8.22" dependencies = [ "serde", "serde_json", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.21" +version = "0.8.22" dependencies = [ "dirs", "keyring", @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "chrono", @@ -1277,7 +1277,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "async-trait", @@ -1290,7 +1290,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "arboard", @@ -1351,7 +1351,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.21" +version = "0.8.22" dependencies = [ "anyhow", "chrono", @@ -1375,7 +1375,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.21" +version = "0.8.22" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index cbae49fa..3dc1cb67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.21" +version = "0.8.22" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 30ac01a8..3c2c2726 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.8.21" } +deepseek-config = { path = "../config", version = "0.8.22" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 8bdbc29b..9ae6f7c2 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.21" } -deepseek-config = { path = "../config", version = "0.8.21" } -deepseek-core = { path = "../core", version = "0.8.21" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" } -deepseek-hooks = { path = "../hooks", version = "0.8.21" } -deepseek-mcp = { path = "../mcp", version = "0.8.21" } -deepseek-protocol = { path = "../protocol", version = "0.8.21" } -deepseek-state = { path = "../state", version = "0.8.21" } -deepseek-tools = { path = "../tools", version = "0.8.21" } +deepseek-agent = { path = "../agent", version = "0.8.22" } +deepseek-config = { path = "../config", version = "0.8.22" } +deepseek-core = { path = "../core", version = "0.8.22" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" } +deepseek-hooks = { path = "../hooks", version = "0.8.22" } +deepseek-mcp = { path = "../mcp", version = "0.8.22" } +deepseek-protocol = { path = "../protocol", version = "0.8.22" } +deepseek-state = { path = "../state", version = "0.8.22" } +deepseek-tools = { path = "../tools", version = "0.8.22" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 609d999b..83be8411 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.21" } -deepseek-app-server = { path = "../app-server", version = "0.8.21" } -deepseek-config = { path = "../config", version = "0.8.21" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" } -deepseek-mcp = { path = "../mcp", version = "0.8.21" } -deepseek-secrets = { path = "../secrets", version = "0.8.21" } -deepseek-state = { path = "../state", version = "0.8.21" } +deepseek-agent = { path = "../agent", version = "0.8.22" } +deepseek-app-server = { path = "../app-server", version = "0.8.22" } +deepseek-config = { path = "../config", version = "0.8.22" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" } +deepseek-mcp = { path = "../mcp", version = "0.8.22" } +deepseek-secrets = { path = "../secrets", version = "0.8.22" } +deepseek-state = { path = "../state", version = "0.8.22" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index ce2b4476..80c772bf 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.8.21" } +deepseek-secrets = { path = "../secrets", version = "0.8.22" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 255d493f..e43688f6 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.21" } -deepseek-config = { path = "../config", version = "0.8.21" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.21" } -deepseek-hooks = { path = "../hooks", version = "0.8.21" } -deepseek-mcp = { path = "../mcp", version = "0.8.21" } -deepseek-protocol = { path = "../protocol", version = "0.8.21" } -deepseek-state = { path = "../state", version = "0.8.21" } -deepseek-tools = { path = "../tools", version = "0.8.21" } +deepseek-agent = { path = "../agent", version = "0.8.22" } +deepseek-config = { path = "../config", version = "0.8.22" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.22" } +deepseek-hooks = { path = "../hooks", version = "0.8.22" } +deepseek-mcp = { path = "../mcp", version = "0.8.22" } +deepseek-protocol = { path = "../protocol", version = "0.8.22" } +deepseek-state = { path = "../state", version = "0.8.22" } +deepseek-tools = { path = "../tools", version = "0.8.22" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index e5533738..bd915fb6 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.21" } +deepseek-protocol = { path = "../protocol", version = "0.8.22" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 09067fe8..927008d0 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.21" } +deepseek-protocol = { path = "../protocol", version = "0.8.22" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 6c8d90bf..508ad6b8 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.21" } +deepseek-protocol = { path = "../protocol", version = "0.8.22" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 866b1125..6394c9b9 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.8.21" } -deepseek-tools = { path = "../tools", version = "0.8.21" } +deepseek-secrets = { path = "../secrets", version = "0.8.22" } +deepseek-tools = { path = "../tools", version = "0.8.22" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 036f0869..f8af14cb 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -618,8 +618,8 @@ mod tests { fn package_version_is_current_hotfix_release() { assert_eq!( env!("CARGO_PKG_VERSION"), - "0.8.21", - "0.8.21 release branch must report the release version before publishing" + "0.8.22", + "0.8.22 release branch must report the release version before publishing" ); } diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index fd78bd07..188b05b7 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -10,7 +10,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, }; -use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url}; +use crate::network_policy::{Decision, NetworkPolicyDecider}; use async_trait::async_trait; use regex::Regex; use serde::Serialize; @@ -142,91 +142,57 @@ impl ToolSpec for FetchUrlTool { )); } - // Extract host once for reuse across network policy + SSRF checks. - let url_host = host_from_url(&url); - - // Per-domain network policy gate (#135). If no policy is attached - // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0. - if let Some(decider) = context.network_policy.as_ref() - && let Some(ref host) = url_host - { - match decider.evaluate(host, "fetch_url") { - Decision::Allow => {} - Decision::Deny => { - return Err(ToolError::permission_denied(format!( - "network call to '{host}' blocked by network policy" - ))); - } - Decision::Prompt => { - return Err(ToolError::permission_denied(format!( - "network call to '{host}' requires approval; \ - re-run after `/network allow {host}` or set network.default = \"allow\" in config" - ))); - } - } - } - - // SSRF protection: resolve hostname and reject private/link-local/loopback IPs. - // Prevents LLM-prompted requests to cloud metadata (169.254.169.254), - // localhost services, and internal networks. - // Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding - // TOCTOU window — reqwest will use the pinned IP instead of re-resolving. - let mut dns_pinning = None; // (hostname, validated_ip) - if let Some(host) = &url_host { - if host == "localhost" || host == "localhost.localdomain" { - return Err(ToolError::permission_denied( - "requests to localhost are not allowed", - )); - } - if let Ok(ip) = host.parse::() { - if is_restricted_ip(&ip) { - return Err(ToolError::permission_denied(format!( - "IP {ip} is a restricted address (private/loopback/link-local)" - ))); - } - } else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await { - let mut first_valid: Option = None; - for addr in addrs { - validate_dns_resolved_ip(host, &addr.ip(), context.network_policy.as_ref())?; - if first_valid.is_none() { - first_valid = Some(addr.ip()); - } - } - if let Some(validated_ip) = first_valid { - dns_pinning = Some((host.clone(), validated_ip)); - } - } - // If DNS resolution fails, let the HTTP request proceed and fail naturally. - } - let format = Format::parse(input.get("format").and_then(Value::as_str))?; let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES); let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS); + let mut current_url = reqwest::Url::parse(&url) + .map_err(|e| ToolError::invalid_input(format!("invalid URL: {e}")))?; + let mut redirects_followed = 0usize; - let mut client_builder = reqwest::Client::builder() - .timeout(Duration::from_millis(timeout_ms)) - .user_agent(USER_AGENT) - .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS)); + let resp = loop { + let dns_pinning = validate_fetch_target(¤t_url, context).await?; + let mut client_builder = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .user_agent(USER_AGENT) + .redirect(reqwest::redirect::Policy::none()); - // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will - // connect to the validated IP directly instead of re-resolving. - if let Some((hostname, validated_ip)) = dns_pinning { - client_builder = - client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0)); - } + // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will + // connect to the validated IP directly instead of re-resolving. + if let Some((hostname, validated_ip)) = dns_pinning { + client_builder = + client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0)); + } - let client = client_builder.build().map_err(|e| { - ToolError::execution_failed(format!("failed to build HTTP client: {e}")) - })?; + let client = client_builder.build().map_err(|e| { + ToolError::execution_failed(format!("failed to build HTTP client: {e}")) + })?; - let resp = client - .get(&url) - .header("Accept", "text/html,text/plain,application/json,*/*;q=0.5") - .header("Accept-Language", "en-US,en;q=0.5") - .send() - .await - .map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?; + let resp = client + .get(current_url.clone()) + .header("Accept", "text/html,text/plain,application/json,*/*;q=0.5") + .header("Accept-Language", "en-US,en;q=0.5") + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?; + + if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS { + break resp; + } + + let Some(location) = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + else { + break resp; + }; + + current_url = resp.url().join(location).map_err(|e| { + ToolError::execution_failed(format!("invalid redirect location: {e}")) + })?; + redirects_followed += 1; + }; let final_url = resp.url().to_string(); let status = resp.status(); @@ -327,6 +293,71 @@ fn is_restricted_ip(ip: &std::net::IpAddr) -> bool { } } +async fn validate_fetch_target( + url: &reqwest::Url, + context: &ToolContext, +) -> Result, ToolError> { + if url.scheme() != "http" && url.scheme() != "https" { + return Err(ToolError::invalid_input( + "only http:// and https:// URLs are supported", + )); + } + + let host = url + .host_str() + .map(str::to_ascii_lowercase) + .ok_or_else(|| ToolError::invalid_input("URL must include a host"))?; + + validate_network_policy(&host, context)?; + + // SSRF protection: resolve hostname and reject private/link-local/loopback IPs. + // Prevents LLM-prompted requests to cloud metadata (169.254.169.254), + // localhost services, and internal networks. + if host == "localhost" || host == "localhost.localdomain" { + return Err(ToolError::permission_denied( + "requests to localhost are not allowed", + )); + } + if let Ok(ip) = host.parse::() { + if is_restricted_ip(&ip) { + return Err(ToolError::permission_denied(format!( + "IP {ip} is a restricted address (private/loopback/link-local)" + ))); + } + return Ok(None); + } + + let mut first_valid: Option = None; + if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await { + for addr in addrs { + validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?; + if first_valid.is_none() { + first_valid = Some(addr.ip()); + } + } + } + + // If DNS resolution fails, let the HTTP request proceed and fail naturally. + Ok(first_valid.map(|validated_ip| (host, validated_ip))) +} + +fn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> { + let Some(decider) = context.network_policy.as_ref() else { + return Ok(()); + }; + + match decider.evaluate(host, "fetch_url") { + Decision::Allow => Ok(()), + Decision::Deny => Err(ToolError::permission_denied(format!( + "network call to '{host}' blocked by network policy" + ))), + Decision::Prompt => Err(ToolError::permission_denied(format!( + "network call to '{host}' requires approval; \ + re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))), + } +} + fn validate_dns_resolved_ip( host: &str, ip: &std::net::IpAddr, @@ -524,6 +555,37 @@ mod tests { assert!(format!("{err}").contains("blocked")); } + #[tokio::test] + async fn redirected_localhost_hostname_is_rejected() { + let url = reqwest::Url::parse("http://localhost:8080/admin").unwrap(); + let err = validate_fetch_target(&url, &ctx()).await.unwrap_err(); + assert!(format!("{err}").contains("localhost")); + } + + #[tokio::test] + async fn redirected_private_ip_literal_is_rejected() { + let url = reqwest::Url::parse("http://169.254.169.254/latest/meta-data").unwrap(); + let err = validate_fetch_target(&url, &ctx()).await.unwrap_err(); + assert!(format!("{err}").contains("restricted address")); + } + + #[tokio::test] + async fn redirected_host_respects_network_policy() { + use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider}; + let policy = NetworkPolicy { + default: Decision::Deny.into(), + allow: vec!["api.deepseek.com".to_string()], + deny: vec![], + proxy: Vec::new(), + audit: false, + }; + let decider = NetworkPolicyDecider::new(policy, None); + let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider); + let url = reqwest::Url::parse("https://example.com/redirect-target").unwrap(); + let err = validate_fetch_target(&url, &ctx).await.unwrap_err(); + assert!(format!("{err}").contains("blocked")); + } + #[test] fn restricted_dns_result_is_denied_without_proxy_opt_in() { let ip = "198.18.0.1".parse().unwrap(); diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index a802a358..dd387faf 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.21", - "deepseekBinaryVersion": "0.8.21", + "version": "0.8.22", + "deepseekBinaryVersion": "0.8.22", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",