This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 22:53:33 +02:00
1 parent 174389e5b1
commit e6604e41fc
73 files changed
+1240 -201

No files matched your search

+65
View File
@@ -45,6 +45,59 @@ impl Default for ProxyTimeouts {
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum MethodPolicyMode {
#[default]
Off,
Allow,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default, deny_unknown_fields)]
pub struct MethodPolicy {
pub mode: MethodPolicyMode,
pub methods: Vec<String>,
}
impl MethodPolicy {
pub fn validate(&mut self) -> Result<()> {
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
let mut seen = std::collections::HashSet::new();
for method in &mut self.methods {
*method = method.trim().to_ascii_uppercase();
ensure!(
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
"invalid HTTP method in method policy"
);
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
}
ensure!(
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
"method allow/block policy requires at least one method"
);
Ok(())
}
pub fn allows(&self, method: &http::Method) -> bool {
let listed = self
.methods
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(method.as_str()));
match self.mode {
MethodPolicyMode::Off => true,
MethodPolicyMode::Allow => listed,
MethodPolicyMode::Block => !listed,
}
}
pub fn allow_header(&self) -> Option<http::HeaderValue> {
(self.mode == MethodPolicyMode::Allow)
.then(|| self.methods.join(", ").parse::<http::HeaderValue>().ok())
.flatten()
}
}
impl ProxyTimeouts {
pub fn validate(&self) -> Result<()> {
for (name, value, max) in [
@@ -204,6 +257,9 @@ pub struct Vhost {
pub backend_timing_header: bool,
#[serde(default)]
pub rate_limit: crate::rate_limit::Settings,
/// Optional allow/block policy for HTTP methods. Vhost and location policies stack.
#[serde(default)]
pub method_policy: MethodPolicy,
#[serde(default)]
pub basic_auth: crate::basic_auth::Settings,
pub id: String,
@@ -403,8 +459,17 @@ pub struct PathRoute {
pub enabled: bool,
#[serde(default)]
pub cache: Option<bool>,
/// Optional reusable ACL profile applied in addition to vhost policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acl_profile_id: Option<String>,
#[serde(default)]
pub acl: Option<Acl>,
/// Location-scoped country allow/block policy, stacked with vhost/profile policy.
#[serde(default)]
pub country_access: crate::rate_limit::CountryAccess,
/// Location-scoped HTTP method allow/block policy, stacked with vhost policy.
#[serde(default)]
pub method_policy: MethodPolicy,
#[serde(default)]
pub headers: HeaderRules,
/// Optional origin-path transformation. Query parameters are preserved.
+24
View File
@@ -82,6 +82,30 @@ mod default_listener_tests {
);
}
#[test]
fn method_policy_normalizes_and_enforces_allow_and_block_modes() {
let mut allow: MethodPolicy = serde_json::from_value(json!({
"mode": "allow",
"methods": ["get", "HEAD", "post"]
}))
.unwrap();
allow.validate().unwrap();
assert_eq!(allow.methods, vec!["GET", "HEAD", "POST"]);
assert!(allow.allows(&http::Method::GET));
assert!(!allow.allows(&http::Method::DELETE));
assert_eq!(allow.allow_header().unwrap().to_str().unwrap(), "GET, HEAD, POST");
let mut block: MethodPolicy = serde_json::from_value(json!({
"mode": "block",
"methods": ["delete"]
}))
.unwrap();
block.validate().unwrap();
assert!(!block.allows(&http::Method::DELETE));
assert!(block.allows(&http::Method::GET));
assert!(block.allow_header().is_none());
}
#[test]
fn h2c_is_cleartext_only_and_defaults_off() {
let plain: Listener =
+14 -1
View File
@@ -8,7 +8,10 @@ impl Default for PathRoute {
match_type: PathMatch::Prefix,
enabled: true,
cache: None,
acl_profile_id: None,
acl: None,
country_access: crate::rate_limit::CountryAccess::default(),
method_policy: MethodPolicy::default(),
headers: HeaderRules::default(),
rewrite: PathRewrite::default(),
response: None,
@@ -28,7 +31,11 @@ impl PathRoute {
&& self.source == Source::ReverseProxy
&& self.backend_id.is_none()
&& self.cache.is_none()
&& self.acl_profile_id.is_none()
&& self.acl.is_none()
&& self.country_access.mode == crate::rate_limit::CountryMode::Off
&& self.country_access.countries.is_empty()
&& self.method_policy == MethodPolicy::default()
&& self.headers.is_empty()
&& self.rewrite.mode == RewriteMode::Preserve
&& self.rewrite.value.is_empty()
@@ -67,6 +74,7 @@ impl Vhost {
if let Some(id) = &self.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
}
self.method_policy.validate()?;
ensure!(
!self.frontend.listener_ids.is_empty(),
"vhost requires at least one named listener"
@@ -111,8 +119,13 @@ impl Vhost {
// Normalize the untouched pre-0.16 synthetic root route out of the custom
// location list so adding/removing Custom locations cannot delete fallback.
self.routes.retain(|route| !route.is_legacy_main_fallback());
for route in &self.routes {
for route in &mut self.routes {
route.source.validate()?;
if let Some(id) = &route.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
}
route.country_access.validate()?;
route.method_policy.validate()?;
ensure!(
route.source.is_reverse_proxy() || route.backend_id.is_none(),
"Web Server location cannot pin a reverse-proxy backend"