Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: src/cli/mod.rs
assertion_line: 438
assertion_line: 461
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
Expand Down Expand Up @@ -51,7 +51,7 @@ Options:

--auto-approve
Auto-approve tool execution (shell, file writes, HTTP, etc.)

Skips interactive approval prompts for standard tools. Destructive operations still require explicit approval. Other safeguards remain active: rate limits, hooks, authentication gates.

-h, --help
Expand Down
28 changes: 27 additions & 1 deletion src/config/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,14 @@ pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), Confi

// For HTTPS, reject private/loopback/link-local/metadata IPs.
// Check both IP literals and resolved hostnames to prevent DNS-based SSRF.
if let Ok(ip) = host.parse::<IpAddr>() {
//
// `Url::host_str()` returns IPv6 literals WITH the surrounding brackets
// (e.g. "[::1]"), but `IpAddr::parse` does not accept brackets — it wants
// bare "::1". Strip them so we recognize IPv6 literals before falling
// through to the DNS-resolution branch (which on some systems with DNS
// hijacking can produce a non-private IP and bypass this check).
let host_for_parse = host.trim_matches(|c| c == '[' || c == ']');
if let Ok(ip) = host_for_parse.parse::<IpAddr>() {
if is_dangerous_ip(&ip) {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
Expand Down Expand Up @@ -601,8 +608,27 @@ mod tests {
assert!(validate_base_url("https://[::]", "TEST").is_err());
}

/// Some local DNS resolvers (ISP/router-level captive portals, ad-injecting
/// providers) hijack lookups for non-existent domains and return a public
/// IP instead of NXDOMAIN. On those networks, RFC 6761 ".invalid" lookups
/// succeed even though they shouldn't, which makes any test that asserts
/// "DNS resolution failure" unreliable. Detect that case and skip the test.
fn invalid_tld_resolves_locally() -> bool {
use std::net::ToSocketAddrs;
("ironclaw-dns-hijack-probe.invalid", 443u16)
.to_socket_addrs()
.is_ok()
}

#[test]
fn validate_base_url_rejects_dns_failure() {
if invalid_tld_resolves_locally() {
eprintln!(
"skipping validate_base_url_rejects_dns_failure: \
local DNS resolver hijacks .invalid lookups"
);
return;
}
// .invalid TLD is guaranteed to never resolve (RFC 6761)
let result = validate_base_url("https://ssrf-test.invalid", "TEST");
assert!(result.is_err());
Expand Down
21 changes: 21 additions & 0 deletions src/setup/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,27 @@ mod tests {

#[tokio::test]
async fn test_validate_public_https_url_fails_closed_on_dns_error() {
// Some local DNS resolvers (ISP/router captive portals, ad-injecting
// providers) hijack lookups for non-existent domains and return a
// public IP instead of NXDOMAIN. On those networks, RFC 6761
// ".invalid" lookups succeed and this test cannot run. Detect and skip.
//
// Use the async resolver with a short timeout so the probe never
// blocks the tokio runtime: a flaky/slow upstream DNS server would
// otherwise stall the whole test suite.
let probe = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::lookup_host(("ironclaw-dns-hijack-probe.invalid", 443u16)),
)
.await;
let hijacked = matches!(probe, Ok(Ok(_)));
if hijacked {
eprintln!(
"skipping test_validate_public_https_url_fails_closed_on_dns_error: \
local DNS resolver hijacks .invalid lookups"
);
return;
}
let err = validate_public_https_url("https://should-not-resolve.invalid/api")
.await
.unwrap_err()
Expand Down
11 changes: 10 additions & 1 deletion tests/module_init_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,18 @@ async fn extension_manager_with_process_manager_constructs() {
);

// Verify the manager is functional — list returns Ok.
//
// We do NOT assert the result is empty: with `store: None`,
// ExtensionManager.list() falls back to file-based load_mcp_servers()
// which reads ~/.ironclaw/mcp-servers.json. Asserting empty would leak
// the developer's local MCP-server configuration into the test, and
// overriding IRONCLAW_BASE_DIR from an integration test is unsafe (it
// would mutate process-wide env state in a binary that has no access
// to the crate-private ENV_MUTEX). The only thing this test is meant
// to verify is that the constructor wires up correctly and list() can
// be called without erroring — which is exactly what is_ok() checks.
let result = manager.list(None, false, "test").await;
assert!(result.is_ok(), "list should succeed on empty manager");
assert!(result.unwrap().is_empty());
}

// ---------------------------------------------------------------------------
Expand Down
Loading