This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 23:34:10 +02:00
1 parent 00218a72df
commit 4a3e4bcf8f
10 files changed
+745 -331

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
## 0.32.5
- Fix Rust `E0505` during dynamic ACL DNS resolution: give `lookup_host` an independently owned hostname, while returning the original normalized cache key. The resolver timeout, allowlist expiration and fail-closed behavior are unchanged.
- Add bounded DNS/DDNS hostnames as **allow-only** client ACL rules in reusable
profiles, host policies, vhosts, locations and Docker ACL assignments.
- Refresh A/AAAA answers asynchronously every 60 seconds and after config
+6 -6
View File
@@ -2,7 +2,7 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ad0382153576411fb19861072635c02bdf33de740d6f122d8f27bd9c52ce112a AGENTS.md
91c6942b3a0d24d61bcfd43f9ee9fc8f3f5d889aeaae63dde118849d64a283c5 CHANGELOG.md
d304d9da32d28b866176383b3b274e6ab020a54316d7e8ffe85cecd08e150f93 CHANGELOG.md
2f1de87bae58761fc8690ec7225520e3a16213c77cbbb5981e34f12a95810668 Cargo.lock
8c76ffaf4adba93df26e30f3dd163e0e5e201d069d1ae69c071ca42d7fc7264e Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
@@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
25a06e7ddbb95d286e849b2fc5ba4adeedbddba0bad948e55bd91d962fd606de docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
e79adbe06155b024adfe8d072abba36394b3c6ef2007920627c217e290c08a88 docs/TEST_REPORT.md
9cf4c4e27ba11f40143f5a158f91d72b6c88a0a612a448390428d7103bbedfb3 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
@@ -132,8 +132,8 @@ f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_s
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs
a0690d0d48bb92e53f4b94ce430ac434736ec2860c03ac4d50bd94df49706458 src/acl/dynamic.rs
2b452b2819f9af4f60c984f98b5fb1e4e79353dee4fad1add6ecb540b7be1c74 src/acl/dynamic/tests.rs
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs
a22e5ea6381a1eacafe650be0f5cd6e653773e31f6491a562fe0459a309fb7cc src/acl/tests.rs
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs
@@ -512,7 +512,7 @@ dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/d
f6a6d12275bc8ba627b4616a546d075511094bb064cd9058231cc86ca489743e tools/checks/dependency_upgrade_0312.py
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
3396e5a510787e70e2ad8d1fad90356af461a88fd84429525d56fa82113da0f3 tools/checks/docs_check.py
2d44f85ec083dedfee60dc15e65e040c6c208614411458b36a2fca8b5762f4f7 tools/checks/dynamic_acl_0325.py
3fc765a7c2723ec783f269cfebc29ad2c09a90f33ea4af58285f883a026c4fa2 tools/checks/dynamic_acl_0325.py
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
1b5ead59bb2ea0e1ff32bf085f791018149cb9cc29b071fae4fb98ea80010824 tools/checks/h3_navigation_0316.py
@@ -541,7 +541,7 @@ e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/r
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
c8bdf822bfc972614b47caf3d6bfb392f1c4c96b43fc0113b963a8def73b7e7d tools/checks/security_logs_0315_ui_test.mjs
518b6d271931df62fc61d21688399d3c9b1986b58201061ac566667a16334823 tools/checks/security_logs_0315_ui_test.mjs
011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs
+7 -9
View File
@@ -59,17 +59,13 @@ impl Acl {
ensure!(rule.network.len() <= 254, "ACL network is too long");
let target = if let Ok(network) = parse_network(&rule.network) {
if let IpNet::V6(net) = network {
ensure!(
net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
);
ensure!(net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules");
}
RuleMatch::Ip(network)
} else {
ensure!(
rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
ensure!(rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny");
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
};
rules.push((target, rule.action));
@@ -143,7 +139,9 @@ pub fn allows_with_country(
&& (ip_action == Some(Action::Allow)
|| acl.is_none_or(|value| value.default == Action::Allow))
}
crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
crate::rate_limit::CountryMode::Allow => {
ip_action == Some(Action::Allow) || country_listed
}
}
}
/// Validate the already-decoded routing representation used internally after
+4 -1
View File
@@ -86,9 +86,12 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
}
async fn resolve(name: String) -> (String, Option<Addresses>) {
// Keep the returned cache key owned independently of the DNS lookup future.
// The lookup result can retain the borrowed hostname until it is dropped.
let lookup_name = name.clone();
let answer = tokio::time::timeout(
Duration::from_secs(LOOKUP_SECONDS),
tokio::net::lookup_host((name.as_str(), 0u16)),
tokio::net::lookup_host((lookup_name.as_str(), 0u16)),
).await;
let mut ips = match answer {
Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::<Vec<_>>(),
+692 -207
View File
File diff suppressed because it is too large. Load diff
+28 -97
View File
@@ -48,58 +48,29 @@ 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;
@@ -111,25 +82,13 @@ 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();
@@ -436,9 +395,7 @@ 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);
};
@@ -454,50 +411,24 @@ fn validate_location_redirect(
);
// 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 {
+6 -11
View File
@@ -146,10 +146,7 @@ async fn run(
}
let renewal_observations = store.load_certificate_renewals().await?;
let geoip_format = snapshot.envelope.config.geoip_updates.format;
let geoip = match rust_proxy::geoip::Database::load_format(
&data_dir.join("geoip"),
geoip_format,
) {
let geoip = match rust_proxy::geoip::Database::load_format(&data_dir.join("geoip"), geoip_format) {
Ok(database) => std::sync::Arc::new(database),
Err(error) => {
tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed");
@@ -436,10 +433,7 @@ async fn run(
grace
};
if managed_update {
tracing::info!(
budget_seconds = cache_flush_budget.as_secs(),
"managed-update hot-to-cold cache handoff started"
);
tracing::info!(budget_seconds = cache_flush_budget.as_secs(), "managed-update hot-to-cold cache handoff started");
}
let flush_result = if managed_update {
tokio::time::timeout(
@@ -451,9 +445,10 @@ async fn run(
tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await
};
match flush_result {
Ok(moved) if managed_update => {
tracing::info!(moved, "managed-update hot-to-cold cache handoff completed")
}
Ok(moved) if managed_update => tracing::info!(
moved,
"managed-update hot-to-cold cache handoff completed"
),
Ok(moved) if moved > 0 => tracing::info!(
moved,
"flushed hot cache objects to cold storage before shutdown"
Regular → Executable
+1
View File
@@ -18,6 +18,7 @@ checks = {
]),
'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']),
'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl,
'resolver owns the cache key independently of the DNS future (E0505)': 'let lookup_name = name.clone();' in dns and 'lookup_host((lookup_name.as_str(), 0u16))' in dns and 'lookup_host((name.as_str(), 0u16))' not in dns,
'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns,
'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']),
'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'),
View File
File mode changed.
View File
File mode changed.