use crate::utils::get_env_with_prefix;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "UPPERCASE")]
#[derive(Default)]
pub enum XFrameOptions {
#[default]
Deny,
SameOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ReferrerPolicy {
NoReferrer,
SameOrigin,
#[default]
StrictOriginWhenCrossOrigin,
StrictOrigin,
UnsafeUrl,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecurityConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_hsts_max_age")]
pub hsts_max_age: u64,
#[serde(default)]
pub hsts_preload: bool,
#[serde(default)]
pub hsts_include_subdomains: bool,
#[serde(default = "default_nosniff")]
pub nosniff: bool,
#[serde(default)]
pub x_frame_options: Option<XFrameOptions>,
#[serde(default)]
pub xss_protection: Option<bool>,
#[serde(default)]
pub content_security_policy: Option<String>,
#[serde(default)]
pub referrer_policy: Option<ReferrerPolicy>,
#[serde(default)]
pub permissions_policy: Option<String>,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
hsts_max_age: default_hsts_max_age(),
hsts_preload: false,
hsts_include_subdomains: true,
nosniff: default_nosniff(),
x_frame_options: Some(XFrameOptions::default()),
xss_protection: Some(false), content_security_policy: None,
referrer_policy: Some(ReferrerPolicy::default()),
permissions_policy: None,
}
}
}
impl SecurityConfig {
pub fn builder() -> SecurityConfigBuilder {
SecurityConfigBuilder::new()
}
pub fn from_env() -> Self {
let mut config = Self::default();
let explicit_hsts_max_age = get_env_with_prefix("SECURITY_HSTS_MAX_AGE").is_some();
if let Some(enabled) = get_env_with_prefix("SECURITY_ENABLED") {
config.enabled = parse_bool_with_default(&enabled, config.enabled);
}
if let Some(max_age) = get_env_with_prefix("SECURITY_HSTS_MAX_AGE") {
if let Ok(age) = max_age.parse() {
config.hsts_max_age = age;
}
}
if let Some(preload) = get_env_with_prefix("SECURITY_HSTS_PRELOAD") {
config.hsts_preload = parse_bool_with_default(&preload, config.hsts_preload);
}
if let Some(include_subdomains) = get_env_with_prefix("SECURITY_HSTS_INCLUDE_SUBDOMAINS") {
config.hsts_include_subdomains =
parse_bool_with_default(&include_subdomains, config.hsts_include_subdomains);
}
if let Some(nosniff) = get_env_with_prefix("SECURITY_NOSNIFF") {
config.nosniff = parse_bool_with_default(&nosniff, config.nosniff);
}
if let Some(frame_options) = get_env_with_prefix("SECURITY_X_FRAME_OPTIONS") {
config.x_frame_options = match frame_options.to_uppercase().as_str() {
"DENY" => Some(XFrameOptions::Deny),
"SAMEORIGIN" => Some(XFrameOptions::SameOrigin),
"DISABLE" | "OFF" => None,
_ => Some(XFrameOptions::default()),
};
}
if let Some(csp) = get_env_with_prefix("SECURITY_CSP") {
config.content_security_policy = Some(csp);
}
if let Some(referrer) = get_env_with_prefix("SECURITY_REFERRER_POLICY") {
config.referrer_policy = match referrer.to_lowercase().as_str() {
"no-referrer" => Some(ReferrerPolicy::NoReferrer),
"same-origin" => Some(ReferrerPolicy::SameOrigin),
"strict-origin-when-cross-origin" => {
Some(ReferrerPolicy::StrictOriginWhenCrossOrigin)
}
"strict-origin" => Some(ReferrerPolicy::StrictOrigin),
"unsafe-url" => Some(ReferrerPolicy::UnsafeUrl),
"disable" | "off" => None,
_ => Some(ReferrerPolicy::default()),
};
}
if let Some(permissions) = get_env_with_prefix("SECURITY_PERMISSIONS_POLICY") {
config.permissions_policy = Some(permissions);
}
if !is_production_like_environment() && !explicit_hsts_max_age {
config.hsts_max_age = 0;
config.hsts_preload = false;
}
config
}
}
fn parse_bool_with_default(value: &str, default: bool) -> bool {
value.parse().unwrap_or(default)
}
fn is_production_like_environment() -> bool {
[
get_env_with_prefix("ENV"),
get_env_with_prefix("ENVIRONMENT"),
get_env_with_prefix("APP_ENV"),
get_env_with_prefix("TIDEWAY_ENV"),
]
.into_iter()
.flatten()
.any(|env_value| matches!(env_value.to_lowercase().as_str(), "prod" | "production"))
}
#[must_use = "builder does nothing until you call build()"]
pub struct SecurityConfigBuilder {
config: SecurityConfig,
}
impl SecurityConfigBuilder {
pub fn new() -> Self {
Self {
config: SecurityConfig::default(),
}
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
pub fn hsts_max_age(mut self, seconds: u64) -> Self {
self.config.hsts_max_age = seconds;
self
}
pub fn hsts_preload(mut self, preload: bool) -> Self {
self.config.hsts_preload = preload;
self
}
pub fn hsts_include_subdomains(mut self, include: bool) -> Self {
self.config.hsts_include_subdomains = include;
self
}
pub fn nosniff(mut self, enabled: bool) -> Self {
self.config.nosniff = enabled;
self
}
pub fn x_frame_options(mut self, options: Option<XFrameOptions>) -> Self {
self.config.x_frame_options = options;
self
}
pub fn deny_framing(mut self) -> Self {
self.config.x_frame_options = Some(XFrameOptions::Deny);
self
}
pub fn same_origin_framing(mut self) -> Self {
self.config.x_frame_options = Some(XFrameOptions::SameOrigin);
self
}
pub fn allow_framing(mut self) -> Self {
self.config.x_frame_options = None;
self
}
pub fn xss_protection(mut self, enabled: Option<bool>) -> Self {
self.config.xss_protection = enabled;
self
}
pub fn content_security_policy(mut self, csp: Option<String>) -> Self {
self.config.content_security_policy = csp;
self
}
pub fn referrer_policy(mut self, policy: Option<ReferrerPolicy>) -> Self {
self.config.referrer_policy = policy;
self
}
pub fn permissions_policy(mut self, policy: Option<String>) -> Self {
self.config.permissions_policy = policy;
self
}
pub fn build(self) -> SecurityConfig {
self.config
}
}
impl Default for SecurityConfigBuilder {
fn default() -> Self {
Self::new()
}
}
fn default_enabled() -> bool {
true
}
fn default_hsts_max_age() -> u64 {
31536000 }
fn default_nosniff() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SecurityConfig::default();
assert!(config.enabled);
assert_eq!(config.hsts_max_age, 31536000);
assert!(config.nosniff);
assert_eq!(config.x_frame_options, Some(XFrameOptions::Deny));
}
#[test]
fn test_builder() {
let config = SecurityConfig::builder()
.hsts_max_age(63072000) .deny_framing()
.nosniff(true)
.build();
assert_eq!(config.hsts_max_age, 63072000);
assert_eq!(config.x_frame_options, Some(XFrameOptions::Deny));
assert!(config.nosniff);
}
#[test]
fn test_framing_options() {
let config = SecurityConfig::builder().same_origin_framing().build();
assert_eq!(config.x_frame_options, Some(XFrameOptions::SameOrigin));
let config = SecurityConfig::builder().allow_framing().build();
assert_eq!(config.x_frame_options, None);
}
#[test]
fn test_from_env_invalid_bool_falls_back_to_defaults() {
unsafe {
std::env::set_var("TIDEWAY_SECURITY_ENABLED", "not-bool");
std::env::set_var("TIDEWAY_SECURITY_NOSNIFF", "maybe");
}
let config = SecurityConfig::from_env();
assert!(config.enabled);
assert!(config.nosniff);
unsafe {
std::env::remove_var("TIDEWAY_SECURITY_ENABLED");
std::env::remove_var("TIDEWAY_SECURITY_NOSNIFF");
}
}
#[test]
fn test_from_env_disables_hsts_in_non_prod_without_override() {
unsafe {
std::env::set_var("TIDEWAY_ENV", "development");
}
let config = SecurityConfig::from_env();
assert_eq!(config.hsts_max_age, 0);
assert!(!config.hsts_preload);
unsafe {
std::env::remove_var("TIDEWAY_ENV");
}
}
#[test]
fn test_from_env_keeps_hsts_override_in_non_prod() {
unsafe {
std::env::set_var("TIDEWAY_ENV", "development");
std::env::set_var("TIDEWAY_SECURITY_HSTS_MAX_AGE", "86400");
}
let config = SecurityConfig::from_env();
assert_eq!(config.hsts_max_age, 86400);
unsafe {
std::env::remove_var("TIDEWAY_ENV");
std::env::remove_var("TIDEWAY_SECURITY_HSTS_MAX_AGE");
}
}
}