eggserve-core 0.1.1

Security policy, path confinement, and static-serving primitives for eggserve
Documentation
//! Path confinement: request-target parsing, validation, and policy enforcement.

pub mod components;
pub mod decode;
pub mod platform;
pub mod policy;
pub mod rejected;
pub mod request_target;

pub use policy::{DotfilePolicy, PathPolicy};
pub use rejected::PathRejection;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfinedPath {
    decoded: String,
    components: Vec<String>,
    path_policy: PathPolicy,
}

impl ConfinedPath {
    pub fn parse(raw: &str, policy: &PathPolicy) -> Result<Self, PathRejection> {
        if raw.len() > 8192 {
            return Err(PathRejection::TooLong);
        }
        let path = request_target::parse_origin_form(raw)?;

        let decoded = decode::percent_decode(path)?;

        let normalized = components::normalize_path(&decoded);

        let parts = components::split_components(&normalized);

        components::validate_components(&parts, policy)?;

        Ok(Self {
            decoded,
            components: parts,
            path_policy: policy.clone(),
        })
    }

    #[allow(dead_code)]
    pub fn as_str(&self) -> &str {
        &self.decoded
    }

    pub fn components(&self) -> &[String] {
        &self.components
    }

    pub fn path_policy(&self) -> &PathPolicy {
        &self.path_policy
    }
}

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

    fn default_policy() -> PathPolicy {
        PathPolicy::default()
    }

    #[test]
    fn simple_path() {
        let p = ConfinedPath::parse("/foo/bar", &default_policy()).unwrap();
        assert_eq!(p.as_str(), "/foo/bar");
        assert_eq!(p.components(), &["foo", "bar"]);
    }

    #[test]
    fn root_path() {
        let p = ConfinedPath::parse("/", &default_policy()).unwrap();
        assert_eq!(p.as_str(), "/");
        assert_eq!(p.components().len(), 0);
    }

    #[test]
    fn path_with_query_stripped() {
        let p = ConfinedPath::parse("/foo?bar=baz", &default_policy()).unwrap();
        assert_eq!(p.as_str(), "/foo");
        assert_eq!(p.components(), &["foo"]);
    }

    #[test]
    fn reject_empty() {
        assert_eq!(
            ConfinedPath::parse("", &default_policy()).unwrap_err(),
            PathRejection::Empty
        );
    }

    #[test]
    fn reject_absolute_form() {
        assert_eq!(
            ConfinedPath::parse("http://example.com/path", &default_policy()).unwrap_err(),
            PathRejection::UnsupportedUriForm
        );
    }

    #[test]
    fn reject_asterisk_form() {
        assert_eq!(
            ConfinedPath::parse("*", &default_policy()).unwrap_err(),
            PathRejection::UnsupportedUriForm
        );
    }

    #[test]
    fn reject_authority_form() {
        assert_eq!(
            ConfinedPath::parse("example.com:443", &default_policy()).unwrap_err(),
            PathRejection::UnsupportedUriForm
        );
    }

    #[test]
    fn normalize_consecutive_slashes() {
        let p = ConfinedPath::parse("/foo//bar", &default_policy()).unwrap();
        assert_eq!(p.components(), &["foo", "bar"]);
    }

    #[test]
    fn reject_dot_component() {
        assert_eq!(
            ConfinedPath::parse("/foo/./bar", &default_policy()).unwrap_err(),
            PathRejection::CurrentComponent
        );
    }

    #[test]
    fn reject_dotdot_component() {
        assert_eq!(
            ConfinedPath::parse("/../etc/passwd", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_percent_encoded_dotdot() {
        assert_eq!(
            ConfinedPath::parse("/%2e%2e/etc/passwd", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_uppercase_percent_encoded_dotdot() {
        assert_eq!(
            ConfinedPath::parse("/%2E%2E/etc/passwd", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_double_encoded_dotdot() {
        assert_eq!(
            ConfinedPath::parse("/%252e%252e/etc/passwd", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_dotdot_in_path() {
        assert_eq!(
            ConfinedPath::parse("/foo/../../bar", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_percent_encoded_dotdot_in_path() {
        assert_eq!(
            ConfinedPath::parse("/foo/%2e%2e/bar", &default_policy()).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_backslash() {
        assert_eq!(
            ConfinedPath::parse("/foo\\bar", &default_policy()).unwrap_err(),
            PathRejection::SeparatorAmbiguity
        );
    }

    #[test]
    fn reject_percent_encoded_backslash() {
        assert_eq!(
            ConfinedPath::parse("/%5cetc%5cpasswd", &default_policy()).unwrap_err(),
            PathRejection::SeparatorAmbiguity
        );
    }

    #[test]
    fn reject_windows_drive_prefix() {
        assert_eq!(
            ConfinedPath::parse("/C:/Windows/System32", &default_policy()).unwrap_err(),
            PathRejection::WindowsPrefixDenied
        );
    }

    #[test]
    fn reject_percent_encoded_windows_drive() {
        assert_eq!(
            ConfinedPath::parse("/c%3a/Windows/System32", &default_policy()).unwrap_err(),
            PathRejection::WindowsPrefixDenied
        );
    }

    #[test]
    fn reject_dotfile() {
        assert_eq!(
            ConfinedPath::parse("/.env", &default_policy()).unwrap_err(),
            PathRejection::DotfileDenied
        );
    }

    #[test]
    fn reject_dotfile_git_config() {
        assert_eq!(
            ConfinedPath::parse("/.git/config", &default_policy()).unwrap_err(),
            PathRejection::DotfileDenied
        );
    }

    #[test]
    fn reject_dotfile_in_subdir() {
        assert_eq!(
            ConfinedPath::parse("/foo/.secret", &default_policy()).unwrap_err(),
            PathRejection::DotfileDenied
        );
    }

    #[test]
    fn reject_windows_reserved_con() {
        assert_eq!(
            ConfinedPath::parse("/CON", &default_policy()).unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn reject_windows_reserved_aux() {
        assert_eq!(
            ConfinedPath::parse("/AUX.txt", &default_policy()).unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn reject_windows_reserved_com1() {
        assert_eq!(
            ConfinedPath::parse("/COM1", &default_policy()).unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn reject_windows_ads() {
        assert_eq!(
            ConfinedPath::parse("/file.txt:stream", &default_policy()).unwrap_err(),
            PathRejection::WindowsAlternateStreamDenied
        );
    }

    #[test]
    fn reject_nul() {
        assert_eq!(
            ConfinedPath::parse("/%00", &default_policy()).unwrap_err(),
            PathRejection::NulByte
        );
    }

    #[test]
    fn reject_malformed_percent() {
        assert_eq!(
            ConfinedPath::parse("/%ZZ", &default_policy()).unwrap_err(),
            PathRejection::MalformedPercentEncoding
        );
    }

    #[test]
    fn allow_dotfile_when_policy_permits() {
        let policy = PathPolicy {
            dotfiles: DotfilePolicy::Allow,
            ..PathPolicy::default()
        };
        let p = ConfinedPath::parse("/.env", &policy).unwrap();
        assert_eq!(p.as_str(), "/.env");
    }

    #[test]
    fn allow_backslash_when_policy_permits() {
        let policy = PathPolicy {
            reject_backslash: false,
            ..PathPolicy::default()
        };
        let p = ConfinedPath::parse("/foo\\bar", &policy).unwrap();
        assert_eq!(p.as_str(), "/foo\\bar");
    }

    #[test]
    fn reject_double_slash_root() {
        let p = ConfinedPath::parse("//", &default_policy()).unwrap();
        assert_eq!(p.components().len(), 0);
    }

    #[test]
    fn reject_triple_slash() {
        let p = ConfinedPath::parse("///", &default_policy()).unwrap();
        assert_eq!(p.components().len(), 0);
    }

    #[test]
    fn path_policy_returns_parsed_policy() {
        let policy = PathPolicy {
            dotfiles: DotfilePolicy::Allow,
            ..PathPolicy::default()
        };
        let p = ConfinedPath::parse("/.env", &policy).unwrap();
        assert_eq!(p.path_policy(), &policy);
    }

    #[test]
    fn path_policy_default_returns_default() {
        let p = ConfinedPath::parse("/foo", &default_policy()).unwrap();
        assert_eq!(p.path_policy(), &default_policy());
    }

    #[test]
    fn reject_too_long() {
        let long = format!("/{}", "a".repeat(8192));
        assert_eq!(
            ConfinedPath::parse(&long, &default_policy()).unwrap_err(),
            PathRejection::TooLong
        );
    }

    #[test]
    fn allow_max_length() {
        let max_len = format!("/{}", "a".repeat(8191));
        assert!(ConfinedPath::parse(&max_len, &default_policy()).is_ok());
    }
}