a3s-boot 0.1.3

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
//! Strict URL validation for server-selected iLink endpoints.

use std::collections::HashSet;
use std::fmt;

use thiserror::Error;
use url::{Host, Url};

#[derive(Clone)]
pub(super) struct IlinkHostPolicy {
    allowed_hosts: HashSet<String>,
    allowed_host_suffixes: HashSet<String>,
    allow_insecure_loopback: bool,
}

impl IlinkHostPolicy {
    pub(super) fn production(
        allowed_hosts: impl IntoIterator<Item = impl AsRef<str>>,
    ) -> Result<Self, IlinkUrlError> {
        let mut normalized = HashSet::new();
        for host in allowed_hosts {
            let host = normalize_allowed_host(host.as_ref())?;
            normalized.insert(host);
        }
        if normalized.is_empty() {
            return Err(IlinkUrlError::EmptyHostPolicy);
        }
        Ok(Self {
            allowed_hosts: normalized,
            allowed_host_suffixes: HashSet::new(),
            allow_insecure_loopback: false,
        })
    }

    pub(super) fn production_with_suffixes(
        allowed_hosts: impl IntoIterator<Item = impl AsRef<str>>,
        allowed_host_suffixes: impl IntoIterator<Item = impl AsRef<str>>,
    ) -> Result<Self, IlinkUrlError> {
        let mut policy = Self::production(allowed_hosts)?;
        for suffix in allowed_host_suffixes {
            policy
                .allowed_host_suffixes
                .insert(normalize_allowed_host(suffix.as_ref())?);
        }
        Ok(policy)
    }

    pub(super) fn for_test_origin(origin: &str) -> Result<Self, IlinkUrlError> {
        let url = Url::parse(origin).map_err(|_| IlinkUrlError::InvalidUrl)?;
        let host = url
            .host_str()
            .ok_or(IlinkUrlError::MissingHost)?
            .to_ascii_lowercase();
        if url.scheme() != "http" || !is_loopback_host(&host) {
            return Err(IlinkUrlError::InsecureScheme);
        }
        Ok(Self {
            allowed_hosts: [host].into_iter().collect(),
            allowed_host_suffixes: HashSet::new(),
            allow_insecure_loopback: true,
        })
    }

    pub(super) fn validate(&self, candidate: &str) -> Result<ValidatedBaseUrl, IlinkUrlError> {
        let mut url = Url::parse(candidate).map_err(|_| IlinkUrlError::InvalidUrl)?;
        if !url.username().is_empty() || url.password().is_some() {
            return Err(IlinkUrlError::UserInfoNotAllowed);
        }
        if url.query().is_some() {
            return Err(IlinkUrlError::QueryNotAllowed);
        }
        if url.fragment().is_some() {
            return Err(IlinkUrlError::FragmentNotAllowed);
        }
        let host = url
            .host_str()
            .ok_or(IlinkUrlError::MissingHost)?
            .to_ascii_lowercase();
        let production_https = url.scheme() == "https" && url.port().is_none_or(|port| port == 443);
        let test_http =
            self.allow_insecure_loopback && url.scheme() == "http" && is_loopback_host(&host);
        if !production_https && !test_http {
            return if url.scheme() != "https" && !test_http {
                Err(IlinkUrlError::InsecureScheme)
            } else {
                Err(IlinkUrlError::PortNotAllowed)
            };
        }
        match url.host() {
            Some(Host::Domain(_)) => {}
            Some(Host::Ipv4(_)) | Some(Host::Ipv6(_)) if test_http => {}
            Some(Host::Ipv4(_)) | Some(Host::Ipv6(_)) => {
                return Err(IlinkUrlError::IpLiteralNotAllowed)
            }
            None => return Err(IlinkUrlError::MissingHost),
        }
        if !self.host_is_allowed(&host) {
            return Err(IlinkUrlError::HostNotAllowed);
        }
        if !url.path().ends_with('/') {
            let path = format!("{}/", url.path());
            url.set_path(&path);
        }
        Ok(ValidatedBaseUrl(url))
    }

    fn host_is_allowed(&self, host: &str) -> bool {
        self.allowed_hosts.contains(host)
            || self
                .allowed_host_suffixes
                .iter()
                .any(|suffix| host == suffix || host.ends_with(&format!(".{suffix}")))
    }

    pub(super) fn validate_redirect_host(
        &self,
        candidate: &str,
    ) -> Result<ValidatedBaseUrl, IlinkUrlError> {
        let probe =
            Url::parse(&format!("https://{candidate}/")).map_err(|_| IlinkUrlError::InvalidUrl)?;
        if !probe.username().is_empty()
            || probe.password().is_some()
            || probe.path() != "/"
            || probe.query().is_some()
            || probe.fragment().is_some()
        {
            return Err(IlinkUrlError::InvalidUrl);
        }
        let host = probe
            .host_str()
            .ok_or(IlinkUrlError::MissingHost)?
            .to_ascii_lowercase();
        let scheme = if self.allow_insecure_loopback && is_loopback_host(&host) {
            "http"
        } else {
            "https"
        };
        self.validate(&format!("{scheme}://{candidate}/"))
    }
}

fn normalize_allowed_host(host: &str) -> Result<String, IlinkUrlError> {
    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
    if host.is_empty()
        || host.contains('/')
        || host.contains(':')
        || host.parse::<std::net::IpAddr>().is_ok()
    {
        return Err(IlinkUrlError::InvalidAllowedHost);
    }
    let probe =
        Url::parse(&format!("https://{host}")).map_err(|_| IlinkUrlError::InvalidAllowedHost)?;
    if !matches!(probe.host(), Some(Host::Domain(_))) {
        return Err(IlinkUrlError::InvalidAllowedHost);
    }
    Ok(host)
}

fn is_loopback_host(host: &str) -> bool {
    host == "localhost"
        || host
            .parse::<std::net::IpAddr>()
            .is_ok_and(|address| address.is_loopback())
}

#[derive(Clone, PartialEq, Eq)]
pub struct ValidatedBaseUrl(Url);

impl ValidatedBaseUrl {
    pub(super) fn join(&self, endpoint: &str) -> Result<Url, IlinkUrlError> {
        if endpoint.starts_with('/') || endpoint.contains("..") {
            return Err(IlinkUrlError::InvalidEndpoint);
        }
        self.0
            .join(endpoint)
            .map_err(|_| IlinkUrlError::InvalidEndpoint)
    }

    #[doc(hidden)]
    pub fn insecure_loopback_for_tests(origin: &str) -> Result<Self, IlinkUrlError> {
        IlinkHostPolicy::for_test_origin(origin)?.validate(origin)
    }
}

impl fmt::Debug for ValidatedBaseUrl {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("ValidatedBaseUrl")
            .field("scheme", &self.0.scheme())
            .field("host", &self.0.host_str())
            .finish_non_exhaustive()
    }
}

#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
pub enum IlinkUrlError {
    #[error("iLink URL is invalid")]
    InvalidUrl,
    #[error("iLink URL must use HTTPS")]
    InsecureScheme,
    #[error("iLink URL host is missing")]
    MissingHost,
    #[error("iLink URL user information is not allowed")]
    UserInfoNotAllowed,
    #[error("iLink URL port is not allowed")]
    PortNotAllowed,
    #[error("iLink URL IP literals are not allowed")]
    IpLiteralNotAllowed,
    #[error("iLink URL host is not approved")]
    HostNotAllowed,
    #[error("iLink URL query is not allowed")]
    QueryNotAllowed,
    #[error("iLink URL fragment is not allowed")]
    FragmentNotAllowed,
    #[error("iLink endpoint is invalid")]
    InvalidEndpoint,
    #[error("iLink host policy must not be empty")]
    EmptyHostPolicy,
    #[error("iLink allowed host is invalid")]
    InvalidAllowedHost,
}