mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
Addressing vareity of security issues
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user