v0.34.4
This commit is contained in:
1 parent
1fd81eaf5f
commit
ee174bc283
36 files changed
+908
-183
No files matched your search
+104
-30
@@ -48,29 +48,58 @@ pub fn compile_with_active(
|
||||
config.compression.validate()?;
|
||||
config.server_header.validate()?;
|
||||
let forwarding = config.forwarding.compile()?;
|
||||
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
|
||||
ensure!(
|
||||
config.php_fpm_profiles.len() <= 128,
|
||||
"too many PHP-FPM profiles (max 128)"
|
||||
);
|
||||
let mut php_profiles = HashMap::new();
|
||||
for profile in &mut config.php_fpm_profiles {
|
||||
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
|
||||
"duplicate PHP-FPM profile id");
|
||||
profile
|
||||
.validate()
|
||||
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(
|
||||
php_profiles
|
||||
.insert(profile.id.clone(), profile.php.clone())
|
||||
.is_none(),
|
||||
"duplicate PHP-FPM profile id"
|
||||
);
|
||||
}
|
||||
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
|
||||
ensure!(
|
||||
config.web_services.len() <= 128,
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
let mut web_services = HashMap::new();
|
||||
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
|
||||
// workers. Different directive sets on one socket can leak to the next vhost.
|
||||
// Report unsafe legacy configurations but keep them loadable for migration.
|
||||
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
|
||||
let mut php_endpoint_policies: HashMap<
|
||||
String,
|
||||
(
|
||||
String,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
),
|
||||
> = HashMap::new();
|
||||
for service in &mut config.web_services {
|
||||
service.validate().with_context(|| format!("Web Service {}", service.id))?;
|
||||
service
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {}", service.id))?;
|
||||
// A profile changes effective runtime settings, not the saved standalone
|
||||
// PHP configuration. Detaching a profile restores local settings exactly.
|
||||
let mut effective = service.clone();
|
||||
if let Some(id) = &service.php_profile_id {
|
||||
effective.php = php_profiles.get(id).with_context(|| {
|
||||
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
|
||||
})?.clone();
|
||||
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
effective.php = php_profiles
|
||||
.get(id)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Web Service {} references unknown PHP-FPM profile {}",
|
||||
service.id, id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
effective
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
}
|
||||
if effective.php.enabled {
|
||||
let php = &effective.php;
|
||||
@@ -82,13 +111,25 @@ pub fn compile_with_active(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
php_endpoint_policies.insert(php.endpoint.clone(), (
|
||||
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
|
||||
));
|
||||
php_endpoint_policies.insert(
|
||||
php.endpoint.clone(),
|
||||
(
|
||||
effective.id.clone(),
|
||||
php.php_values.clone(),
|
||||
php.php_admin_values.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
|
||||
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
|
||||
let compiled = Arc::new(
|
||||
effective
|
||||
.compile()
|
||||
.with_context(|| format!("Web Service {}", service.id))?,
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id"
|
||||
);
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
let mut vhost_ids = HashSet::new();
|
||||
@@ -267,7 +308,9 @@ pub fn compile_with_active(
|
||||
// Prevent unbounded DNS ACL work across the entire published configuration.
|
||||
crate::acl::dynamic::configured_hostnames(&config)?;
|
||||
for agent in &config.docker_agents.agents {
|
||||
if let Some(profile_id) = agent.acl_profile_id.as_deref() {
|
||||
for profile_id in
|
||||
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
|
||||
{
|
||||
ensure!(
|
||||
acl_profiles.contains_key(profile_id),
|
||||
"Docker agent {} references unknown default ACL profile {}",
|
||||
@@ -276,7 +319,10 @@ pub fn compile_with_active(
|
||||
);
|
||||
}
|
||||
for assignment in &agent.assignments {
|
||||
if let Some(profile_id) = assignment.acl_profile_id.as_deref() {
|
||||
for profile_id in crate::acl::profile_ids(
|
||||
assignment.acl_profile_id.as_deref(),
|
||||
&assignment.acl_profile_ids,
|
||||
) {
|
||||
ensure!(
|
||||
acl_profiles.contains_key(profile_id),
|
||||
"Docker assignment {} on agent {} references unknown ACL profile {}",
|
||||
@@ -395,7 +441,9 @@ fn validate_route_rewrite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
fn validate_location_redirect(
|
||||
redirect: Option<&crate::vhost::LocationRedirect>,
|
||||
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
let Some(redirect) = redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -411,24 +459,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
|
||||
);
|
||||
// Replace capture references with a harmless segment so URL structure can
|
||||
// be validated before requests reach the data plane.
|
||||
let authority_end = redirect.target.find("://").and_then(|start| {
|
||||
let rest = &redirect.target[start + 3..];
|
||||
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
|
||||
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
|
||||
let authority_end = redirect
|
||||
.target
|
||||
.find("://")
|
||||
.and_then(|start| {
|
||||
let rest = &redirect.target[start + 3..];
|
||||
rest.char_indices()
|
||||
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
|
||||
.map(|(offset, _)| start + 3 + offset)
|
||||
.or(Some(redirect.target.len()))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(
|
||||
!redirect.target[..authority_end].contains('$'),
|
||||
"location redirect captures are not allowed in the URL authority"
|
||||
);
|
||||
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
|
||||
.expect("static capture regex")
|
||||
.replace_all(&redirect.target, "capture");
|
||||
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
|
||||
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
|
||||
let parsed =
|
||||
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(
|
||||
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
|
||||
"location redirect target must use http/https and include a host"
|
||||
);
|
||||
ensure!(
|
||||
parsed.username().is_empty() && parsed.password().is_none(),
|
||||
"location redirect target cannot contain credentials"
|
||||
);
|
||||
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(pattern) => {
|
||||
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
|
||||
ensure!(
|
||||
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
|
||||
"invalid location redirect rewrite regex"
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(pattern);
|
||||
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid location redirect rewrite regex")?)
|
||||
builder
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(
|
||||
builder
|
||||
.build()
|
||||
.context("invalid location redirect rewrite regex")?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Some(crate::config::CompiledLocationRedirect {
|
||||
|
||||
@@ -263,6 +263,8 @@ pub struct CompiledFrontend {
|
||||
pub acl_profile_id: Option<String>,
|
||||
pub profile_acl: Option<CompiledAcl>,
|
||||
pub profile_country_access: crate::rate_limit::CountryAccess,
|
||||
/// All Vhost profile scopes, checked before the inline ACL.
|
||||
pub profile_policies: Vec<crate::acl::ProfileAccess>,
|
||||
pub acl: Option<CompiledAcl>,
|
||||
pub headers: CompiledHeaders,
|
||||
pub security: crate::security::CompiledSecurity,
|
||||
|
||||
+11
-5
@@ -173,12 +173,17 @@ fn compile_routing(
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
||||
.transpose()?;
|
||||
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() {
|
||||
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
let profile_policies: Vec<crate::acl::ProfileAccess> = crate::acl::profile_ids(
|
||||
host.acl_profile_id.as_deref(), &host.acl_profile_ids,
|
||||
).map(|id| {
|
||||
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?,
|
||||
None => (None, crate::rate_limit::CountryAccess::default()),
|
||||
};
|
||||
})?;
|
||||
Ok(crate::acl::ProfileAccess { acl, countries })
|
||||
}).collect::<Result<_>>()?;
|
||||
let (profile_acl, profile_country_access) = profile_policies.first()
|
||||
.map(|entry| (entry.acl.clone(), entry.countries.clone()))
|
||||
.unwrap_or_else(|| (None, crate::rate_limit::CountryAccess::default()));
|
||||
let frontend = Arc::new(CompiledFrontend {
|
||||
metrics,
|
||||
websockets: host.websockets,
|
||||
@@ -215,6 +220,7 @@ fn compile_routing(
|
||||
acl_profile_id: host.acl_profile_id.clone(),
|
||||
profile_acl,
|
||||
profile_country_access,
|
||||
profile_policies,
|
||||
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
|
||||
headers: host.headers.compile()?,
|
||||
security: host.security_headers.compile()?,
|
||||
|
||||
+21
-14
@@ -1,9 +1,6 @@
|
||||
impl Snapshot {
|
||||
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
|
||||
fn find_host_pattern<T>(
|
||||
host: &str,
|
||||
mut lookup: impl FnMut(&str) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
|
||||
if let Some(value) = lookup(host) {
|
||||
return Some(value);
|
||||
}
|
||||
@@ -136,11 +133,7 @@ impl Snapshot {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (candidate, matched_by) in [
|
||||
(authority, "authority"),
|
||||
(host, "host"),
|
||||
(sni, "sni"),
|
||||
] {
|
||||
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
|
||||
let Some(candidate) = candidate else {
|
||||
continue;
|
||||
};
|
||||
@@ -212,7 +205,10 @@ impl Snapshot {
|
||||
/// Legacy inspection helper only; network handlers must call select_on.
|
||||
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
|
||||
let fallback = self
|
||||
.fallbacks
|
||||
.get(pattern)
|
||||
.and_then(|routes| routes.first())?;
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
@@ -269,17 +265,25 @@ where
|
||||
prefix
|
||||
}
|
||||
impl CompiledRoute {
|
||||
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
|
||||
pub fn redirect_location(
|
||||
&self,
|
||||
path: &str,
|
||||
uri: &http::Uri,
|
||||
) -> Result<Option<http::HeaderValue>> {
|
||||
let Some(spec) = &self.redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target = if let Some(regex) = &spec.rewrite_regex {
|
||||
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
|
||||
ensure!(
|
||||
regex.is_match(path),
|
||||
"location redirect rewrite regex did not match"
|
||||
);
|
||||
regex.replace(path, spec.target.as_str()).into_owned()
|
||||
} else {
|
||||
spec.target.clone()
|
||||
};
|
||||
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
|
||||
let mut target =
|
||||
url::Url::parse(&target).context("invalid compiled location redirect target")?;
|
||||
if spec.preserve_query {
|
||||
if let Some(query) = uri.query() {
|
||||
let merged = match target.query() {
|
||||
@@ -289,7 +293,10 @@ impl CompiledRoute {
|
||||
target.set_query(Some(&merged));
|
||||
}
|
||||
}
|
||||
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
|
||||
Ok(Some(
|
||||
http::HeaderValue::from_str(target.as_str())
|
||||
.context("invalid redirect Location header")?,
|
||||
))
|
||||
}
|
||||
/// Return a normalized replacement path for the upstream hop. `None` means
|
||||
/// preserve the client's exact origin-form path bytes.
|
||||
|
||||
@@ -1054,4 +1054,42 @@ fn stack_host_lookup_preserves_long_internal_host_fallback() {
|
||||
assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260)));
|
||||
assert_eq!(patterns[2], "*.sub.example.test");
|
||||
}
|
||||
#[test]
|
||||
fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.acl_profiles = serde_json::from_value(serde_json::json!([
|
||||
{"id":"office","name":"Office","acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}},
|
||||
{"id":"home","name":"Home","acl":{"default":"deny","rules":[{"network":"192.0.2.0/24","action":"allow"}]}}
|
||||
])).unwrap();
|
||||
c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()];
|
||||
c.vhosts[0].acl = Some(crate::acl::Acl {
|
||||
default: crate::acl::Action::Deny,
|
||||
rules: vec![crate::acl::Rule { network: "10.0.0.0/8".into(), action: crate::acl::Action::Allow }],
|
||||
});
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.profile_policies.len(), 2);
|
||||
assert!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None));
|
||||
assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap()));
|
||||
|
||||
c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()];
|
||||
assert!(compile(c.clone(), 2, &policy()).is_err());
|
||||
c.vhosts[0].acl_profile_ids = vec!["office".into(), "office".into()];
|
||||
assert!(compile(c, 3, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_vhost_profile_field_survives_json_roundtrip() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.acl_profiles = serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap();
|
||||
c.vhosts[0].acl_profile_id = Some("office".into());
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let decoded: Config = serde_json::from_str(&json).unwrap();
|
||||
assert!(decoded.vhosts[0].acl_profile_ids.is_empty());
|
||||
let snapshot = compile(decoded, 1, &policy()).unwrap();
|
||||
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.profile_policies.len(), 1);
|
||||
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user