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
1 change: 1 addition & 0 deletions llm-access/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ static KIRO_REMOTE_MEDIA_CLIENT: std::sync::LazyLock<reqwest::Client> =
reqwest::Client::builder()
.timeout(KIRO_REMOTE_MEDIA_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.dns_resolver(Arc::new(kiro_media::PrivateFilteringDnsResolver))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable inherited proxies for the guarded fetcher

When llm-access runs with HTTP_PROXY/HTTPS_PROXY set, reqwest will still use the automatic system proxy here because this builder never calls no_proxy(). In that configuration this resolver is applied to the proxy host, not the user-supplied media host, so an internal proxy hostname can be rejected outright; and if the proxy is usable, the target hostname is resolved by the proxy/CONNECT path while the old preflight lookup remains the only SSRF check. For the remote-media security boundary to hold, this client should force direct connections (or otherwise validate the exact target address the proxy will dial).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally declining the forced no_proxy(). If an operator sets HTTP(S)_PROXY the fetcher must honor it — forcing direct connections would break egress-proxy deployments. And in CONNECT mode the client never learns the IP the proxy dials, so connect-time target pinning is fundamentally impossible there: the proxy is the egress boundary by design. Protections that still hold in proxy mode: the static literal-IP gate in validate_kiro_remote_media_url (rejects literal private/loopback targets with no resolution at all) plus the preflight resolution check. The DNS-rebinding resolver fix targets the default direct-connection path, which is this service's actual egress mode.

.pool_idle_timeout(provider_client_pool_idle_timeout())
.pool_max_idle_per_host(provider_client_pool_max_idle_per_host())
.tcp_keepalive(Duration::from_secs(30))
Expand Down
206 changes: 188 additions & 18 deletions llm-access/src/provider/kiro_media.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Kiro remote-media (image/document) resolution, validation, and fetch.

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};

use async_trait::async_trait;
use base64::Engine as _;
Expand Down Expand Up @@ -459,32 +459,47 @@ fn validate_kiro_remote_media_url(
))
},
}
let host = url
.host_str()
.ok_or_else(|| KiroRemoteMediaResolutionError::new("URL source is missing host"))?;
if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
return Err(KiroRemoteMediaResolutionError::new("URL source host must not be localhost"));
}
if let Ok(ip) = host.parse::<IpAddr>() {
reject_private_kiro_remote_media_ip(ip)?;
// Match the parsed host enum, NOT `host_str()`: the latter returns IPv6
// literals bracketed (`[::1]`), which `parse::<IpAddr>()` rejects, letting a
// literal-IP URL skip the private-address gate entirely (reqwest dials the
// literal without ever calling the filtering resolver).
match url
.host()
.ok_or_else(|| KiroRemoteMediaResolutionError::new("URL source is missing host"))?
{
url::Host::Domain(domain) => {
if domain.eq_ignore_ascii_case("localhost") || domain.ends_with(".localhost") {
return Err(KiroRemoteMediaResolutionError::new(
"URL source host must not be localhost",
));
}
},
url::Host::Ipv4(ip) => reject_private_kiro_remote_media_ip(IpAddr::V4(ip))?,
url::Host::Ipv6(ip) => reject_private_kiro_remote_media_ip(IpAddr::V6(ip))?,
}
Ok(url)
}
async fn validate_kiro_remote_media_resolved_addresses(
url: &url::Url,
) -> Result<(), KiroRemoteMediaResolutionError> {
let host = url
.host_str()
.ok_or_else(|| KiroRemoteMediaResolutionError::new("URL source is missing host"))?;
if host.parse::<IpAddr>().is_ok() {
return Ok(());
}
// Literal IPs are already vetted by `validate_kiro_remote_media_url` and are
// dialed directly (no resolver); only domains need a pre-flight lookup. Use
// the host enum so bracketed IPv6 literals aren't misread as domains.
let domain = match url
.host()
.ok_or_else(|| KiroRemoteMediaResolutionError::new("URL source is missing host"))?
{
url::Host::Domain(domain) => domain.to_string(),
url::Host::Ipv4(_) | url::Host::Ipv6(_) => return Ok(()),
};
let port = url
.port_or_known_default()
.ok_or_else(|| KiroRemoteMediaResolutionError::new("URL source is missing port"))?;
let addresses = tokio::net::lookup_host((host, port)).await.map_err(|err| {
KiroRemoteMediaResolutionError::new(format!("failed to resolve URL source host: {err}"))
})?;
let addresses = tokio::net::lookup_host((domain.as_str(), port))
.await
.map_err(|err| {
KiroRemoteMediaResolutionError::new(format!("failed to resolve URL source host: {err}"))
})?;
let mut resolved_any = false;
for address in addresses {
resolved_any = true;
Expand Down Expand Up @@ -521,9 +536,164 @@ fn is_private_kiro_remote_media_ipv4(ip: Ipv4Addr) -> bool {
|| ip == Ipv4Addr::UNSPECIFIED
}
fn is_private_kiro_remote_media_ipv6(ip: Ipv6Addr) -> bool {
// IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`) addresses
// are dialed as their embedded IPv4 by dual-stack sockets, so an attacker
// could smuggle a private IPv4 target (e.g. `::ffff:127.0.0.1`) past the
// native-IPv6 checks below. Re-check the embedded IPv4 against the v4 rules.
if let Some(mapped) = ip.to_ipv4() {
if is_private_kiro_remote_media_ipv4(mapped) {
return true;
}
}
ip.is_loopback()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.is_unspecified()
|| matches!(ip.segments(), [0x2001, 0x0db8, _, _, _, _, _, _])
}

/// Drops private/local addresses from a resolved set, rejecting the lookup if
/// none remain. Used by [`PrivateFilteringDnsResolver`] so the address that is
/// vetted is exactly the address reqwest dials.
fn filter_public_kiro_remote_media_addrs(
host: &str,
addrs: Vec<SocketAddr>,
) -> Result<Vec<SocketAddr>, String> {
let public = addrs
.into_iter()
.filter(|addr| !is_private_kiro_remote_media_ip(addr.ip()))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject IPv4-mapped private IPv6 addresses

A hostile DNS server can still pass the new resolver by returning an IPv4-mapped IPv6 address such as ::ffff:127.0.0.1 or ::ffff:169.254.169.254 on the connect-time lookup. This filter treats those as ordinary IPv6 addresses, but Rust does not classify ::ffff:127.0.0.1 itself as loopback unless it is first converted to its mapped IPv4 address, so the resolver can hand reqwest a private endpoint after the preflight lookup answered with a public IP. Convert IPv4-mapped/compatible IPv6 addresses to IPv4 before applying the private/local checks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e182cc4 — the embedded IPv4 of mapped/compatible IPv6 (::ffff:a.b.c.d / ::a.b.c.d) is now re-checked against the v4 private rules in is_private_kiro_remote_media_ipv6 (Ipv6Addr::to_ipv4()), so a hostile resolver returning ::ffff:127.0.0.1 is dropped. Covered by filter_public_addrs_rejects_ipv4_mapped_private.

.collect::<Vec<_>>();
Comment on lines +562 to +565
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Security Vulnerability: SSRF Bypass via IPv4-Mapped IPv6 Addresses

The current implementation of filter_public_kiro_remote_media_addrs filters addresses using is_private_kiro_remote_media_ip(addr.ip()). However, if a hostname resolves to an IPv4-mapped IPv6 address (e.g., ::ffff:127.0.0.1 or ::ffff:10.1.2.3), is_private_kiro_remote_media_ipv6 will not recognize it as a private or loopback address because it only checks standard IPv6 loopback (::1) and unique local/link-local prefixes.

Since dual-stack sockets automatically translate IPv4-mapped IPv6 addresses to IPv4 connections at the OS layer, an attacker can bypass the SSRF protection entirely by returning IPv4-mapped IPv6 addresses pointing to private IPv4 targets.

Recommendation

Extract the mapped IPv4 address using .to_ipv4() if the address is IPv6, and validate it against is_private_kiro_remote_media_ipv4.

Additionally, to prevent DNS Rebinding SSRF (TOCTOU vulnerabilities), ensure this filtering is performed within a custom connection-level resolver on the HTTP client (or by resolving once and pinning the IP directly) rather than performing DNS resolution once for validation and a second time for the actual HTTP request.

    let public = addrs
        .into_iter()
        .filter(|addr| {
            let ip = addr.ip();
            if is_private_kiro_remote_media_ip(ip) {
                return false;
            }
            if let IpAddr::V6(ipv6) = ip {
                if let Some(ipv4) = ipv6.to_ipv4() {
                    if is_private_kiro_remote_media_ipv4(ipv4) {
                        return false;
                    }
                }
            }
            true
        })
        .collect::<Vec<_>>();
References
  1. To prevent DNS Rebinding SSRF (TOCTOU vulnerabilities) when validating and fetching external URLs, do not perform DNS resolution once for validation and a second time for the actual HTTP request. Instead, configure a custom connection-level resolver on the HTTP client (e.g., reqwest::Client) to validate and filter out private/local IP addresses at socket connection time, or resolve the IP once and request the pinned IP directly while preserving the Host header and SNI for TLS.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e182cc4. is_private_kiro_remote_media_ipv6 now re-checks the embedded IPv4 via Ipv6Addr::to_ipv4() against the v4 private rules, so ::ffff:127.0.0.1 / ::ffff:10.x / ::ffff:169.254.169.254 are rejected by both the literal gate (validate_kiro_remote_media_url) and the resolving filter (filter_public_kiro_remote_media_addrs). Tests: filter_public_addrs_rejects_ipv4_mapped_private + filter_public_addrs_keeps_ipv4_mapped_public.

if public.is_empty() {
return Err(format!(
"URL source host `{host}` resolved only to private or local addresses"
));
}
Ok(public)
}

/// Custom reqwest DNS resolver for the Kiro remote-media client that filters
/// out private/local addresses at resolution time.
///
/// This is the actual SSRF guard against DNS rebinding: reqwest dials the
/// addresses this resolver returns, so the IP that is checked is the IP that is
/// connected to — closing the time-of-check/time-of-use gap that a separate
/// pre-flight `lookup_host` check cannot (reqwest performs its own resolution
/// at connect time, which a hostile resolver can answer differently).
/// IP-literal URLs (never sent to a resolver) remain covered by the literal-IP
/// gate in [`validate_kiro_remote_media_url`].
pub(super) struct PrivateFilteringDnsResolver;

impl reqwest::dns::Resolve for PrivateFilteringDnsResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let host = name.as_str().to_string();
Box::pin(async move {
let resolved = tokio::net::lookup_host((host.as_str(), 0u16))
.await
.map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)?
.collect::<Vec<SocketAddr>>();
let public = filter_public_kiro_remote_media_addrs(&host, resolved)
.map_err(Box::<dyn std::error::Error + Send + Sync>::from)?;
Ok(Box::new(public.into_iter()) as reqwest::dns::Addrs)
})
}
}

#[cfg(test)]
mod tests {
use super::*;

fn sa(addr: &str) -> SocketAddr {
format!("{addr}:443").parse().expect("valid socket addr")
}

#[test]
fn filter_public_addrs_keeps_public_drops_private() {
let out = filter_public_kiro_remote_media_addrs("example.test", vec![
sa("8.8.8.8"),
sa("10.0.0.5"),
sa("1.1.1.1"),
sa("127.0.0.1"),
])
.expect("public addresses remain");
assert_eq!(out, vec![sa("8.8.8.8"), sa("1.1.1.1")]);
}

#[test]
fn filter_public_addrs_rejects_all_private() {
let err = filter_public_kiro_remote_media_addrs("rebind.test", vec![
sa("127.0.0.1"),
sa("169.254.169.254"),
sa("10.1.2.3"),
])
.expect_err("an all-private resolution must be rejected");
assert!(err.contains("private or local"), "unexpected message: {err}");
}
Comment on lines +621 to +630
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a unit test to verify that IPv4-mapped IPv6 addresses pointing to private IPv4 addresses are correctly rejected by the filtering resolver.

    #[test]
    fn filter_public_addrs_rejects_all_private() {
        let err = filter_public_kiro_remote_media_addrs("rebind.test", vec![
            sa("127.0.0.1"),
            sa("169.254.169.254"),
            sa("10.1.2.3"),
        ])
        .expect_err("an all-private resolution must be rejected");
        assert!(err.contains("private or local"), "unexpected message: {err}");
    }

    #[test]
    fn filter_public_addrs_rejects_ipv4_mapped_ipv6() {
        let err = filter_public_kiro_remote_media_addrs("rebind.test", vec![
            sa("[::ffff:127.0.0.1]"),
            sa("[::ffff:10.1.2.3]"),
        ])
        .expect_err("IPv4-mapped private addresses must be rejected");
        assert!(err.contains("private or local"), "unexpected message: {err}");
    }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e182cc4. filter_public_addrs_rejects_ipv4_mapped_private rejects ::ffff:127.0.0.1, ::ffff:10.1.2.3, ::ffff:169.254.169.254; filter_public_addrs_keeps_ipv4_mapped_public confirms ::ffff:8.8.8.8 survives while ::ffff:10.0.0.1 is dropped. Also added validate_url_rejects_literal_loopback_variants (covers [::1], [::ffff:127.0.0.1], 127.0.0.1, decimal-form 2130706433) and validate_url_allows_public_literal_ipv6.


#[test]
fn filter_public_addrs_rejects_empty() {
let err = filter_public_kiro_remote_media_addrs("empty.test", Vec::new())
.expect_err("an empty resolution must be rejected");
assert!(err.contains("private or local"), "unexpected message: {err}");
}

#[tokio::test]
async fn resolver_rejects_loopback_hostname() {
use std::str::FromStr;

use reqwest::dns::Resolve;
// `localhost` resolves to loopback (RFC 6761), so the resolver must drop
// every address and error — proving rebinding to loopback is blocked.
let name = reqwest::dns::Name::from_str("localhost").expect("valid dns name");
let result = PrivateFilteringDnsResolver.resolve(name).await;
assert!(result.is_err(), "localhost must be rejected by the filtering resolver");
}

#[test]
fn filter_public_addrs_rejects_ipv4_mapped_private() {
// Dual-stack sockets dial `::ffff:a.b.c.d` as the embedded IPv4, so a
// hostile resolver could smuggle a private IPv4 target as IPv4-mapped
// IPv6. The embedded address must be re-checked against the v4 rules.
let err = filter_public_kiro_remote_media_addrs("rebind.test", vec![
sa("[::ffff:127.0.0.1]"),
sa("[::ffff:10.1.2.3]"),
sa("[::ffff:169.254.169.254]"),
])
.expect_err("IPv4-mapped private addresses must be rejected");
assert!(err.contains("private or local"), "unexpected message: {err}");
}

#[test]
fn filter_public_addrs_keeps_ipv4_mapped_public() {
let out = filter_public_kiro_remote_media_addrs("example.test", vec![
sa("[::ffff:8.8.8.8]"),
sa("[::ffff:10.0.0.1]"),
])
.expect("the mapped-public address remains");
assert_eq!(out, vec![sa("[::ffff:8.8.8.8]")]);
}

#[test]
fn validate_url_rejects_literal_loopback_variants() {
// host_str() brackets IPv6 literals, so a `parse::<IpAddr>()` gate would
// skip them; matching url.host() must reject these (incl. IPv4-mapped and
// url-normalized decimal IPv4).
for raw in [
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://[::ffff:10.0.0.1]/",
"http://127.0.0.1/",
"http://2130706433/",
] {
let err = validate_kiro_remote_media_url(raw)
.err()
.unwrap_or_else(|| panic!("{raw} must be rejected"));
assert!(err.to_string().contains("private or local"), "raw={raw} msg={err}");
}
}

#[test]
fn validate_url_allows_public_literal_ipv6() {
validate_kiro_remote_media_url("http://[2606:4700:4700::1111]/")
.expect("public literal IPv6 must be allowed");
}
}
Loading