Addressing vareity of security issues

This commit is contained in:
Illia Polosukhin
2026-02-05 10:40:24 -08:00
parent 0ab9643843
commit 3e6dfb8409
11 changed files with 602 additions and 58 deletions
+8
View File
@@ -214,6 +214,10 @@ impl Tool for ReadFileTool {
fn requires_sanitization(&self) -> bool {
true // File content could contain anything
}
fn requires_approval(&self) -> bool {
true // Reading local files should require approval
}
}
/// Write file contents tool.
@@ -422,6 +426,10 @@ impl Tool for ListDirTool {
fn requires_sanitization(&self) -> bool {
false // Directory listings are safe
}
fn requires_approval(&self) -> bool {
true // Directory listings can leak filesystem structure
}
}
/// Recursively list directory contents.
+100 -7
View File
@@ -1,12 +1,14 @@
//! HTTP request tool.
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use crate::context::JobContext;
use crate::safety::LeakDetector;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for making HTTP requests.
@@ -26,6 +28,58 @@ impl HttpTool {
}
}
fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ToolError::InvalidParameters(format!("invalid URL: {}", e)))?;
if parsed.scheme() != "https" {
return Err(ToolError::NotAuthorized(
"only https URLs are allowed".to_string(),
));
}
let host = parsed
.host_str()
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".to_string()))?;
let host_lower = host.to_lowercase();
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return Err(ToolError::NotAuthorized(
"localhost is not allowed".to_string(),
));
}
if let Ok(ip) = host.parse::<IpAddr>() {
if is_disallowed_ip(&ip) {
return Err(ToolError::NotAuthorized(
"private or local IPs are not allowed".to_string(),
));
}
}
Ok(parsed)
}
fn is_disallowed_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| *v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
}
}
}
impl Default for HttpTool {
fn default() -> Self {
Self::new()
@@ -90,20 +144,25 @@ impl Tool for HttpTool {
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'url' parameter".to_string()))?;
let parsed_url = validate_url(url)?;
// Parse headers
let headers: HashMap<String, String> = params
.get("headers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let headers_vec: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Build request
let mut request = match method.to_uppercase().as_str() {
"GET" => self.client.get(url),
"POST" => self.client.post(url),
"PUT" => self.client.put(url),
"DELETE" => self.client.delete(url),
"PATCH" => self.client.patch(url),
"GET" => self.client.get(parsed_url.clone()),
"POST" => self.client.post(parsed_url.clone()),
"PUT" => self.client.put(parsed_url.clone()),
"DELETE" => self.client.delete(parsed_url.clone()),
"PATCH" => self.client.patch(parsed_url.clone()),
_ => {
return Err(ToolError::InvalidParameters(format!(
"unsupported method: {}",
@@ -118,9 +177,20 @@ impl Tool for HttpTool {
}
// Add body if present
if let Some(body) = params.get("body") {
let body_bytes = if let Some(body) = params.get("body") {
let bytes = serde_json::to_vec(body)
.map_err(|e| ToolError::InvalidParameters(format!("invalid body JSON: {}", e)))?;
request = request.json(body);
}
Some(bytes)
} else {
None
};
// Leak detection on outbound request (url/headers/body)
let detector = LeakDetector::new();
detector
.scan_http_request(parsed_url.as_str(), &headers_vec, body_bytes.as_deref())
.map_err(|e| ToolError::NotAuthorized(format!("{}", e)))?;
// Execute request
let response = request.send().await.map_err(|e| {
@@ -168,3 +238,26 @@ impl Tool for HttpTool {
true // HTTP requests go to external services, require user approval
}
}
#[cfg(test)]
mod tests {
use super::validate_url;
#[test]
fn test_validate_url_rejects_http() {
let err = validate_url("http://example.com").unwrap_err();
assert!(err.to_string().contains("https"));
}
#[test]
fn test_validate_url_rejects_localhost() {
let err = validate_url("https://localhost:8080").unwrap_err();
assert!(err.to_string().contains("localhost"));
}
#[test]
fn test_validate_url_accepts_https_public() {
let url = validate_url("https://example.com").unwrap();
assert_eq!(url.host_str(), Some("example.com"));
}
}
+31 -16
View File
@@ -56,7 +56,7 @@ impl Tool for CreateJobTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -72,7 +72,11 @@ impl Tool for CreateJobTool {
ToolError::InvalidParameters("missing 'description' parameter".into())
})?;
match self.context_manager.create_job(title, description).await {
match self
.context_manager
.create_job_for_user(&ctx.user_id, title, description)
.await
{
Ok(job_id) => {
let result = serde_json::json!({
"job_id": job_id.to_string(),
@@ -133,7 +137,7 @@ impl Tool for ListJobsTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -143,8 +147,8 @@ impl Tool for ListJobsTool {
.unwrap_or("all");
let job_ids = match filter {
"active" => self.context_manager.active_jobs().await,
_ => self.context_manager.all_jobs().await,
"active" => self.context_manager.active_jobs_for(&ctx.user_id).await,
_ => self.context_manager.all_jobs_for(&ctx.user_id).await,
};
let mut jobs = Vec::new();
@@ -168,7 +172,7 @@ impl Tool for ListJobsTool {
}
}
let summary = self.context_manager.summary().await;
let summary = self.context_manager.summary_for(&ctx.user_id).await;
let result = serde_json::json!({
"jobs": jobs,
@@ -226,9 +230,10 @@ impl Tool for JobStatusTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone();
let job_id_str = params
.get("job_id")
@@ -240,16 +245,22 @@ impl Tool for JobStatusTool {
})?;
match self.context_manager.get_context(job_id).await {
Ok(ctx) => {
Ok(job_ctx) => {
if job_ctx.user_id != requester_id {
let result = serde_json::json!({
"error": "Job not found".to_string()
});
return Ok(ToolOutput::success(result, start.elapsed()));
}
let result = serde_json::json!({
"job_id": job_id.to_string(),
"title": ctx.title,
"description": ctx.description,
"status": format!("{:?}", ctx.state),
"created_at": ctx.created_at.to_rfc3339(),
"started_at": ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": ctx.completed_at.map(|t| t.to_rfc3339()),
"actual_cost": ctx.actual_cost.to_string()
"title": job_ctx.title,
"description": job_ctx.description,
"status": format!("{:?}", job_ctx.state),
"created_at": job_ctx.created_at.to_rfc3339(),
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()),
"actual_cost": job_ctx.actual_cost.to_string()
});
Ok(ToolOutput::success(result, start.elapsed()))
}
@@ -304,9 +315,10 @@ impl Tool for CancelJobTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone();
let job_id_str = params
.get("job_id")
@@ -321,6 +333,9 @@ impl Tool for CancelJobTool {
match self
.context_manager
.update_context(job_id, |ctx| {
if ctx.user_id != requester_id {
return Err("Job not found".to_string());
}
ctx.transition_to(JobState::Cancelled, Some("Cancelled by user".to_string()))
})
.await