v0.24.13
This commit is contained in:
1 parent
bd077754a9
commit
ed7e3377c1
40 files changed
+1764
-365
No files matched your search
@@ -6,6 +6,7 @@ pub fn compile_with_active(
|
||||
previous: Option<&Snapshot>,
|
||||
active: &[crate::vhost::Listener],
|
||||
) -> Result<Snapshot> {
|
||||
config.migrate_legacy_user_agent_blocking()?;
|
||||
config.administration.validate(&config.tls)?;
|
||||
config.logging.validate()?;
|
||||
config.error_log.validate()?;
|
||||
@@ -254,7 +255,7 @@ pub fn compile_with_active(
|
||||
guarded_bindings.sort();
|
||||
guarded_bindings.dedup();
|
||||
let global_headers = config.headers.compile()?;
|
||||
let user_agent_blocking = config.user_agent_blocking.compile()?;
|
||||
let global_security = config.global_security.compile()?;
|
||||
ensure!(
|
||||
config.host_policies.len() <= 256,
|
||||
"too many host ACL policies"
|
||||
@@ -368,7 +369,7 @@ pub fn compile_with_active(
|
||||
redirects,
|
||||
not_found_hosts,
|
||||
host_acls,
|
||||
user_agent_blocking,
|
||||
global_security,
|
||||
headers: global_headers,
|
||||
})
|
||||
}
|
||||
|
||||
+70
-2
@@ -70,13 +70,81 @@ pub struct Config {
|
||||
pub vhosts: Vec<crate::vhost::Vhost>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_policies: Vec<HostPolicy>,
|
||||
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
|
||||
// Migration happens in compile_with_active, before validation and persistence.
|
||||
#[serde(default, rename = "user_agent_blocking", skip_serializing_if = "Option::is_none")]
|
||||
pub legacy_user_agent_blocking: Option<crate::user_agent::Policy>,
|
||||
#[serde(default)]
|
||||
pub user_agent_blocking: crate::user_agent::Policy,
|
||||
pub global_security: GlobalSecurity,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub acl_profiles: Vec<AclProfile>,
|
||||
#[serde(default, skip_serializing_if = "HeaderRules::is_empty")]
|
||||
pub headers: HeaderRules,
|
||||
}
|
||||
/// Global HTTP public-listener security. Disabled defaults preserve older configurations.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GlobalSecurity {
|
||||
// None distinguishes omitted legacy input from an explicitly configured empty policy.
|
||||
// Successful compilation always normalizes this to Some(...).
|
||||
pub user_agent_blocking: Option<crate::user_agent::Policy>,
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
pub acl: Option<Acl>,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub content_type_policy: crate::vhost::ContentTypePolicy,
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
pub security_headers: crate::security::SecurityHeaders,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledGlobalSecurity {
|
||||
pub user_agent_blocking: crate::user_agent::CompiledPolicy,
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
pub acl: Option<CompiledAcl>,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub content_type_policy: crate::vhost::ContentTypePolicy,
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
pub headers: crate::security::CompiledSecurity,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn migrate_legacy_user_agent_blocking(&mut self) -> Result<()> {
|
||||
if let Some(legacy) = self.legacy_user_agent_blocking.take() {
|
||||
match &self.global_security.user_agent_blocking {
|
||||
Some(current) => ensure!(
|
||||
*current == legacy,
|
||||
"conflicting legacy user_agent_blocking and global_security.user_agent_blocking policies"
|
||||
),
|
||||
None => self.global_security.user_agent_blocking = Some(legacy),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalSecurity {
|
||||
pub fn compile(&mut self) -> Result<CompiledGlobalSecurity> {
|
||||
let user_agent_blocking = self.user_agent_blocking
|
||||
.get_or_insert_with(Default::default)
|
||||
.compile()?;
|
||||
self.country_access.validate()?;
|
||||
self.method_policy.validate()?;
|
||||
self.content_type_policy.validate()?;
|
||||
self.rate_limit.validate()?;
|
||||
let acl = self.acl.as_ref().map(Acl::compile).transpose()?;
|
||||
let headers = self.security_headers.compile()?;
|
||||
Ok(CompiledGlobalSecurity {
|
||||
user_agent_blocking,
|
||||
country_access: self.country_access.clone(),
|
||||
acl,
|
||||
method_policy: self.method_policy.clone(),
|
||||
content_type_policy: self.content_type_policy.clone(),
|
||||
rate_limit: self.rate_limit.clone(),
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostPolicy {
|
||||
@@ -275,7 +343,7 @@ pub struct Snapshot {
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
host_acls: HashMap<String, Arc<CompiledAcl>>,
|
||||
pub user_agent_blocking: crate::user_agent::CompiledPolicy,
|
||||
pub global_security: CompiledGlobalSecurity,
|
||||
pub headers: CompiledHeaders,
|
||||
}
|
||||
impl std::fmt::Debug for Snapshot {
|
||||
|
||||
@@ -1184,4 +1184,131 @@ mod tests {
|
||||
assert_eq!(frontend.profile_policies.len(), 1);
|
||||
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({
|
||||
"user_agent_blocking": {"enabled":true,"patterns":["bad-bot"]}
|
||||
})).unwrap();
|
||||
assert_eq!(config.global_security.country_access.mode, crate::rate_limit::CountryMode::Off);
|
||||
assert!(config.global_security.acl.is_none());
|
||||
assert_eq!(config.global_security.method_policy.mode, crate::vhost::MethodPolicyMode::Off);
|
||||
assert!(!config.global_security.content_type_policy.enabled);
|
||||
assert!(!config.global_security.rate_limit.enabled);
|
||||
assert!(!config.global_security.security_headers.hsts.enabled);
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("BAD-BOT/2"));
|
||||
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers));
|
||||
assert!(snapshot.envelope.config.legacy_user_agent_blocking.is_none());
|
||||
let canonical = serde_json::to_value(&snapshot.envelope.config).unwrap();
|
||||
assert!(canonical.get("user_agent_blocking").is_none());
|
||||
assert_eq!(canonical["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot");
|
||||
assert!(snapshot.global_security.country_access.allows(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_user_agent_migration_rejects_conflicting_dual_configuration() {
|
||||
let old_and_new: Config = serde_json::from_value(serde_json::json!({
|
||||
"user_agent_blocking": { "enabled":true,"patterns":["old-crawler"] },
|
||||
"global_security": {
|
||||
"user_agent_blocking": { "enabled":false,"patterns":[] }
|
||||
}
|
||||
})).unwrap();
|
||||
let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("conflicting legacy"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_global_user_agent_policy_survives_json_roundtrip() {
|
||||
let mut config = Config::default();
|
||||
config.global_security.user_agent_blocking = Some(crate::user_agent::Policy {
|
||||
enabled: true,
|
||||
patterns: vec!["trusted-test-bot".into()],
|
||||
});
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert!(json.get("user_agent_blocking").is_none());
|
||||
assert!(json["global_security"]["user_agent_blocking"]["enabled"].as_bool().unwrap());
|
||||
let restored: Config = serde_json::from_value(json).unwrap();
|
||||
let snapshot = compile(restored, 1, &policy()).unwrap();
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("trusted-test-bot/1"));
|
||||
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_security_compiles_country_ip_method_mime_rate_and_https_headers() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let global = &mut config.global_security;
|
||||
global.country_access.mode = crate::rate_limit::CountryMode::Block;
|
||||
global.country_access.countries = vec!["ru".into()];
|
||||
global.acl = Some(crate::acl::Acl {
|
||||
default: crate::acl::Action::Allow,
|
||||
rules: vec![crate::acl::Rule {
|
||||
network: "192.0.2.0/24".into(),
|
||||
action: crate::acl::Action::Deny,
|
||||
}],
|
||||
});
|
||||
global.method_policy.mode = crate::vhost::MethodPolicyMode::Block;
|
||||
global.method_policy.methods = vec!["trace".into()];
|
||||
global.content_type_policy.enabled = true;
|
||||
global.content_type_policy.blocked = vec!["application/x-danger".into()];
|
||||
global.rate_limit.enabled = true;
|
||||
global.security_headers.nosniff = true;
|
||||
global.security_headers.hsts.enabled = true;
|
||||
global.security_headers.frame_options = crate::security::FrameOptions::Deny;
|
||||
|
||||
let snapshot = compile(config, 2, &policy()).unwrap();
|
||||
let rules = &snapshot.global_security;
|
||||
assert_eq!(snapshot.envelope.config.global_security.country_access.countries, vec!["RU"]);
|
||||
assert!(!rules.country_access.allows(Some("RU")));
|
||||
assert!(rules.country_access.allows(None));
|
||||
assert!(!rules.acl.as_ref().unwrap().allows("192.0.2.2".parse().unwrap()));
|
||||
assert!(rules.acl.as_ref().unwrap().allows("198.51.100.1".parse().unwrap()));
|
||||
assert!(!rules.method_policy.allows(&http::Method::TRACE));
|
||||
assert!(rules.method_policy.allows(&http::Method::GET));
|
||||
assert!(rules.rate_limit.enabled);
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/x-danger; charset=utf-8"));
|
||||
assert!(rules.content_type_policy.blocks(&headers));
|
||||
headers.clear();
|
||||
rules.headers.apply(&mut headers, true);
|
||||
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
|
||||
assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
|
||||
assert!(headers.contains_key("strict-transport-security"));
|
||||
rules.headers.apply(&mut headers, false);
|
||||
assert!(!headers.contains_key("strict-transport-security"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_security_rejects_invalid_country_method_and_hsts() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.global_security.country_access.mode = crate::rate_limit::CountryMode::Allow;
|
||||
assert!(compile(config.clone(), 1, &policy()).is_err());
|
||||
config.global_security.country_access.countries = vec!["PL".into()];
|
||||
config.global_security.method_policy.mode = crate::vhost::MethodPolicyMode::Allow;
|
||||
assert!(compile(config.clone(), 1, &policy()).is_err());
|
||||
config.global_security.method_policy.methods = vec!["GET".into()];
|
||||
config.global_security.security_headers.hsts.preload = true;
|
||||
assert!(compile(config.clone(), 1, &policy()).is_err());
|
||||
config.global_security.security_headers.hsts.enabled = true;
|
||||
config.global_security.security_headers.hsts.include_subdomains = true;
|
||||
assert!(compile(config, 1, &policy()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_acl_dns_hosts_join_dynamic_resolver_inventory() {
|
||||
let mut config = Config::default();
|
||||
config.global_security.acl = Some(crate::acl::Acl {
|
||||
default: crate::acl::Action::Deny,
|
||||
rules: vec![crate::acl::Rule {
|
||||
network: "external.example.test".into(),
|
||||
action: crate::acl::Action::Allow,
|
||||
}],
|
||||
});
|
||||
assert_eq!(
|
||||
crate::acl::dynamic::configured_hostnames(&config).unwrap(),
|
||||
vec!["external.example.test".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user