use std::{num::NonZeroU32, num::NonZeroU64, time::Duration};
use http::{HeaderName, Method, Uri};
use soaprs_auth::AuthorizationName;
use soaprs_core::{SoapError, SoapResult};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RateLimitScope {
Global,
#[default]
ClientIp,
Principal,
ApiKey,
Custom(AuthorizationName),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RateLimitPolicy {
pub requests: NonZeroU32,
pub period: Duration,
pub burst: Option<NonZeroU32>,
pub scope: RateLimitScope,
}
impl RateLimitPolicy {
pub fn new(requests: NonZeroU32, period: Duration) -> SoapResult<Self> {
if period.is_zero() {
return Err(SoapError::validation(
"rate-limit period must be greater than zero",
));
}
Ok(Self {
requests,
period,
burst: None,
scope: RateLimitScope::ClientIp,
})
}
#[must_use]
pub fn scope(mut self, scope: RateLimitScope) -> Self {
self.scope = scope;
self
}
#[must_use]
pub fn burst(mut self, burst: NonZeroU32) -> Self {
self.burst = Some(burst);
self
}
pub fn validate(&self) -> SoapResult<()> {
if self.period.is_zero() {
Err(SoapError::validation(
"rate-limit period must be greater than zero",
))
} else {
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BodyLimitPolicy {
pub max_bytes: NonZeroU64,
}
impl BodyLimitPolicy {
pub const fn new(max_bytes: NonZeroU64) -> Self {
Self { max_bytes }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CsrfPolicy {
#[default]
Disabled,
Required,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowedOrigins {
Any,
Exact(Vec<Uri>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CorsPolicy {
pub origins: AllowedOrigins,
pub methods: Vec<Method>,
pub allow_headers: Vec<HeaderName>,
pub expose_headers: Vec<HeaderName>,
pub allow_credentials: bool,
pub max_age: Option<Duration>,
}
impl CorsPolicy {
pub fn any(methods: Vec<Method>) -> SoapResult<Self> {
validate_methods(&methods)?;
Ok(Self {
origins: AllowedOrigins::Any,
methods,
allow_headers: Vec::new(),
expose_headers: Vec::new(),
allow_credentials: false,
max_age: None,
})
}
pub fn exact<I, S>(origins: I, methods: Vec<Method>) -> SoapResult<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
validate_methods(&methods)?;
let origins = origins
.into_iter()
.map(|origin| validate_origin(origin.as_ref()))
.collect::<SoapResult<Vec<_>>>()?;
if origins.is_empty() {
return Err(SoapError::validation("CORS origins cannot be empty"));
}
Ok(Self {
origins: AllowedOrigins::Exact(origins),
methods,
allow_headers: Vec::new(),
expose_headers: Vec::new(),
allow_credentials: false,
max_age: None,
})
}
pub fn allow_credentials(mut self) -> SoapResult<Self> {
if matches!(self.origins, AllowedOrigins::Any) {
return Err(SoapError::validation(
"credentialed CORS cannot allow every origin",
));
}
self.allow_credentials = true;
Ok(self)
}
#[must_use]
pub fn allow_headers(mut self, headers: Vec<HeaderName>) -> Self {
self.allow_headers = headers;
self
}
#[must_use]
pub fn expose_headers(mut self, headers: Vec<HeaderName>) -> Self {
self.expose_headers = headers;
self
}
pub fn max_age(mut self, max_age: Duration) -> SoapResult<Self> {
if max_age.is_zero() {
return Err(SoapError::validation(
"CORS max age must be greater than zero",
));
}
self.max_age = Some(max_age);
Ok(self)
}
pub fn validate(&self) -> SoapResult<()> {
validate_methods(&self.methods)?;
match &self.origins {
AllowedOrigins::Any if self.allow_credentials => {
return Err(SoapError::validation(
"credentialed CORS cannot allow every origin",
));
}
AllowedOrigins::Exact(origins) if origins.is_empty() => {
return Err(SoapError::validation("CORS origins cannot be empty"));
}
AllowedOrigins::Exact(origins) => {
origins
.iter()
.try_for_each(|origin| validate_origin(&origin.to_string()).map(|_| ()))?;
}
AllowedOrigins::Any => {}
}
if self.max_age.is_some_and(|max_age| max_age.is_zero()) {
return Err(SoapError::validation(
"CORS max age must be greater than zero",
));
}
Ok(())
}
}
fn validate_methods(methods: &[Method]) -> SoapResult<()> {
if methods.is_empty() {
Err(SoapError::validation("CORS methods cannot be empty"))
} else {
Ok(())
}
}
fn validate_origin(origin: &str) -> SoapResult<Uri> {
let uri = origin
.parse::<Uri>()
.map_err(|_| SoapError::validation(format!("invalid CORS origin `{origin}`")))?;
let has_root_or_empty_path = uri.path().is_empty() || uri.path() == "/";
if !matches!(uri.scheme_str(), Some("http" | "https"))
|| uri.authority().is_none()
|| uri
.authority()
.is_some_and(|authority| authority.as_str().contains('@'))
|| !has_root_or_empty_path
|| uri.query().is_some()
{
return Err(SoapError::validation(format!(
"invalid CORS origin `{origin}`"
)));
}
Ok(uri)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameOptions {
Deny,
SameOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferrerPolicy {
NoReferrer,
StrictOriginWhenCrossOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HstsPolicy {
pub max_age: Duration,
pub include_subdomains: bool,
pub preload: bool,
}
impl HstsPolicy {
pub fn new(max_age: Duration) -> SoapResult<Self> {
if max_age.is_zero() {
return Err(SoapError::validation(
"HSTS max age must be greater than zero",
));
}
Ok(Self {
max_age,
include_subdomains: false,
preload: false,
})
}
#[must_use]
pub const fn include_subdomains(mut self) -> Self {
self.include_subdomains = true;
self
}
#[must_use]
pub const fn preload(mut self) -> Self {
self.preload = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecurityHeadersPolicy {
pub no_sniff: bool,
pub frame_options: Option<FrameOptions>,
pub referrer_policy: Option<ReferrerPolicy>,
pub content_security_policy: Option<String>,
pub hsts: Option<HstsPolicy>,
}
impl SecurityHeadersPolicy {
pub const fn secure_defaults() -> Self {
Self {
no_sniff: true,
frame_options: Some(FrameOptions::Deny),
referrer_policy: Some(ReferrerPolicy::NoReferrer),
content_security_policy: None,
hsts: None,
}
}
pub fn content_security_policy(mut self, policy: impl Into<String>) -> SoapResult<Self> {
let policy = policy.into();
if policy.trim().is_empty() || policy.chars().any(char::is_control) {
return Err(SoapError::validation(
"content security policy is empty or contains control characters",
));
}
self.content_security_policy = Some(policy);
Ok(self)
}
#[must_use]
pub const fn hsts(mut self, policy: HstsPolicy) -> Self {
self.hsts = Some(policy);
self
}
pub fn validate(&self) -> SoapResult<()> {
if self
.content_security_policy
.as_ref()
.is_some_and(|policy| policy.trim().is_empty() || policy.chars().any(char::is_control))
{
return Err(SoapError::validation(
"content security policy is empty or contains control characters",
));
}
if self.hsts.is_some_and(|policy| policy.max_age.is_zero()) {
return Err(SoapError::validation(
"HSTS max age must be greater than zero",
));
}
Ok(())
}
}
impl Default for SecurityHeadersPolicy {
fn default() -> Self {
Self::secure_defaults()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheVisibility {
NoStore,
Private,
Public,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseCachePolicy {
pub visibility: CacheVisibility,
pub max_age: Option<Duration>,
pub vary: Vec<HeaderName>,
}
impl ResponseCachePolicy {
pub const fn no_store() -> Self {
Self {
visibility: CacheVisibility::NoStore,
max_age: None,
vary: Vec::new(),
}
}
pub fn private(max_age: Duration) -> SoapResult<Self> {
Self::cacheable(CacheVisibility::Private, max_age)
}
pub fn public(max_age: Duration) -> SoapResult<Self> {
Self::cacheable(CacheVisibility::Public, max_age)
}
fn cacheable(visibility: CacheVisibility, max_age: Duration) -> SoapResult<Self> {
if max_age.is_zero() {
return Err(SoapError::validation(
"response cache max age must be greater than zero",
));
}
Ok(Self {
visibility,
max_age: Some(max_age),
vary: Vec::new(),
})
}
#[must_use]
pub fn vary(mut self, vary: Vec<HeaderName>) -> Self {
self.vary = vary;
self
}
pub fn validate(&self) -> SoapResult<()> {
match (self.visibility, self.max_age) {
(CacheVisibility::NoStore, Some(_)) => Err(SoapError::validation(
"no-store response cache policy cannot declare max age",
)),
(CacheVisibility::Private | CacheVisibility::Public, None) => Err(
SoapError::validation("cacheable response policy requires max age"),
),
(_, Some(max_age)) if max_age.is_zero() => Err(SoapError::validation(
"response cache max age must be greater than zero",
)),
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use std::{num::NonZeroU32, time::Duration};
use http::Method;
use soaprs_auth::AuthorizationPolicy;
use super::{
CacheVisibility, CorsPolicy, RateLimitPolicy, RateLimitScope, ResponseCachePolicy,
SecurityHeadersPolicy,
};
#[test]
fn authorization_names_and_lists_are_validated() {
assert!(AuthorizationPolicy::strategy("jwt").is_ok());
assert!(AuthorizationPolicy::optional_strategy("session").is_ok());
assert!(AuthorizationPolicy::all_permissions(["orders:read", "orders:write"]).is_ok());
assert!(AuthorizationPolicy::any_role(Vec::<String>::new()).is_err());
assert!(AuthorizationPolicy::named("bad policy name").is_err());
assert!(!AuthorizationPolicy::Optional.requires_identity());
assert!(AuthorizationPolicy::Optional.authenticates_when_present());
assert!(!AuthorizationPolicy::Optional.allows_public_response_cache());
}
#[test]
fn rate_limits_include_scope_and_burst_without_choosing_storage() {
let Some(requests) = NonZeroU32::new(100) else {
panic!("non-zero fixture");
};
let result = RateLimitPolicy::new(requests, Duration::from_secs(60)).map(|policy| {
policy
.scope(RateLimitScope::Principal)
.burst(NonZeroU32::MIN)
});
assert!(result.is_ok());
}
#[test]
fn cors_rejects_wildcard_credentials_and_non_origins() {
let wildcard = CorsPolicy::any(vec![Method::GET]);
assert!(wildcard.and_then(CorsPolicy::allow_credentials).is_err());
assert!(CorsPolicy::exact(["https://example.com"], vec![Method::GET]).is_ok());
assert!(CorsPolicy::exact(["https://example.com/path"], vec![Method::GET]).is_err());
assert!(CorsPolicy::exact(["ftp://example.com"], vec![Method::GET]).is_err());
assert!(CorsPolicy::exact(["https://user@example.com"], vec![Method::GET]).is_err());
}
#[test]
fn public_policy_fields_are_revalidated_before_registration() {
let Some(requests) = NonZeroU32::new(10) else {
panic!("non-zero fixture");
};
let Some(mut rate_limit) = RateLimitPolicy::new(requests, Duration::from_secs(1)).ok()
else {
panic!("valid rate limit");
};
rate_limit.period = Duration::ZERO;
assert!(rate_limit.validate().is_err());
let mut cors = CorsPolicy::any(vec![Method::GET]).unwrap_or_else(|error| {
panic!("valid CORS fixture failed: {error}");
});
cors.allow_credentials = true;
assert!(cors.validate().is_err());
let mut cache = ResponseCachePolicy::no_store();
cache.visibility = CacheVisibility::Public;
assert!(cache.validate().is_err());
let mut headers = SecurityHeadersPolicy::secure_defaults();
headers.content_security_policy = Some("\r\ninjected: true".to_owned());
assert!(headers.validate().is_err());
}
}