passless-rs 0.17.0

FIDO2 security token emulator
Documentation
use std::fmt;
use std::ops::Deref;
use std::path::{Component, Path};

use log::error;

const MAX_RP_ID_LENGTH: usize = 255;

pub fn validate_rp_id_for_storage(raw: &str) -> Result<ValidatedRpId, RpIdValidationError> {
    match ValidatedRpId::try_from(raw) {
        Ok(id) => Ok(id),
        Err(e) => {
            error!("Rejected credential with invalid RP ID '{}': {}", raw, e);
            Err(e)
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ValidatedRpId(String);

#[derive(Debug)]
pub enum RpIdValidationError {
    Empty,
    TooLong(usize),
    ContainsNull,
    ContainsForwardSlash,
    ContainsBackslash,
    ParentDirectoryComponent,
    CurrentDirectoryComponent,
    MultiplePathComponents,
    AbsolutePath,
    HiddenFile,
}

impl fmt::Display for RpIdValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => write!(f, "RP ID must not be empty"),
            Self::TooLong(len) => {
                write!(
                    f,
                    "RP ID too long: {} bytes (max {})",
                    len, MAX_RP_ID_LENGTH
                )
            }
            Self::ContainsNull => write!(f, "RP ID must not contain null bytes"),
            Self::ContainsForwardSlash => {
                write!(f, "RP ID must not contain '/' path separator")
            }
            Self::ContainsBackslash => {
                write!(f, "RP ID must not contain '\\' path separator")
            }
            Self::ParentDirectoryComponent => write!(f, "RP ID must not contain '..'"),
            Self::CurrentDirectoryComponent => write!(f, "RP ID must not be or contain '.'"),
            Self::MultiplePathComponents => {
                write!(f, "RP ID must be a single path component")
            }
            Self::AbsolutePath => write!(f, "RP ID must not be an absolute path"),
            Self::HiddenFile => write!(f, "RP ID must not start with '.'"),
        }
    }
}

impl TryFrom<&str> for ValidatedRpId {
    type Error = RpIdValidationError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let trimmed = value.trim();

        if trimmed.is_empty() {
            return Err(RpIdValidationError::Empty);
        }

        if trimmed.len() > MAX_RP_ID_LENGTH {
            return Err(RpIdValidationError::TooLong(trimmed.len()));
        }

        if trimmed.contains('\0') {
            return Err(RpIdValidationError::ContainsNull);
        }

        let path = Path::new(trimmed);
        let components: Vec<_> = path.components().collect();

        match components.as_slice() {
            [Component::Normal(_)] => {}
            [Component::CurDir] => {
                return Err(RpIdValidationError::CurrentDirectoryComponent);
            }
            [Component::ParentDir] => {
                return Err(RpIdValidationError::ParentDirectoryComponent);
            }
            _ => {
                if components.iter().any(|c| matches!(c, Component::RootDir)) {
                    return Err(RpIdValidationError::AbsolutePath);
                }
                if matches!(components.first(), Some(Component::ParentDir)) {
                    return Err(RpIdValidationError::ParentDirectoryComponent);
                }
                return Err(RpIdValidationError::MultiplePathComponents);
            }
        }

        if trimmed.contains('/') {
            return Err(RpIdValidationError::ContainsForwardSlash);
        }

        if trimmed.contains('\\') {
            return Err(RpIdValidationError::ContainsBackslash);
        }

        if trimmed.starts_with('.') {
            return Err(RpIdValidationError::HiddenFile);
        }

        Ok(Self(trimmed.to_string()))
    }
}

impl ValidatedRpId {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for ValidatedRpId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl Deref for ValidatedRpId {
    type Target = str;

    fn deref(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for ValidatedRpId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

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

    #[test]
    fn test_valid_rp_ids() {
        assert!(ValidatedRpId::try_from("example.com").is_ok());
        assert!(ValidatedRpId::try_from("webauthn.example.org").is_ok());
        assert!(ValidatedRpId::try_from("localhost").is_ok());
        assert!(ValidatedRpId::try_from("a-b-c").is_ok());
        assert!(ValidatedRpId::try_from("my-app.example.com").is_ok());
        assert!(ValidatedRpId::try_from("xn--n1e.ru").is_ok());
    }

    #[test]
    fn test_rejects_empty() {
        let err = ValidatedRpId::try_from("").unwrap_err();
        assert!(matches!(err, RpIdValidationError::Empty));
        let err = ValidatedRpId::try_from("  ").unwrap_err();
        assert!(matches!(err, RpIdValidationError::Empty));
    }

    #[test]
    fn test_rejects_path_traversal() {
        let err = ValidatedRpId::try_from("../outside").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ParentDirectoryComponent));
        let err = ValidatedRpId::try_from("a/../../outside").unwrap_err();
        assert!(matches!(err, RpIdValidationError::MultiplePathComponents));
    }

    #[test]
    fn test_rejects_absolute_paths() {
        let err = ValidatedRpId::try_from("/tmp/absolute").unwrap_err();
        assert!(matches!(err, RpIdValidationError::AbsolutePath));
    }

    #[test]
    fn test_rejects_current_dir() {
        let err = ValidatedRpId::try_from(".").unwrap_err();
        assert!(matches!(
            err,
            RpIdValidationError::CurrentDirectoryComponent
        ));
        let err = ValidatedRpId::try_from("..").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ParentDirectoryComponent));
    }

    #[test]
    fn test_rejects_separators() {
        let err = ValidatedRpId::try_from("foo/bar").unwrap_err();
        assert!(matches!(err, RpIdValidationError::MultiplePathComponents));
        let err = ValidatedRpId::try_from("foo\\bar").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsBackslash));
    }

    #[test]
    fn test_rejects_hidden_files() {
        let err = ValidatedRpId::try_from(".hidden").unwrap_err();
        assert!(matches!(err, RpIdValidationError::HiddenFile));
    }

    #[test]
    fn test_rejects_null() {
        let err = ValidatedRpId::try_from("bad\0.com").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsNull));
    }

    #[test]
    fn test_rejects_overlong() {
        let long = "a".repeat(MAX_RP_ID_LENGTH + 1);
        let err = ValidatedRpId::try_from(long.as_str()).unwrap_err();
        assert!(matches!(err, RpIdValidationError::TooLong(_)));
    }

    #[test]
    fn test_accepts_max_length() {
        let long = "a".repeat(MAX_RP_ID_LENGTH);
        assert!(ValidatedRpId::try_from(long.as_str()).is_ok());
    }

    #[test]
    fn test_display_and_deref() {
        let rp = ValidatedRpId::try_from("example.com").unwrap();
        assert_eq!(rp.as_str(), "example.com");
        assert_eq!(rp.to_string(), "example.com");
        assert_eq!(*rp, "example.com".to_string());
    }

    #[test]
    fn test_equality_and_hashing() {
        let a = ValidatedRpId::try_from("example.com").unwrap();
        let b = ValidatedRpId::try_from("example.com").unwrap();
        let c = ValidatedRpId::try_from("other.com").unwrap();
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn test_security_rejects_backslash_on_unix() {
        let err = ValidatedRpId::try_from("foo\\bar").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsBackslash));
    }

    #[test]
    fn test_security_rejects_windows_paths() {
        let err = ValidatedRpId::try_from("C:\\outside").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsBackslash));
    }

    #[test]
    fn test_security_rejects_unc_paths() {
        let err = ValidatedRpId::try_from("\\\\server\\share").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsBackslash));
    }

    #[test]
    fn test_security_rejects_null_bytes() {
        let err = ValidatedRpId::try_from("evil\0.com").unwrap_err();
        assert!(matches!(err, RpIdValidationError::ContainsNull));
    }

    #[test]
    fn test_security_rejects_empty_after_trim() {
        let err = ValidatedRpId::try_from("   ").unwrap_err();
        assert!(matches!(err, RpIdValidationError::Empty));
    }

    #[test]
    fn test_security_rejects_overlong_rp_id() {
        let long = "a".repeat(256);
        let err = ValidatedRpId::try_from(long.as_str()).unwrap_err();
        assert!(matches!(err, RpIdValidationError::TooLong(256)));
    }

    #[test]
    fn test_security_accepts_unicode_idna() {
        assert!(ValidatedRpId::try_from("xn--n1e.ru").is_ok());
        assert!(ValidatedRpId::try_from("münchen.de").is_ok());
    }
}