diff --git a/Cargo.lock b/Cargo.lock index e40ca73..169d84e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -481,7 +481,7 @@ dependencies = [ [[package]] name = "mt5-quant" -version = "1.27.0" +version = "1.28.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index a90d44a..e523d5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mt5-quant" -version = "1.27.0" +version = "1.28.0" edition = "2021" description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP" authors = ["masdevid "] diff --git a/src/models/config.rs b/src/models/config.rs index c68087f..0d0a298 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -2,7 +2,6 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; /// Active MT5 account session info diff --git a/src/tools/handlers/backtest.rs b/src/tools/handlers/backtest.rs index 1eb5a46..6a17514 100644 --- a/src/tools/handlers/backtest.rs +++ b/src/tools/handlers/backtest.rs @@ -37,6 +37,7 @@ impl BacktestPreflight { } } + #[allow(dead_code)] fn is_ready(&self) -> bool { self.account.is_some() && !self.available_symbols.is_empty() && self.ea_exists } diff --git a/src/tools/handlers/utility.rs b/src/tools/handlers/utility.rs index ecd84ed..2b68cf3 100644 --- a/src/tools/handlers/utility.rs +++ b/src/tools/handlers/utility.rs @@ -1,10 +1,41 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::fs; -use std::path::Path; -use std::process::Command; +use std::path::{Path, PathBuf}; use crate::models::Config; -use walkdir::WalkDir; + +/// Validate that `user_path` resolves to a location within `allowed_base`. +/// Returns the canonicalized absolute path on success. +fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result { + // Resolve the base first (must already exist). + let base = allowed_base.canonicalize() + .with_context(|| format!("allowed base directory does not exist: {}", allowed_base.display()))?; + + // Build the candidate path. If the user supplied an absolute path we use it + // as-is; relative paths are joined onto the base so they stay inside it. + let candidate = { + let p = Path::new(user_path); + if p.is_absolute() { p.to_path_buf() } else { base.join(p) } + }; + + // Canonicalize the parent directory (the file itself need not exist yet). + let parent = candidate.parent() + .ok_or_else(|| anyhow::anyhow!("output_path has no parent directory"))?; + let canonical_parent = parent.canonicalize() + .with_context(|| format!("output_path parent directory does not exist: {}", parent.display()))?; + let canonical = canonical_parent.join( + candidate.file_name() + .ok_or_else(|| anyhow::anyhow!("output_path must include a filename"))?, + ); + + if !canonical.starts_with(&base) { + return Err(anyhow::anyhow!( + "output_path '{}' is outside the allowed directory '{}'", + user_path, base.display() + )); + } + Ok(canonical) +} /// Check if symbol has sufficient data for date range pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> Result { @@ -215,7 +246,7 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul } /// Compare multiple backtests -pub async fn handle_compare_backtests(config: &Config, args: &Value) -> Result { +pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result { let report_dirs = args.get("report_dirs") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow::anyhow!("report_dirs array is required"))?; @@ -269,7 +300,7 @@ pub async fn handle_compare_backtests(config: &Config, args: &Value) -> Result Result // Determine output path let set_path = if let Some(path) = output_path { - Path::new(path).to_path_buf() + // Restrict writes to the tester profiles dir (or the EA's own dir as fallback). + let allowed_base = if let Some(profiles_dir) = &config.tester_profiles_dir { + Path::new(profiles_dir).to_path_buf() + } else { + ea_path.parent().unwrap_or(Path::new(".")).to_path_buf() + }; + safe_output_path(path, &allowed_base)? } else if let Some(profiles_dir) = &config.tester_profiles_dir { Path::new(profiles_dir).join(format!("{}.set", ea)) } else { @@ -748,7 +785,7 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result Result Path::new(p).to_path_buf(), - None => path.join(format!("report.{}" -, format)) + Some(p) => safe_output_path(p, path)?, + None => path.join(format!("report.{}", format)), }; let content = match format {