use actix_web::HttpRequest;
use super::error::WebSocketSecurityError;
#[derive(Debug, Clone, Default)]
pub struct OriginValidator {
allowed_origins: Vec<String>,
allow_any: bool,
allow_missing: bool,
}
impl OriginValidator {
pub fn new(origins: &[&str]) -> Self {
Self {
allowed_origins: origins.iter().map(|s| s.to_string()).collect(),
allow_any: false,
allow_missing: false,
}
}
pub fn builder() -> OriginValidatorBuilder {
OriginValidatorBuilder::default()
}
pub fn allow_any() -> Self {
Self {
allowed_origins: Vec::new(),
allow_any: true,
allow_missing: true,
}
}
pub fn validate(&self, req: &HttpRequest) -> Result<(), WebSocketSecurityError> {
if self.allow_any {
return Ok(());
}
let origin = req
.headers()
.get("origin")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
match origin {
Some(origin) => {
if self.is_allowed(&origin) {
Ok(())
} else {
Err(WebSocketSecurityError::InvalidOrigin { origin })
}
}
None => {
if self.allow_missing {
Ok(())
} else {
Err(WebSocketSecurityError::MissingOrigin)
}
}
}
}
fn is_allowed(&self, origin: &str) -> bool {
let normalized = origin.trim_end_matches('/');
for allowed in &self.allowed_origins {
let allowed_normalized = allowed.trim_end_matches('/');
if normalized.eq_ignore_ascii_case(allowed_normalized) {
return true;
}
if let Some(pattern) = allowed_normalized.strip_prefix("*.") {
if let Some(domain) = normalized.split("://").nth(1) {
if domain.ends_with(pattern) || domain == pattern {
return true;
}
}
}
}
false
}
}
#[derive(Debug, Clone, Default)]
pub struct OriginValidatorBuilder {
allowed_origins: Vec<String>,
allow_any: bool,
allow_missing: bool,
allow_localhost_in_dev: bool,
}
impl OriginValidatorBuilder {
pub fn allow(mut self, origin: &str) -> Self {
self.allowed_origins.push(origin.to_string());
self
}
pub fn allow_all(mut self, origins: &[&str]) -> Self {
self.allowed_origins
.extend(origins.iter().map(|s| s.to_string()));
self
}
pub fn allow_subdomain_pattern(mut self, pattern: &str) -> Self {
if !pattern.starts_with("*.") {
self.allowed_origins.push(format!("*.{}", pattern));
} else {
self.allowed_origins.push(pattern.to_string());
}
self
}
pub fn allow_any(mut self) -> Self {
self.allow_any = true;
self
}
pub fn allow_missing(mut self) -> Self {
self.allow_missing = true;
self
}
pub fn allow_localhost_in_dev(mut self, allow: bool) -> Self {
self.allow_localhost_in_dev = allow;
self
}
pub fn build(mut self) -> OriginValidator {
#[cfg(debug_assertions)]
if self.allow_localhost_in_dev {
self.allowed_origins.push("http://localhost".to_string());
self.allowed_origins.push("http://127.0.0.1".to_string());
self.allowed_origins.push("http://localhost:*".to_string());
self.allowed_origins.push("http://127.0.0.1:*".to_string());
}
OriginValidator {
allowed_origins: self.allowed_origins,
allow_any: self.allow_any,
allow_missing: self.allow_missing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
#[test]
fn test_exact_origin_match() {
let validator = OriginValidator::new(&["https://myapp.com"]);
let req = TestRequest::default()
.insert_header(("origin", "https://myapp.com"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
}
#[test]
fn test_origin_case_insensitive() {
let validator = OriginValidator::new(&["https://myapp.com"]);
let req = TestRequest::default()
.insert_header(("origin", "https://MYAPP.COM"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
}
#[test]
fn test_invalid_origin() {
let validator = OriginValidator::new(&["https://myapp.com"]);
let req = TestRequest::default()
.insert_header(("origin", "https://evil.com"))
.to_http_request();
let result = validator.validate(&req);
assert!(matches!(
result,
Err(WebSocketSecurityError::InvalidOrigin { origin }) if origin == "https://evil.com"
));
}
#[test]
fn test_missing_origin_rejected() {
let validator = OriginValidator::new(&["https://myapp.com"]);
let req = TestRequest::default().to_http_request();
assert!(matches!(
validator.validate(&req),
Err(WebSocketSecurityError::MissingOrigin)
));
}
#[test]
fn test_missing_origin_allowed() {
let validator = OriginValidator::builder()
.allow("https://myapp.com")
.allow_missing()
.build();
let req = TestRequest::default().to_http_request();
assert!(validator.validate(&req).is_ok());
}
#[test]
fn test_allow_any() {
let validator = OriginValidator::allow_any();
let req = TestRequest::default()
.insert_header(("origin", "https://any-origin.com"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
}
#[test]
fn test_wildcard_subdomain() {
let validator = OriginValidator::builder()
.allow_subdomain_pattern("*.myapp.com")
.build();
let req = TestRequest::default()
.insert_header(("origin", "https://api.myapp.com"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
let req = TestRequest::default()
.insert_header(("origin", "https://admin.myapp.com"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
let req = TestRequest::default()
.insert_header(("origin", "https://evil.com"))
.to_http_request();
assert!(validator.validate(&req).is_err());
}
#[test]
fn test_multiple_allowed_origins() {
let validator = OriginValidator::builder()
.allow("https://myapp.com")
.allow("https://api.myapp.com")
.allow("https://admin.myapp.com")
.build();
for origin in [
"https://myapp.com",
"https://api.myapp.com",
"https://admin.myapp.com",
] {
let req = TestRequest::default()
.insert_header(("origin", origin))
.to_http_request();
assert!(validator.validate(&req).is_ok());
}
}
#[test]
fn test_trailing_slash_normalization() {
let validator = OriginValidator::new(&["https://myapp.com/"]);
let req = TestRequest::default()
.insert_header(("origin", "https://myapp.com"))
.to_http_request();
assert!(validator.validate(&req).is_ok());
}
}