fraiseql-functions 2.16.0

Serverless functions runtime for FraiseQL — WASM and Deno backends
Documentation
use super::*;

#[test]
fn test_is_domain_allowed_with_wildcard() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    assert!(is_domain_allowed("example.com", &config.allowed_domains));
    assert!(is_domain_allowed("any.domain.anywhere", &config.allowed_domains));
}

#[test]
fn test_is_domain_allowed_exact_match() {
    let config = HttpClientConfig {
        allowed_domains: vec!["example.com".to_string(), "safe.io".to_string()],
        ..Default::default()
    };
    assert!(is_domain_allowed("example.com", &config.allowed_domains));
    assert!(is_domain_allowed("safe.io", &config.allowed_domains));
    assert!(!is_domain_allowed("other.com", &config.allowed_domains));
}

#[test]
fn test_is_domain_allowed_glob_pattern() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*.example.com".to_string()],
        ..Default::default()
    };
    assert!(is_domain_allowed("api.example.com", &config.allowed_domains));
    assert!(is_domain_allowed("sub.api.example.com", &config.allowed_domains));
    assert!(!is_domain_allowed("example.com", &config.allowed_domains));
    assert!(!is_domain_allowed("other.com", &config.allowed_domains));
}

#[tokio::test]
async fn test_validate_outbound_url_valid() {
    let config = HttpClientConfig {
        allowed_domains: vec!["example.com".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("https://example.com/api", &config).await.is_ok());
}

#[tokio::test]
async fn test_validate_outbound_url_invalid_domain() {
    let config = HttpClientConfig {
        allowed_domains: vec!["example.com".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("https://other.com/api", &config).await.is_err());
}

#[tokio::test]
async fn test_validate_outbound_url_blocks_private_ip() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("http://127.0.0.1/api", &config).await.is_err());
    assert!(validate_outbound_url("http://192.168.1.1/api", &config).await.is_err());
    assert!(validate_outbound_url("http://10.0.0.1/api", &config).await.is_err());
}

#[tokio::test]
async fn test_validate_outbound_url_blocks_ipv6_loopback() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("http://[::1]/api", &config).await.is_err());
}

#[tokio::test]
async fn test_validate_outbound_url_blocks_ipv6_link_local() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("http://[fe80::1]/api", &config).await.is_err());
}

#[tokio::test]
async fn test_validate_outbound_url_allows_public_ip() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    assert!(validate_outbound_url("http://8.8.8.8/api", &config).await.is_ok());
}

#[tokio::test]
async fn test_validate_outbound_url_invalid_url() {
    let config = HttpClientConfig::default();
    assert!(validate_outbound_url("not a valid url", &config).await.is_err());
}

// ── M-fn-ssrf: deny-by-default + DNS-rebinding + redirect policy ───────────

#[test]
fn test_default_allowlist_is_deny_by_default() {
    // The default allowlist is empty: no host is permitted.
    let config = HttpClientConfig::default();
    assert!(config.allowed_domains.is_empty());
    assert!(!is_domain_allowed("example.com", &config.allowed_domains));
    assert!(!is_domain_allowed("8.8.8.8", &config.allowed_domains));
}

#[tokio::test]
async fn test_deny_by_default_rejects_unlisted_domain() {
    // With the default (empty) allowlist, an otherwise-public domain is rejected.
    let config = HttpClientConfig::default();
    let result = validate_outbound_url("https://example.com/api", &config).await;
    assert!(
        matches!(result, Err(FraiseQLError::Authorization { .. })),
        "deny-by-default must reject an unlisted domain, got: {result:?}"
    );
}

#[tokio::test]
async fn test_domain_resolving_to_private_ip_is_rejected() {
    // `localhost` is allowlisted so the request clears the allowlist gate, but it
    // resolves to a loopback address — the DNS-rebinding guard must reject it.
    let config = HttpClientConfig {
        allowed_domains: vec!["localhost".to_string()],
        ..Default::default()
    };
    let result = validate_outbound_url("http://localhost/api", &config).await;
    assert!(
        result.is_err(),
        "a domain resolving to a private/loopback IP must be rejected: {result:?}"
    );
}

#[tokio::test]
async fn test_built_client_disables_redirects() {
    use wiremock::{
        Mock, MockServer, ResponseTemplate,
        matchers::{method, path},
    };

    // The client built for outbound host functions must not follow redirects:
    // a 3xx could otherwise bounce to an un-validated internal target. With
    // `Policy::none()` the 302 is returned verbatim instead of being followed.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/start"))
        .respond_with(
            ResponseTemplate::new(302).insert_header("location", "http://169.254.169.254/"),
        )
        .mount(&server)
        .await;

    let client = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .connect_timeout(std::time::Duration::from_secs(5))
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .unwrap();

    let resp = client.get(format!("{}/start", server.uri())).send().await.unwrap();
    assert_eq!(
        resp.status().as_u16(),
        302,
        "client must surface the 302 instead of following the redirect"
    );
}

// =============================================================================
// #802 — the IPv6 arm accepts every IPv4-mapped form and the unspecified address
//
// Vectors verbatim from the issue's Proof 1. `Ipv6Addr::is_loopback` is true only
// for `::1`, so a mapped address such as `::ffff:169.254.169.254` matches none of
// the three predicates the arm tests and is accepted — and a dual-stack socket
// really does reach the corresponding IPv4 host (issue Proof 3).
// =============================================================================

#[tokio::test]
async fn blocks_ipv4_mapped_and_unspecified_v6() {
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    for s in [
        "::ffff:127.0.0.1",
        "::ffff:169.254.169.254",
        "::ffff:a9fe:a9fe",
        "::ffff:10.0.0.1",
        "::ffff:192.168.1.1",
        "::ffff:0.0.0.0",
        "::",
    ] {
        let url = format!("http://[{s}]/");
        assert!(validate_outbound_url(&url, &config).await.is_err(), "must block {s}");
    }
}

#[tokio::test]
async fn blocks_mapped_metadata_literal_through_the_url_path() {
    // The full literal-host route the guest reaches via `fraiseql_http_request`:
    // Url::parse -> host_str -> the shared resolve-and-check. `"*"` is a
    // supported allowlist value, and in that configuration this guard is the only
    // remaining control.
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    for url in [
        "http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/",
        "http://[::ffff:127.0.0.1]:5432/",
        "http://[::]/",
    ] {
        assert!(validate_outbound_url(url, &config).await.is_err(), "must block {url}");
    }
}

// ── The shared outbound corpus, at this crate's entry point ───────────────────
//
// `"*"` is the allowlist setting an operator must reach for when function code
// calls tenant-supplied URLs. In that configuration this guard is the entire
// remaining control, which is what made #802 exploitable.

#[tokio::test]
async fn refuses_every_blocked_corpus_entry() {
    use fraiseql_guard::net::vectors::{MUST_BLOCK, MUST_BLOCK_HOSTS, url_host};
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    for (addr, why) in MUST_BLOCK {
        let url = format!("http://{}/latest/meta-data/", url_host(addr));
        assert!(
            validate_outbound_url(&url, &config).await.is_err(),
            "must refuse {addr} ({why})"
        );
    }
    for (host, why) in MUST_BLOCK_HOSTS {
        let url = format!("http://{host}/");
        assert!(
            validate_outbound_url(&url, &config).await.is_err(),
            "must refuse {host} ({why})"
        );
    }
}

#[tokio::test]
async fn permits_every_allowed_corpus_entry() {
    use fraiseql_guard::net::vectors::{MUST_ALLOW, url_host};
    // At the URL entry point: an IP literal is checked without a DNS lookup, so this
    // needs no network.
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    for addr in MUST_ALLOW {
        let url = format!("http://{}/", url_host(addr));
        assert!(validate_outbound_url(&url, &config).await.is_ok(), "must permit {addr}");
    }
}

/// The hostname half of that counterweight, at the real entry point (#1280).
///
/// Every row of `MUST_ALLOW` is an IP literal, so without this test the guard could
/// refuse every hostname and still pass the whole corpus.
///
/// It cannot assert `is_ok()`: `validate_outbound_url` resolves a non-literal
/// host, and a unit test must not depend on the network. It asserts the property
/// that does not need one — the refusal, if any, must not be the HOST rule.
/// `blocked_host_reason` runs before the lookup and reports `Authorization`,
/// while a DNS failure reports `Validation`, so the two are distinguishable and a
/// sandbox with no resolver still exercises the branch under test.
#[tokio::test]
async fn permits_every_allowed_hostname_at_the_url_entry_point() {
    use fraiseql_guard::net::vectors::MUST_ALLOW_HOSTS;
    let config = HttpClientConfig {
        allowed_domains: vec!["*".to_string()],
        ..Default::default()
    };
    for host in MUST_ALLOW_HOSTS {
        let url = format!("http://{host}/");
        let outcome = validate_outbound_url(&url, &config).await;
        // `Validation` is the DNS branch and is not this test's subject; the host
        // rule reports `Authorization`, and so does the allowlist, which is why the
        // config above opens it to `*`.
        let refused_on_a_host_rule = matches!(outcome, Err(FraiseQLError::Authorization { .. }));
        assert!(!refused_on_a_host_rule, "must not refuse {host} on a host rule: {outcome:?}");
    }
}