klieo-a2a 3.4.0

Durable A2A v1.0 protocol layer atop klieo-bus traits.
Documentation
//! SSRF guard for caller-supplied push-notification callback URLs.
//!
//! `CreateTaskPushNotificationConfig` (A2A spec §9.4.7) lets an
//! authenticated caller register a URL the server will later POST task
//! updates to. Because the URL — and therefore the outbound request's
//! destination — is fully caller-controlled, an unvalidated config is a
//! textbook Server-Side Request Forgery primitive (CWE-918): a caller
//! could point the server at `http://127.0.0.1:<internal-port>/`,
//! `http://169.254.169.254/latest/meta-data/` (cloud instance metadata),
//! or any other address the server itself can reach but the caller
//! cannot.
//!
//! [`validate_callback_url`] rejects the syntactically-detectable cases
//! at config-create time: non-`https` schemes and IP-literal hosts in
//! loopback/private/link-local/unspecified/multicast ranges.
//!
//! # Known gap — this does not fully close SSRF by itself
//!
//! A hostname (as opposed to an IP literal) is accepted here without a
//! DNS lookup, so `https://evil.example/` passes this check even if
//! `evil.example` resolves to `127.0.0.1` (DNS rebinding). This module
//! only ever sees the config at *create* time; `klieo-a2a` does not
//! itself perform the later outbound POST — that is implemented by
//! whatever [`crate::handler::A2aHandler::create_task_push_notification_config`]
//! override the integrator wires in. Full SSRF closure requires that
//! integrator to re-validate (or pin) the resolved IP at the actual
//! connect time, since DNS can change between config-create and
//! send. This guard is defense-in-depth against the obvious literal-IP
//! case, not a substitute for a resolving egress guard at the real send
//! path.

use crate::error::A2aError;
use std::net::{Ipv4Addr, Ipv6Addr};
use url::{Host, Url};

/// Only scheme accepted for a push-notification callback URL. Plain
/// `http` is never allowed — unlike the loopback-only exception some
/// klieo egress paths grant for local development, a caller-supplied
/// callback URL has no legitimate loopback use case (the server would
/// be POSTing to itself) and there is no local-dev affordance to
/// preserve here.
const REQUIRED_SCHEME: &str = "https";

/// IPv6 unique-local prefix (RFC 4193, `fc00::/7`) — not covered by
/// [`Ipv6Addr::is_loopback`] / [`Ipv6Addr::is_unspecified`].
const IPV6_UNIQUE_LOCAL_MASK: u16 = 0xfe00;
const IPV6_UNIQUE_LOCAL_PREFIX: u16 = 0xfc00;

/// IPv6 link-local prefix (`fe80::/10`).
const IPV6_LINK_LOCAL_MASK: u16 = 0xffc0;
const IPV6_LINK_LOCAL_PREFIX: u16 = 0xfe80;

/// Hostnames rejected outright regardless of DNS, since they are
/// well-known local aliases rather than routable public names.
const DISALLOWED_HOSTNAMES: [&str; 2] = ["localhost", "localhost.localdomain"];

/// Validate a caller-supplied push-notification callback URL before it
/// is persisted or handed to an [`crate::handler::A2aHandler`]
/// override. Returns [`A2aError::InvalidParams`] (JSON-RPC -32602) on
/// rejection — this is a client-input validation failure, not a server
/// fault.
pub fn validate_callback_url(raw: &str) -> Result<(), A2aError> {
    let parsed = Url::parse(raw).map_err(|e| {
        A2aError::InvalidParams(format!("invalid push-notification callback URL: {e}"))
    })?;
    if parsed.scheme() != REQUIRED_SCHEME {
        return Err(A2aError::InvalidParams(format!(
            "push-notification callback URL must use {REQUIRED_SCHEME}; got scheme `{}`",
            parsed.scheme()
        )));
    }
    // `Url::host()` is used rather than `host_str()` because the latter
    // returns IPv6 literals WITH their `[...]` brackets (e.g. `"[::1]"`),
    // which fails `str::parse::<IpAddr>()` and would silently fall
    // through to the hostname check — letting every bracketed IPv6
    // literal bypass the loopback/private/link-local guard entirely.
    // `host()` returns a typed `Host<&str>` with the address already
    // parsed, so there is no string round-trip to get wrong.
    let host = parsed.host().ok_or_else(|| {
        A2aError::InvalidParams("push-notification callback URL has no host".into())
    })?;
    if is_disallowed_host(&host) {
        return Err(A2aError::InvalidParams(format!(
            "push-notification callback URL host `{}` is not allowed \
             (loopback/private/link-local/reserved ranges are blocked)",
            parsed.host_str().unwrap_or_default(),
        )));
    }
    Ok(())
}

fn is_disallowed_host(host: &Host<&str>) -> bool {
    match host {
        Host::Domain(name) => DISALLOWED_HOSTNAMES.contains(&name.to_ascii_lowercase().as_str()),
        Host::Ipv4(v4) => is_disallowed_ipv4(v4),
        Host::Ipv6(v6) => is_disallowed_ipv6(v6),
    }
}

fn is_disallowed_ipv4(v4: &Ipv4Addr) -> bool {
    v4.is_loopback()
        || v4.is_private()
        || v4.is_link_local()
        || v4.is_unspecified()
        || v4.is_broadcast()
        || v4.is_documentation()
        || v4.is_multicast()
}

fn is_disallowed_ipv6(v6: &Ipv6Addr) -> bool {
    if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() {
        return true;
    }
    if let Some(mapped) = v6.to_ipv4_mapped() {
        return is_disallowed_ipv4(&mapped);
    }
    let first_segment = v6.segments()[0];
    let is_unique_local = (first_segment & IPV6_UNIQUE_LOCAL_MASK) == IPV6_UNIQUE_LOCAL_PREFIX;
    let is_link_local = (first_segment & IPV6_LINK_LOCAL_MASK) == IPV6_LINK_LOCAL_PREFIX;
    is_unique_local || is_link_local
}

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

    #[test]
    fn https_public_host_is_allowed() {
        validate_callback_url("https://example.test/hook").expect("public https URL must pass");
    }

    #[test]
    fn plain_http_is_rejected() {
        let err = validate_callback_url("http://example.test/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn loopback_hostname_is_rejected() {
        let err = validate_callback_url("https://localhost/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn loopback_ipv4_literal_is_rejected() {
        let err = validate_callback_url("https://127.0.0.1/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn loopback_ipv6_literal_is_rejected() {
        let err = validate_callback_url("https://[::1]/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn private_ipv4_ranges_are_rejected() {
        for host in ["10.0.0.1", "172.16.0.1", "192.168.1.1"] {
            let url = format!("https://{host}/hook");
            let err = validate_callback_url(&url).unwrap_err();
            assert!(matches!(err, A2aError::InvalidParams(_)), "{host}");
        }
    }

    #[test]
    fn link_local_metadata_endpoint_is_rejected() {
        // Cloud-provider instance metadata endpoint (AWS/GCP/Azure all
        // use the 169.254.0.0/16 link-local range for it).
        let err = validate_callback_url("https://169.254.169.254/latest/meta-data/").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn ipv4_mapped_ipv6_loopback_is_rejected() {
        let err = validate_callback_url("https://[::ffff:127.0.0.1]/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn ipv6_unique_local_is_rejected() {
        let err = validate_callback_url("https://[fc00::1]/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn ipv6_link_local_is_rejected() {
        let err = validate_callback_url("https://[fe80::1]/hook").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn public_ipv4_literal_is_allowed() {
        validate_callback_url("https://93.184.216.34/hook").expect("public IP must pass");
    }

    #[test]
    fn malformed_url_is_rejected() {
        let err = validate_callback_url("not a url").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }

    #[test]
    fn url_with_empty_host_is_rejected() {
        // `https:` is a WHATWG "special" scheme that requires a
        // non-empty host — `Url::parse` itself fails closed here
        // rather than returning `Ok` with an empty host.
        let err = validate_callback_url("https://").unwrap_err();
        assert!(matches!(err, A2aError::InvalidParams(_)));
    }
}