Skip to main content

swink_agent_eval/
url_filter.rs

1//! URL filtering helpers for remote eval assets.
2
3use std::net::IpAddr;
4
5use url::{Host, Url};
6
7/// Policy for deciding whether a remote URL is safe to fetch.
8pub trait UrlFilter: Send + Sync {
9    /// Returns `true` when the URL is allowed to be fetched.
10    fn allows(&self, url: &Url) -> bool;
11}
12
13/// Default SSRF-oriented filter for remote eval assets.
14///
15/// This filter blocks loopback, RFC1918/private, link-local, unspecified, and
16/// known cloud metadata endpoints. Public hostnames remain allowed here; later
17/// attachment materialization layers can additionally require HTTPS.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct DefaultUrlFilter;
20
21impl UrlFilter for DefaultUrlFilter {
22    fn allows(&self, url: &Url) -> bool {
23        let Some(host) = url.host() else {
24            return false;
25        };
26
27        match host {
28            Host::Ipv4(address) => is_public_ip(IpAddr::V4(address)),
29            Host::Ipv6(address) => is_public_ip(IpAddr::V6(address)),
30            Host::Domain(host) => !is_blocked_hostname(host),
31        }
32    }
33}
34
35fn is_public_ip(address: IpAddr) -> bool {
36    match address {
37        IpAddr::V4(address) => {
38            !(address.is_loopback()
39                || address.is_private()
40                || address.is_link_local()
41                || address.is_broadcast()
42                || address.is_unspecified()
43                || is_azure_metadata_ipv4(address))
44        }
45        IpAddr::V6(address) => {
46            !(address.is_loopback()
47                || address.is_unspecified()
48                || address.is_unique_local()
49                || address.is_unicast_link_local())
50        }
51    }
52}
53
54fn is_blocked_hostname(host: &str) -> bool {
55    let host = host.trim_end_matches('.').to_ascii_lowercase();
56
57    host == "localhost"
58        || host.ends_with(".localhost")
59        || matches!(
60            host.as_str(),
61            "metadata.google.internal" | "instance-data.ec2.internal"
62        )
63}
64
65fn is_azure_metadata_ipv4(address: std::net::Ipv4Addr) -> bool {
66    address.octets() == [169, 254, 169, 254]
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn allows_public_hostnames() {
75        let filter = DefaultUrlFilter;
76        assert!(filter.allows(&Url::parse("https://example.com/image.png").unwrap()));
77        assert!(filter.allows(&Url::parse("http://example.com/image.png").unwrap()));
78    }
79
80    #[test]
81    fn blocks_loopback_and_private_ip_literals() {
82        let filter = DefaultUrlFilter;
83
84        assert!(!filter.allows(&Url::parse("https://127.0.0.1/test.png").unwrap()));
85        assert!(!filter.allows(&Url::parse("https://10.0.0.5/test.png").unwrap()));
86        assert!(!filter.allows(&Url::parse("https://192.168.1.20/test.png").unwrap()));
87        assert!(!filter.allows(&Url::parse("https://[::1]/test.png").unwrap()));
88    }
89
90    #[test]
91    fn blocks_known_metadata_hosts() {
92        let filter = DefaultUrlFilter;
93
94        assert!(!filter.allows(&Url::parse("https://169.254.169.254/latest/meta-data").unwrap()));
95        assert!(
96            !filter.allows(
97                &Url::parse("https://metadata.google.internal/computeMetadata/v1").unwrap()
98            )
99        );
100        assert!(
101            !filter.allows(
102                &Url::parse("https://instance-data.ec2.internal/latest/meta-data").unwrap()
103            )
104        );
105        assert!(!filter.allows(&Url::parse("https://localhost/test.png").unwrap()));
106    }
107}