This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:09:50 +02:00
1 parent 1fd81eaf5f
commit ee174bc283
36 files changed
+908 -183

No files matched your search

+104 -30
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+38
View File
@@ -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"));
}
}