mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
merge: Resolve conflicts with main, add user-scoped DB methods
Merge main's security hardening (user-scoped job/conversation access, cargo-dist config, CI improvements) into turso branch. Add Database trait methods for user-scoped operations: - list_sandbox_jobs_for_user - sandbox_job_summary_for_user - sandbox_job_belongs_to_user - conversation_belongs_to_user Implemented in both postgres and libsql backends. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -595,7 +595,7 @@ Create alongside the .wasm file to grant capabilities:
|
||||
AgentToolError::BuilderFailed(format!("LLM response failed: {}", e))
|
||||
})?;
|
||||
|
||||
match result {
|
||||
match result.result {
|
||||
RespondResult::Text(response) => {
|
||||
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
||||
|
||||
|
||||
+138
-36
@@ -36,7 +36,7 @@ fn is_workspace_path(path: &str) -> bool {
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or(path);
|
||||
|
||||
WORKSPACE_FILES.iter().any(|ws| *ws == filename)
|
||||
WORKSPACE_FILES.contains(&filename)
|
||||
|| path.starts_with("daily/")
|
||||
|| path.starts_with("context/")
|
||||
}
|
||||
@@ -50,65 +50,90 @@ const MAX_WRITE_SIZE: usize = 5 * 1024 * 1024;
|
||||
/// Maximum directory listing entries.
|
||||
const MAX_DIR_ENTRIES: usize = 500;
|
||||
|
||||
/// Validate that a path is safe (no traversal attacks).
|
||||
fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result<PathBuf, ToolError> {
|
||||
let path = PathBuf::from(path_str);
|
||||
|
||||
// Reject paths with suspicious components (validation only, no action needed)
|
||||
/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
|
||||
///
|
||||
/// This is critical for security: `std::fs::canonicalize` only works on paths that exist,
|
||||
/// so for new files we must normalize without touching the filesystem.
|
||||
fn normalize_lexical(path: &Path) -> PathBuf {
|
||||
let mut components = Vec::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::ParentDir => {
|
||||
// Allow .. but validate final path is within sandbox
|
||||
}
|
||||
std::path::Component::Normal(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
if s.starts_with('.') && s != "." && s != ".." && !s.starts_with(".git") {
|
||||
// Hidden files are OK for .git, .gitignore, etc.
|
||||
// Only pop if there's a normal component to pop (don't escape root/prefix)
|
||||
if components
|
||||
.last()
|
||||
.is_some_and(|c| matches!(c, std::path::Component::Normal(_)))
|
||||
{
|
||||
components.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
std::path::Component::CurDir => {}
|
||||
other => components.push(other),
|
||||
}
|
||||
}
|
||||
components.iter().collect()
|
||||
}
|
||||
|
||||
/// Validate that a path is safe (no traversal attacks).
|
||||
///
|
||||
/// For sandboxed paths (base_dir is set), we normalize the joined path lexically
|
||||
/// and then verify it lives under the canonical base. This prevents escapes through
|
||||
/// non-existent parent directories where `canonicalize()` would fall back to the
|
||||
/// raw (un-normalized) path.
|
||||
fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result<PathBuf, ToolError> {
|
||||
let path = PathBuf::from(path_str);
|
||||
|
||||
// Resolve to absolute path
|
||||
let resolved = if path.is_absolute() {
|
||||
path.canonicalize().unwrap_or_else(|_| path.clone())
|
||||
path.canonicalize()
|
||||
.unwrap_or_else(|_| normalize_lexical(&path))
|
||||
} else if let Some(base) = base_dir {
|
||||
base.join(&path)
|
||||
let joined = base.join(&path);
|
||||
joined
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| base.join(&path))
|
||||
.unwrap_or_else(|_| normalize_lexical(&joined))
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
let joined = std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(&path)
|
||||
.join(&path);
|
||||
normalize_lexical(&joined)
|
||||
};
|
||||
|
||||
// If base_dir is set, ensure path is within it
|
||||
// If base_dir is set, ensure the resolved path is within it
|
||||
if let Some(base) = base_dir {
|
||||
// Canonicalize the base to handle symlinks (e.g., /var -> /private/var on macOS)
|
||||
let base_canonical = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
|
||||
let base_canonical = base
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| normalize_lexical(base));
|
||||
|
||||
// For files that don't exist yet, we need to check the parent directory
|
||||
// and ensure the resolved path would be within the base
|
||||
// For existing paths, canonicalize to resolve symlinks.
|
||||
// For non-existent paths, the lexical normalization above already removed
|
||||
// all `..` components, so starts_with is reliable.
|
||||
let check_path = if resolved.exists() {
|
||||
resolved.canonicalize().unwrap_or_else(|_| resolved.clone())
|
||||
} else {
|
||||
// For non-existent files, canonicalize the parent and append the filename
|
||||
if let Some(parent) = resolved.parent() {
|
||||
if parent.exists() {
|
||||
let canonical_parent = parent
|
||||
// Walk up to the nearest existing ancestor directory, canonicalize it,
|
||||
// then re-append the remaining tail. This handles the case where a
|
||||
// symlink sits above the new file.
|
||||
let mut ancestor = resolved.as_path();
|
||||
let mut tail_parts: Vec<&std::ffi::OsStr> = Vec::new();
|
||||
loop {
|
||||
if ancestor.exists() {
|
||||
let canonical_ancestor = ancestor
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| parent.to_path_buf());
|
||||
if let Some(filename) = resolved.file_name() {
|
||||
canonical_parent.join(filename)
|
||||
} else {
|
||||
resolved.clone()
|
||||
.unwrap_or_else(|_| ancestor.to_path_buf());
|
||||
let mut result = canonical_ancestor;
|
||||
for part in tail_parts.into_iter().rev() {
|
||||
result = result.join(part);
|
||||
}
|
||||
} else {
|
||||
resolved.clone()
|
||||
break result;
|
||||
}
|
||||
if let Some(name) = ancestor.file_name() {
|
||||
tail_parts.push(name);
|
||||
}
|
||||
match ancestor.parent() {
|
||||
Some(parent) if parent != ancestor => ancestor = parent,
|
||||
_ => break resolved.clone(),
|
||||
}
|
||||
} else {
|
||||
resolved.clone()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -871,4 +896,81 @@ mod tests {
|
||||
let entries = result.result.get("entries").unwrap().as_array().unwrap();
|
||||
assert!(entries.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_lexical() {
|
||||
// Basic .. resolution
|
||||
assert_eq!(
|
||||
normalize_lexical(Path::new("/a/b/../c")),
|
||||
PathBuf::from("/a/c")
|
||||
);
|
||||
// Multiple .. components
|
||||
assert_eq!(
|
||||
normalize_lexical(Path::new("/a/b/c/../../d")),
|
||||
PathBuf::from("/a/d")
|
||||
);
|
||||
// . components stripped
|
||||
assert_eq!(
|
||||
normalize_lexical(Path::new("/a/./b/./c")),
|
||||
PathBuf::from("/a/b/c")
|
||||
);
|
||||
// Cannot escape root
|
||||
assert_eq!(
|
||||
normalize_lexical(Path::new("/a/../../..")),
|
||||
PathBuf::from("/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_rejects_traversal_nonexistent_parent() {
|
||||
// The critical test: writing to ../../outside/newdir/file with base_dir
|
||||
// set should be rejected even when the parent directory does not exist
|
||||
// (i.e. canonicalize() cannot resolve it).
|
||||
let dir = TempDir::new().unwrap();
|
||||
let evil_path = format!(
|
||||
"{}/../../outside/newdir/file.txt",
|
||||
dir.path().to_str().unwrap()
|
||||
);
|
||||
let result = validate_path(&evil_path, Some(dir.path()));
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should reject traversal via non-existent parent, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_rejects_relative_traversal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let result = validate_path("../../etc/passwd", Some(dir.path()));
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should reject relative traversal, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_allows_valid_nested_write() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let result = validate_path("subdir/newfile.txt", Some(dir.path()));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should allow nested writes within sandbox: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_allows_dot_dot_within_sandbox() {
|
||||
// a/b/../c resolves to a/c which is still inside the sandbox
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
|
||||
let result = validate_path("a/b/../c.txt", Some(dir.path()));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should allow .. that stays within sandbox: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! HTTP request tool.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -11,6 +11,9 @@ use crate::context::JobContext;
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
|
||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Tool for making HTTP requests.
|
||||
pub struct HttpTool {
|
||||
client: Client,
|
||||
@@ -21,6 +24,7 @@ impl HttpTool {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
@@ -49,6 +53,7 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
||||
));
|
||||
}
|
||||
|
||||
// Check literal IP addresses
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_disallowed_ip(&ip) {
|
||||
return Err(ToolError::NotAuthorized(
|
||||
@@ -57,6 +62,22 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve hostname and check all resolved IPs against the blocklist.
|
||||
// This prevents DNS rebinding where a hostname resolves to a private IP.
|
||||
let port = parsed.port_or_known_default().unwrap_or(443);
|
||||
let socket_addr = format!("{}:{}", host, port);
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
if is_disallowed_ip(&addr.ip()) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"hostname '{}' resolves to disallowed IP {}",
|
||||
host,
|
||||
addr.ip()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
@@ -202,17 +223,36 @@ impl Tool for HttpTool {
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
// Block redirects: the server tried to send us elsewhere (potential SSRF)
|
||||
if (300..400).contains(&status) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
let headers: HashMap<String, String> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
|
||||
.collect();
|
||||
|
||||
// Get response body
|
||||
let body_text = response.text().await.map_err(|e| {
|
||||
// Get response body with size cap to prevent OOM
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
ToolError::ExternalService(format!("failed to read response body: {}", e))
|
||||
})?;
|
||||
|
||||
if body_bytes.len() > MAX_RESPONSE_SIZE {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Response body too large ({} bytes, max {})",
|
||||
body_bytes.len(),
|
||||
MAX_RESPONSE_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
|
||||
|
||||
// Try to parse as JSON, fall back to string
|
||||
let body: serde_json::Value = serde_json::from_str(&body_text)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(body_text.clone()));
|
||||
@@ -241,7 +281,7 @@ impl Tool for HttpTool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_url;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_url_rejects_http() {
|
||||
@@ -260,4 +300,40 @@ mod tests {
|
||||
let url = validate_url("https://example.com").unwrap();
|
||||
assert_eq!(url.host_str(), Some("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_url_rejects_private_ip_literal() {
|
||||
let err = validate_url("https://192.168.1.1/api").unwrap_err();
|
||||
assert!(err.to_string().contains("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_url_rejects_loopback_ip() {
|
||||
let err = validate_url("https://127.0.0.1/api").unwrap_err();
|
||||
assert!(err.to_string().contains("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_url_rejects_link_local() {
|
||||
let err = validate_url("https://169.254.169.254/latest/meta-data/").unwrap_err();
|
||||
assert!(err.to_string().contains("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disallowed_ip_covers_ranges() {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
// Private ranges
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
|
||||
// Loopback
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
|
||||
// Cloud metadata
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
169, 254, 169, 254
|
||||
))));
|
||||
// Public
|
||||
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::workspace::{Workspace, paths};
|
||||
|
||||
/// Identity files that the LLM must not overwrite via tool calls.
|
||||
/// These are loaded into the system prompt and could be used for prompt
|
||||
/// injection if an attacker tricks the agent into overwriting them.
|
||||
const PROTECTED_IDENTITY_FILES: &[&str] =
|
||||
&[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];
|
||||
|
||||
/// Tool for searching workspace memory.
|
||||
///
|
||||
/// Performs hybrid search (FTS + semantic) across all memory documents.
|
||||
@@ -188,6 +194,16 @@ impl Tool for MemoryWriteTool {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("daily_log");
|
||||
|
||||
// Reject writes to identity files that are loaded into the system prompt.
|
||||
// An attacker could use prompt injection to trick the agent into overwriting
|
||||
// these, poisoning future conversations.
|
||||
if PROTECTED_IDENTITY_FILES.contains(&target) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"writing to '{}' is not allowed (identity file protected from tool writes)",
|
||||
target,
|
||||
)));
|
||||
}
|
||||
|
||||
let append = params
|
||||
.get("append")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -230,6 +246,20 @@ impl Tool for MemoryWriteTool {
|
||||
paths::HEARTBEAT.to_string()
|
||||
}
|
||||
path => {
|
||||
// Protect identity files from LLM overwrites (prompt injection defense).
|
||||
// These files are injected into the system prompt, so poisoning them
|
||||
// would let an attacker rewrite the agent's core instructions.
|
||||
let normalized = path.trim_start_matches('/');
|
||||
if PROTECTED_IDENTITY_FILES
|
||||
.iter()
|
||||
.any(|p| normalized.eq_ignore_ascii_case(p))
|
||||
{
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"writing to '{}' is not allowed (identity file protected from tool access)",
|
||||
path
|
||||
)));
|
||||
}
|
||||
|
||||
if append {
|
||||
self.workspace
|
||||
.append(path, content)
|
||||
|
||||
@@ -11,7 +11,7 @@ mod marketplace;
|
||||
mod memory;
|
||||
mod restaurant;
|
||||
pub mod routine;
|
||||
mod shell;
|
||||
pub(crate) mod shell;
|
||||
mod taskrabbit;
|
||||
mod time;
|
||||
|
||||
|
||||
+83
-14
@@ -74,6 +74,58 @@ static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
]
|
||||
});
|
||||
|
||||
/// Patterns that should NEVER be auto-approved, even if the user chose "always approve"
|
||||
/// for the shell tool. These require explicit per-invocation approval because they are
|
||||
/// destructive or security-sensitive.
|
||||
static NEVER_AUTO_APPROVE_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
vec![
|
||||
"rm -rf",
|
||||
"rm -fr",
|
||||
"chmod -r 777",
|
||||
"chmod 777",
|
||||
"chown -r",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"init 0",
|
||||
"init 6",
|
||||
"iptables",
|
||||
"nft ",
|
||||
"useradd",
|
||||
"userdel",
|
||||
"passwd",
|
||||
"visudo",
|
||||
"crontab",
|
||||
"systemctl disable",
|
||||
"launchctl unload",
|
||||
"kill -9",
|
||||
"killall",
|
||||
"pkill",
|
||||
"docker rm",
|
||||
"docker rmi",
|
||||
"docker system prune",
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git clean -f",
|
||||
"DROP TABLE",
|
||||
"DROP DATABASE",
|
||||
"TRUNCATE",
|
||||
"DELETE FROM",
|
||||
]
|
||||
});
|
||||
|
||||
/// Check whether a shell command contains patterns that must never be auto-approved.
|
||||
///
|
||||
/// Even when the user has chosen "always approve" for the shell tool, these commands
|
||||
/// require explicit per-invocation approval because they are destructive.
|
||||
pub fn requires_explicit_approval(command: &str) -> bool {
|
||||
let lower = command.to_lowercase();
|
||||
NEVER_AUTO_APPROVE_PATTERNS
|
||||
.iter()
|
||||
.any(|p| lower.contains(&p.to_lowercase()))
|
||||
}
|
||||
|
||||
/// Shell command execution tool.
|
||||
pub struct ShellTool {
|
||||
/// Working directory for commands (if None, uses job's working dir or cwd).
|
||||
@@ -289,23 +341,17 @@ impl ShellTool {
|
||||
// Determine timeout
|
||||
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
|
||||
|
||||
// Try sandbox execution if available
|
||||
// Use sandbox if configured; fail-closed (never silently fall through
|
||||
// to unsandboxed execution when sandbox was intended).
|
||||
if let Some(ref sandbox) = self.sandbox {
|
||||
if sandbox.is_initialized() || sandbox.config().enabled {
|
||||
match self
|
||||
return self
|
||||
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
||||
.await
|
||||
{
|
||||
Ok((output, code)) => return Ok((output, code)),
|
||||
Err(e) => {
|
||||
// Log sandbox failure and fall through to direct execution
|
||||
tracing::warn!("Sandbox execution failed, falling back to direct: {}", e);
|
||||
}
|
||||
}
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct execution
|
||||
// Only execute directly when no sandbox was configured at all.
|
||||
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
|
||||
Ok((output, code as i64))
|
||||
}
|
||||
@@ -392,17 +438,19 @@ impl Tool for ShellTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate output to fit within limits.
|
||||
/// Truncate output to fit within limits (UTF-8 safe).
|
||||
fn truncate_output(s: &str) -> String {
|
||||
if s.len() <= MAX_OUTPUT_SIZE {
|
||||
s.to_string()
|
||||
} else {
|
||||
let half = MAX_OUTPUT_SIZE / 2;
|
||||
let head_end = crate::util::floor_char_boundary(s, half);
|
||||
let tail_start = crate::util::floor_char_boundary(s, s.len() - half);
|
||||
format!(
|
||||
"{}\n\n... [truncated {} bytes] ...\n\n{}",
|
||||
&s[..half],
|
||||
&s[..head_end],
|
||||
s.len() - MAX_OUTPUT_SIZE,
|
||||
&s[s.len() - half..]
|
||||
&s[tail_start..]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -458,6 +506,27 @@ mod tests {
|
||||
assert!(matches!(result, Err(ToolError::Timeout(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_explicit_approval() {
|
||||
// Destructive commands should require explicit approval
|
||||
assert!(requires_explicit_approval("rm -rf /tmp/stuff"));
|
||||
assert!(requires_explicit_approval("git push --force origin main"));
|
||||
assert!(requires_explicit_approval("git reset --hard HEAD~5"));
|
||||
assert!(requires_explicit_approval("docker rm container_name"));
|
||||
assert!(requires_explicit_approval("kill -9 12345"));
|
||||
assert!(requires_explicit_approval("DROP TABLE users;"));
|
||||
|
||||
// Safe commands should not
|
||||
assert!(!requires_explicit_approval("cargo build"));
|
||||
assert!(!requires_explicit_approval("git status"));
|
||||
assert!(!requires_explicit_approval("ls -la"));
|
||||
assert!(!requires_explicit_approval("echo hello"));
|
||||
assert!(!requires_explicit_approval("cat file.txt"));
|
||||
assert!(!requires_explicit_approval(
|
||||
"git push origin feature-branch"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_policy_builder() {
|
||||
let tool = ShellTool::new()
|
||||
|
||||
@@ -365,12 +365,7 @@ pub async fn save_mcp_servers_to_db(
|
||||
store
|
||||
.set_setting(user_id, "mcp_servers", &value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+112
-3
@@ -25,9 +25,46 @@ use crate::tools::wasm::{
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Names of built-in tools that cannot be shadowed by dynamic registrations.
|
||||
/// This prevents a dynamically built or installed tool from replacing a
|
||||
/// security-critical built-in like "shell" or "memory_write".
|
||||
const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
"echo",
|
||||
"time",
|
||||
"json",
|
||||
"http",
|
||||
"shell",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"list_dir",
|
||||
"apply_patch",
|
||||
"memory_search",
|
||||
"memory_write",
|
||||
"memory_read",
|
||||
"memory_tree",
|
||||
"create_job",
|
||||
"list_jobs",
|
||||
"job_status",
|
||||
"cancel_job",
|
||||
"build_software",
|
||||
"tool_search",
|
||||
"tool_install",
|
||||
"tool_auth",
|
||||
"tool_activate",
|
||||
"tool_list",
|
||||
"tool_remove",
|
||||
"routine_create",
|
||||
"routine_list",
|
||||
"routine_update",
|
||||
"routine_delete",
|
||||
"routine_history",
|
||||
];
|
||||
|
||||
/// Registry of available tools.
|
||||
pub struct ToolRegistry {
|
||||
tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
|
||||
/// Tracks which names were registered as built-in (protected from shadowing).
|
||||
builtin_names: RwLock<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
@@ -35,21 +72,35 @@ impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tools: RwLock::new(HashMap::new()),
|
||||
builtin_names: RwLock::new(std::collections::HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a tool.
|
||||
/// Register a tool. Rejects dynamic tools that try to shadow a built-in name.
|
||||
pub async fn register(&self, tool: Arc<dyn Tool>) {
|
||||
let name = tool.name().to_string();
|
||||
if self.builtin_names.read().await.contains(&name) {
|
||||
tracing::warn!(
|
||||
tool = %name,
|
||||
"Rejected tool registration: would shadow a built-in tool"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.tools.write().await.insert(name.clone(), tool);
|
||||
tracing::debug!("Registered tool: {}", name);
|
||||
}
|
||||
|
||||
/// Register a tool (sync version for startup).
|
||||
/// Register a tool (sync version for startup, marks as built-in).
|
||||
pub fn register_sync(&self, tool: Arc<dyn Tool>) {
|
||||
let name = tool.name().to_string();
|
||||
if let Ok(mut tools) = self.tools.try_write() {
|
||||
tools.insert(name.clone(), tool);
|
||||
// Mark as built-in so it can't be shadowed later
|
||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
|
||||
if let Ok(mut builtins) = self.builtin_names.try_write() {
|
||||
builtins.insert(name.clone());
|
||||
}
|
||||
}
|
||||
tracing::debug!("Registered tool: {}", name);
|
||||
}
|
||||
}
|
||||
@@ -419,10 +470,18 @@ impl Default for ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ToolRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ToolRegistry")
|
||||
.field("count", &self.count())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::tool::EchoTool;
|
||||
use crate::tools::registry::EchoTool;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_and_get() {
|
||||
@@ -452,4 +511,54 @@ mod tests {
|
||||
assert_eq!(defs.len(), 1);
|
||||
assert_eq!(defs[0].name, "echo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builtin_tool_cannot_be_shadowed() {
|
||||
let registry = ToolRegistry::new();
|
||||
// Register echo as built-in (uses register_sync which marks protected names)
|
||||
registry.register_sync(Arc::new(EchoTool));
|
||||
assert!(registry.has("echo").await);
|
||||
|
||||
let original_desc = registry
|
||||
.get("echo")
|
||||
.await
|
||||
.unwrap()
|
||||
.description()
|
||||
.to_string();
|
||||
|
||||
// Create a fake tool that tries to shadow "echo"
|
||||
struct FakeEcho;
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for FakeEcho {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"EVIL SHADOW"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &crate::context::JobContext,
|
||||
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
// Try to shadow via register() (dynamic path)
|
||||
registry.register(Arc::new(FakeEcho)).await;
|
||||
|
||||
// The original should still be there
|
||||
let desc = registry
|
||||
.get("echo")
|
||||
.await
|
||||
.unwrap()
|
||||
.description()
|
||||
.to_string();
|
||||
assert_eq!(desc, original_desc);
|
||||
assert_ne!(desc, "EVIL SHADOW");
|
||||
}
|
||||
}
|
||||
|
||||
+47
-47
@@ -199,57 +199,57 @@ pub trait Tool: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple no-op tool for testing.
|
||||
#[derive(Debug)]
|
||||
pub struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Echoes back the input message. Useful for testing."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to echo back"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Echo is a trusted internal tool
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A simple no-op tool for testing.
|
||||
#[derive(Debug)]
|
||||
pub struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Echoes back the input message. Useful for testing."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to echo back"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Echo is a trusted internal tool
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_echo_tool() {
|
||||
let tool = EchoTool;
|
||||
|
||||
@@ -182,6 +182,17 @@ fn parse_url(url: &str) -> Result<ParsedUrl, String> {
|
||||
return Err(format!("Unsupported scheme: {}", scheme));
|
||||
}
|
||||
|
||||
// Reject URLs with userinfo (user:pass@host) to prevent allowlist bypass.
|
||||
// A URL like https://[email protected]/ would match the allowlist
|
||||
// for api.openai.com but actually send traffic to evil.com.
|
||||
let authority = match rest.find('/') {
|
||||
Some(idx) => &rest[..idx],
|
||||
None => rest,
|
||||
};
|
||||
if authority.contains('@') {
|
||||
return Err("URL contains userinfo (@) which is not allowed".to_string());
|
||||
}
|
||||
|
||||
// Split host from path
|
||||
let (host_and_port, path) = match rest.find('/') {
|
||||
Some(idx) => (&rest[..idx], &rest[idx..]),
|
||||
@@ -207,6 +218,14 @@ fn parse_url(url: &str) -> Result<ParsedUrl, String> {
|
||||
None => host_and_port,
|
||||
};
|
||||
|
||||
// Reject URLs with userinfo (user:pass@host).
|
||||
// A URL like https://[email protected]/ confuses the parser into
|
||||
// seeing "api.openai.com" as the host, but reqwest actually sends to
|
||||
// "evil.com". Block any '@' in the authority section to prevent this.
|
||||
if host.contains('@') || host_and_port.contains('@') {
|
||||
return Err("URL contains userinfo (@) which is not allowed".to_string());
|
||||
}
|
||||
|
||||
// Validate host
|
||||
if host.is_empty() {
|
||||
return Err("Empty host".to_string());
|
||||
@@ -332,6 +351,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_userinfo_rejected() {
|
||||
let validator = validator_with_patterns();
|
||||
|
||||
// Userinfo in URL should be rejected to prevent allowlist bypass
|
||||
let result = validator.validate("https://[email protected]/v1/chat", "GET");
|
||||
assert!(!result.is_allowed());
|
||||
|
||||
if let super::AllowlistResult::Denied(reason) = result {
|
||||
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
|
||||
} else {
|
||||
panic!("Expected denied for userinfo URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_url() {
|
||||
let validator = validator_with_patterns();
|
||||
@@ -354,4 +388,28 @@ mod tests {
|
||||
let result = validator.validate("http://localhost:8080/api", "GET");
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_url_with_userinfo() {
|
||||
let validator = validator_with_patterns();
|
||||
|
||||
// Attacker uses userinfo to trick the parser: the allowlist sees
|
||||
// "api.openai.com" but reqwest would actually connect to "evil.com".
|
||||
let result = validator.validate("https://[email protected]/v1/steal", "GET");
|
||||
assert!(!result.is_allowed());
|
||||
|
||||
if let super::AllowlistResult::Denied(reason) = result {
|
||||
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
|
||||
} else {
|
||||
panic!("Expected denied due to userinfo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_url_with_user_pass() {
|
||||
let validator = validator_with_patterns();
|
||||
|
||||
let result = validator.validate("https://user:[email protected]/v1/chat", "GET");
|
||||
assert!(!result.is_allowed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ use wasmtime::{Config, Engine, OptLevel};
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
|
||||
|
||||
/// Default epoch tick interval. Each tick increments the engine's epoch counter,
|
||||
/// which causes any store with an expired epoch deadline to trap.
|
||||
pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Configuration for the WASM runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmRuntimeConfig {
|
||||
@@ -123,6 +127,25 @@ impl WasmToolRuntime {
|
||||
WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e))
|
||||
})?;
|
||||
|
||||
// Spawn a background thread that periodically increments the engine's
|
||||
// epoch counter. Without this, epoch_deadline_trap() never fires and
|
||||
// WASM modules can spin indefinitely even with a deadline set.
|
||||
let ticker_engine = engine.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("wasm-epoch-ticker".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(EPOCH_TICK_INTERVAL);
|
||||
ticker_engine.increment_epoch();
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
WasmError::EngineCreationFailed(format!(
|
||||
"Failed to spawn epoch ticker thread: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
engine,
|
||||
config,
|
||||
|
||||
+190
-6
@@ -23,7 +23,7 @@ use crate::tools::wasm::capabilities::Capabilities;
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
use crate::tools::wasm::host::{HostState, LogLevel};
|
||||
use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter};
|
||||
use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime};
|
||||
use crate::tools::wasm::runtime::{EPOCH_TICK_INTERVAL, PreparedModule, WasmToolRuntime};
|
||||
|
||||
// Generate component model bindings from the WIT file.
|
||||
//
|
||||
@@ -194,10 +194,25 @@ impl near::agent::host::Host for StoreData {
|
||||
.scan_http_request(&url, &header_vec, body.as_deref())
|
||||
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
||||
|
||||
// Get the max response size from capabilities (default 10MB).
|
||||
let max_response_bytes = self
|
||||
.host_state
|
||||
.capabilities()
|
||||
.http
|
||||
.as_ref()
|
||||
.map(|h| h.max_response_bytes)
|
||||
.unwrap_or(10 * 1024 * 1024);
|
||||
|
||||
// Resolve hostname and reject private/internal IPs to prevent DNS rebinding.
|
||||
reject_private_ip(&url)?;
|
||||
|
||||
// Make HTTP request using blocking I/O.
|
||||
// We're inside a spawn_blocking context, so use block_on.
|
||||
let result = tokio::runtime::Handle::current().block_on(async {
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| format!("failed to create HTTP client: {e}"))?;
|
||||
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
@@ -241,11 +256,31 @@ impl near::agent::host::Host for StoreData {
|
||||
})
|
||||
.collect();
|
||||
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
|
||||
|
||||
// Check Content-Length header for early rejection of oversized responses.
|
||||
let max_response = max_response_bytes;
|
||||
if let Some(cl) = response.content_length() {
|
||||
if cl as usize > max_response {
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Read body with a size cap to prevent memory exhaustion.
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?
|
||||
.to_vec();
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
if body.len() > max_response {
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
body.len(),
|
||||
max_response
|
||||
));
|
||||
}
|
||||
let body = body.to_vec();
|
||||
|
||||
// Leak detection on response body
|
||||
if let Ok(body_str) = std::str::from_utf8(&body) {
|
||||
@@ -380,9 +415,13 @@ impl WasmToolWrapper {
|
||||
.map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
|
||||
}
|
||||
|
||||
// Configure epoch deadline for timeout backup
|
||||
// Configure epoch deadline as a hard timeout backup.
|
||||
// The epoch ticker thread increments the engine epoch every EPOCH_TICK_INTERVAL.
|
||||
// Setting deadline to N means "trap after N ticks", so we compute the number
|
||||
// of ticks that fit in the tool's timeout. Minimum 1 to always have a backstop.
|
||||
store.epoch_deadline_trap();
|
||||
store.set_epoch_deadline(1);
|
||||
let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
|
||||
store.set_epoch_deadline(ticks);
|
||||
|
||||
// Set up resource limiter
|
||||
store.limiter(|data| &mut data.limiter);
|
||||
@@ -531,6 +570,88 @@ impl std::fmt::Debug for WasmToolWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the URL's hostname and reject connections to private/internal IP addresses.
|
||||
/// This prevents DNS rebinding attacks where an attacker's domain resolves to an
|
||||
/// internal IP after passing the allowlist check.
|
||||
fn reject_private_ip(url: &str) -> Result<(), String> {
|
||||
let host = url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|rest| {
|
||||
let host_and_port = rest.split('/').next().unwrap_or(rest);
|
||||
// Strip port
|
||||
if host_and_port.starts_with('[') {
|
||||
// IPv6
|
||||
host_and_port.find(']').map(|i| &host_and_port[1..i])
|
||||
} else {
|
||||
Some(
|
||||
host_and_port
|
||||
.rfind(':')
|
||||
.map_or(host_and_port, |i| &host_and_port[..i]),
|
||||
)
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "Failed to parse host from URL".to_string())?;
|
||||
|
||||
// If the host is already an IP, check it directly
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return if is_private_ip(ip) {
|
||||
Err(format!(
|
||||
"HTTP request to private/internal IP {} is not allowed",
|
||||
ip
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve DNS and check all addresses
|
||||
use std::net::ToSocketAddrs;
|
||||
// Port 0 is a placeholder; ToSocketAddrs needs host:port but the port
|
||||
// doesn't affect which IPs the hostname resolves to.
|
||||
let addrs: Vec<_> = format!("{}:0", host)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| format!("DNS resolution failed for {}: {}", host, e))?
|
||||
.collect();
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(format!("DNS resolution returned no addresses for {}", host));
|
||||
}
|
||||
|
||||
for addr in &addrs {
|
||||
if is_private_ip(addr.ip()) {
|
||||
return Err(format!(
|
||||
"DNS rebinding detected: {} resolved to private IP {}",
|
||||
host,
|
||||
addr.ip()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an IP address belongs to a private/internal range.
|
||||
fn is_private_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_loopback() // 127.0.0.0/8
|
||||
|| v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
|| v4.is_link_local() // 169.254.0.0/16
|
||||
|| v4.is_unspecified() // 0.0.0.0
|
||||
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.is_loopback() // ::1
|
||||
|| v6.is_unspecified() // ::
|
||||
// fc00::/7 (unique local)
|
||||
|| (v6.segments()[0] & 0xFE00) == 0xFC00
|
||||
// fe80::/10 (link-local)
|
||||
|| (v6.segments()[0] & 0xFFC0) == 0xFE80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -557,4 +678,67 @@ mod tests {
|
||||
assert!(caps.tool_invoke.is_none());
|
||||
assert!(caps.secrets.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_private_ip_v4() {
|
||||
use std::net::IpAddr;
|
||||
// Private ranges
|
||||
assert!(super::is_private_ip("127.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(super::is_private_ip("10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(super::is_private_ip(
|
||||
"172.16.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(super::is_private_ip(
|
||||
"192.168.1.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(super::is_private_ip(
|
||||
"169.254.1.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(super::is_private_ip("0.0.0.0".parse::<IpAddr>().unwrap()));
|
||||
// CGNAT
|
||||
assert!(super::is_private_ip(
|
||||
"100.64.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
|
||||
// Public IPs
|
||||
assert!(!super::is_private_ip("8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
assert!(!super::is_private_ip("1.1.1.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(!super::is_private_ip(
|
||||
"93.184.216.34".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_private_ip_v6() {
|
||||
use std::net::IpAddr;
|
||||
assert!(super::is_private_ip("::1".parse::<IpAddr>().unwrap()));
|
||||
assert!(super::is_private_ip("::".parse::<IpAddr>().unwrap()));
|
||||
assert!(super::is_private_ip("fc00::1".parse::<IpAddr>().unwrap()));
|
||||
assert!(super::is_private_ip("fe80::1".parse::<IpAddr>().unwrap()));
|
||||
|
||||
// Public
|
||||
assert!(!super::is_private_ip(
|
||||
"2606:4700::1111".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_private_ip_loopback() {
|
||||
let result = super::reject_private_ip("https://127.0.0.1:8080/api");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("private/internal IP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_private_ip_internal() {
|
||||
let result = super::reject_private_ip("https://192.168.1.1/admin");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_private_ip_public_ok() {
|
||||
// 8.8.8.8 (Google DNS) is public
|
||||
let result = super::reject_private_ip("https://8.8.8.8/dns-query");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user