use security_core::severity::SecuritySeverity;
use security_events::event::{EventOutcome, SecurityEvent};
use security_events::kind::EventKind;
use security_events::sink::SecuritySink;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CleartextResult {
Secure,
CleartextBlocked,
ExemptedLocalhost,
InsecureScheme {
scheme: String,
},
}
#[derive(Clone, Debug)]
pub struct CleartextDetector {
exempt_localhost: bool,
}
impl CleartextDetector {
#[must_use]
pub fn new() -> Self {
Self {
exempt_localhost: false,
}
}
#[must_use]
pub fn with_localhost_exemption(mut self, exempt: bool) -> Self {
self.exempt_localhost = exempt;
self
}
pub fn check(&self, url: &str) -> CleartextResult {
let lower = url.to_ascii_lowercase();
let (scheme, rest) = match lower.split_once("://") {
Some((s, r)) => (s, r),
None => return CleartextResult::CleartextBlocked,
};
match scheme {
"https" | "wss" => CleartextResult::Secure,
"http" | "ws" => {
if self.exempt_localhost && Self::is_localhost(rest) {
CleartextResult::ExemptedLocalhost
} else {
CleartextResult::CleartextBlocked
}
}
"ftp" | "telnet" | "gopher" => CleartextResult::InsecureScheme {
scheme: scheme.to_string(),
},
_ => {
if Self::is_known_secure_scheme(scheme) {
CleartextResult::Secure
} else {
CleartextResult::InsecureScheme {
scheme: scheme.to_string(),
}
}
}
}
}
pub fn check_and_emit(&self, url: &str, sink: &dyn SecuritySink) -> CleartextResult {
let result = self.check(url);
match &result {
CleartextResult::CleartextBlocked => {
let mut event = SecurityEvent::new(
EventKind::CleartextBlocked,
SecuritySeverity::High,
EventOutcome::Blocked,
);
event.reason_code = Some("cleartext_http");
sink.write_event(&event);
}
CleartextResult::InsecureScheme { .. } => {
let mut event = SecurityEvent::new(
EventKind::CleartextBlocked,
SecuritySeverity::High,
EventOutcome::Blocked,
);
event.reason_code = Some("insecure_scheme");
sink.write_event(&event);
}
CleartextResult::Secure | CleartextResult::ExemptedLocalhost => {}
}
result
}
fn is_localhost(host_and_path: &str) -> bool {
let host_port = host_and_path.split('/').next().unwrap_or("");
let host = if host_port.starts_with('[') {
host_port.split(']').next().map(|s| &s[1..]).unwrap_or("")
} else {
host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
};
matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0")
}
fn is_known_secure_scheme(scheme: &str) -> bool {
matches!(scheme, "https" | "wss" | "ftps" | "ssh" | "sftp")
}
}
impl Default for CleartextDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn localhost_detection() {
assert!(CleartextDetector::is_localhost("localhost/path"));
assert!(CleartextDetector::is_localhost("127.0.0.1:8080/path"));
assert!(CleartextDetector::is_localhost("[::1]:443/path"));
assert!(!CleartextDetector::is_localhost("example.com/path"));
}
}