0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

-13
View File
@@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout(
Host::Ipv6(ip) => {
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr)
}
// DNS destinations remain subject to the normal self-target guard, but
// are never trusted for administration-cookie passthrough. The special
// control-plane path must point at the listener explicitly by IP.
Host::Domain(_) => false,
};
// Exact local listener targets are rejected by AddressPolicy by default, including DNS
// answers. An explicit per-backend risk acceptance can override only that self-target
// guard; remote origins on any valid backend port, including 9090, remain valid.
let authority = match url.host().context("backend host missing")? {
Host::Ipv6(ip) => format!("[{ip}]:{port}"),
host => format!("{host}:{port}"),
@@ -120,7 +114,6 @@ pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Resu
(policy.proxy_addr, policy.tls_addr),
)
}
/// Validate both active sockets and the sockets that will be used after restart.
pub fn compile_with_defaults(
config: Config,
revision: u64,
@@ -129,8 +122,6 @@ pub fn compile_with_defaults(
) -> Result<Snapshot> {
compile_reusing(config, revision, policy, defaults, None)
}
/// Rebuild route policies and retain compatible backend clients, health observations,
/// full-exchange counters and affinity tokens through unrelated config edits.
pub fn compile_reusing(
config: Config,
revision: u64,
@@ -154,8 +145,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
for redirect in &config.redirects {
let bindings = redirect.frontend.resolved(&config.listeners)?;
// Disabled redirect hosts reserve their names as vhosts do, preventing
// an overlapping fallback from unexpectedly taking traffic.
for name in &redirect.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
@@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
}
for host in &config.not_found_hosts {
let bindings = host.frontend.resolved(&config.listeners)?;
// Disabled local-response hosts reserve names too, avoiding accidental
// exposure through a broader wildcard vhost.
for name in &host.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
+109 -45
View File
@@ -10,9 +10,6 @@ pub fn compile_with_active(
config.logging.validate()?;
config.error_log.validate()?;
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
// A persisted non-loopback address is only accepted with administration TLS.
// When `listen` is omitted, the startup/CLI address remains authoritative and
// its legacy plain-HTTP risk gate is enforced by main before compilation.
ensure!(
config.administration.listen.is_none()
|| desired_admin.ip().is_loopback()
@@ -48,29 +45,53 @@ 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))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
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 +103,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();
@@ -202,8 +235,6 @@ pub fn compile_with_active(
guarded_bindings.extend(previous.listeners.iter().map(|l| l.address));
}
guarded_bindings.extend(active.iter().map(|l| l.address));
// Startup/default addresses remain protected backend destinations even when
// the desired named-listener plan changes before a restart.
guarded_bindings.push(defaults.0);
guarded_bindings.extend(defaults.1);
guarded_bindings.extend([
@@ -264,22 +295,29 @@ pub fn compile_with_active(
"duplicate ACL profile id"
);
}
// Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents {
for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) {
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 {}",
agent.id, profile_id
agent.id,
profile_id
);
}
for assignment in &agent.assignments {
for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) {
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 {}",
assignment.service_key, agent.id, profile_id
assignment.service_key,
agent.id,
profile_id
);
}
}
@@ -392,7 +430,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);
};
@@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
&& !redirect.target.chars().any(char::is_control),
"invalid location redirect target"
);
// 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 {
+16 -9
View File
@@ -1,7 +1,4 @@
impl Snapshot {
/// Resolve a vhost-local internal named location. Internal locations are
/// deliberately excluded from ordinary URI matching and can only be reached
/// by an explicit internal dispatch such as an error fallback.
pub fn select_internal_on(
&self,
host: &str,
@@ -25,21 +22,31 @@ impl Snapshot {
Self::find_host_pattern(host, |pattern| {
let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| {
routes.iter().any(|route| {
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
});
if !owns_host {
return None;
}
// Some(None) stops at the owning hostname even when the named
// location is absent. Do not try a less-specific wildcard.
Some(self.routes.get(pattern).and_then(|routes| {
routes.iter().find(|route| {
route.internal
&& route.name == target
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
&& route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
}))
})
-26
View File
@@ -36,8 +36,6 @@ pub struct Config {
pub geoip_updates: crate::geoip::UpdateSettings,
#[serde(default)]
pub listeners: crate::settings::Listeners,
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
/// persist an explicit `binding.http2_settings` object.
#[serde(default)]
pub http2: crate::settings::Http2Connections,
#[serde(default)]
@@ -112,20 +110,14 @@ pub struct Backend {
pub address_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_ca_pem: Option<String>,
/// Optional DNS name used for TLS SNI and certificate hostname verification.
/// TCP/DNS connection targeting remains pinned to `url`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_server_name: Option<String>,
/// Verify the HTTPS backend certificate chain and server identity.
/// Disabled by default for reverse-proxy compatibility; enable explicitly when required.
#[serde(default)]
pub tls_verify: bool,
pub id: String,
pub url: String,
#[serde(default)]
pub allow_private: bool,
/// Explicitly allow a backend that resolves to a local RustProxy listener.
/// This can expose the control plane or create a proxy loop and is off by default.
#[serde(default)]
pub accept_local_listener_risk: bool,
#[serde(default)]
@@ -136,12 +128,8 @@ pub struct Backend {
pub enum UpstreamProtocol {
#[default]
Http1,
/// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge.
/// Retain its 0.32.2 meaning when loading unchanged saved configurations.
Http2,
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
Auto,
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
H2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -159,7 +147,6 @@ pub struct Update {
pub struct ValidationPolicy {
pub tls_addr: Option<SocketAddr>,
pub cert_dir: Option<PathBuf>,
/// Resolved runtime data directory. Exposed only through authenticated admin status.
pub data_dir: Option<PathBuf>,
pub proxy_addr: SocketAddr,
pub admin_addr: SocketAddr,
@@ -170,21 +157,14 @@ pub struct CompiledBackend {
pub client: crate::upstream::ProxyClient,
pub websocket_client: crate::upstream::ProxyClient,
pub id: String,
/// Configured network origin used by raw TCP probes and policy checks.
pub origin: String,
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
pub request_origin: String,
/// Parsed once at configuration publish; reused for every upstream request.
pub request_origin_uri: http::Uri,
pub host_header: HeaderValue,
pub preserve_host: bool,
pub protocol: UpstreamProtocol,
/// Whether backend TLS certificate chain/name verification is enabled.
/// Retained in the compiled snapshot for failure diagnostics only.
pub tls_verify: bool,
pub accept_local_listener_risk: bool,
/// True only when this backend URL names the configured administration socket
/// directly. Used by the guarded control-plane reverse-proxy path.
pub targets_admin_listener: bool,
}
#[derive(Debug, Clone)]
@@ -208,7 +188,6 @@ pub struct CompiledRoute {
pub frontend: Option<Arc<CompiledFrontend>>,
pub pool: Option<Arc<crate::balance::Pool>>,
pub cache: Option<bool>,
/// Vhost-local stable location ID (`main` for the main source).
pub name: String,
pub id: String,
pub internal: bool,
@@ -237,10 +216,8 @@ pub struct CompiledFrontend {
pub websocket_rfc8441: bool,
pub websocket_rfc9220: bool,
pub force_ssl: bool,
/// Optional quick www/non-www canonical redirect (from, to).
pub www_redirect: Option<(String, String)>,
pub forward_client_ip: bool,
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
pub https_redirect_port: Option<u16>,
pub http2_enabled: bool,
pub http3_enabled: bool,
@@ -264,7 +241,6 @@ 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,
@@ -295,7 +271,6 @@ pub struct Snapshot {
pub tls: Option<Arc<rustls::ServerConfig>>,
pub envelope: Envelope,
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
/// Main vhost backend fallback, kept separate from user Custom locations.
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
@@ -305,7 +280,6 @@ pub struct Snapshot {
}
impl std::fmt::Debug for Snapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// QUIC crypto has no Debug implementation; never format private keys.
f.debug_struct("Snapshot")
.field("revision", &self.envelope.revision)
.field("listeners", &self.listeners)
+99
View File
@@ -0,0 +1,99 @@
mod route_policy_tests {
use crate::{
acl::{routing_path, Acl, Action, Rule as AclRule},
config::{compile, demo_config, ValidationPolicy},
headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule},
vhost::{LocationResponse, PathRoute},
};
use http::HeaderMap;
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
fn local_route() -> PathRoute {
PathRoute {
id: "local-status".into(),
path_prefix: "/status".into(),
response: Some(LocationResponse {
status: 200,
content_type: "text/plain; charset=utf-8".into(),
body: "ok".into(),
}),
..PathRoute::default()
}
}
#[test]
fn repeated_slashes_route_normally_but_reach_upstream_unchanged() {
let raw_target = "/api//v1/items";
let routing_target = routing_path(raw_target).unwrap();
assert_eq!(routing_target, "/api/v1/items");
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes = vec![PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
..PathRoute::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", &routing_target).unwrap();
assert_eq!(selected.id, "demo/api");
assert_eq!(raw_target, "/api//v1/items");
}
#[test]
fn local_location_response_uses_response_rules_without_opening_backend() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.headers = HeaderRules {
request: vec![],
response: vec![HeaderRule {
op: Operation::Set,
name: "x-local-response".into(),
value: Some("yes".into()),
}],
};
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let response = selected.response.as_ref().expect("route must remain local");
assert_eq!(response.status, 200);
assert_eq!(response.body, "ok");
let mut headers = HeaderMap::new();
apply_headers(&mut headers, &selected.headers.response);
assert_eq!(headers.get("x-local-response").unwrap(), "yes");
assert!(selected.response.is_some());
}
#[test]
fn local_location_response_still_respects_route_acl() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.acl = Some(Acl {
default: Action::Deny,
rules: vec![AclRule {
network: "10.0.0.0/8".into(),
action: Action::Allow,
}],
});
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let acl = selected.acl.as_ref().expect("route ACL must be compiled");
assert!(acl.allows("10.1.2.3".parse().unwrap()));
assert!(!acl.allows("192.0.2.1".parse().unwrap()));
assert!(selected.response.is_some());
}
}
-6
View File
@@ -77,7 +77,6 @@ fn compile_routing(
|| host_bindings.iter().all(|l| l.tls),
"prefixed affinity cookies require TLS on every resolved frontend"
);
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
@@ -91,8 +90,6 @@ fn compile_routing(
}
claimed.extend(host_bindings.iter().copied());
}
// Backend definitions are irrelevant to a Web Server main source unless
// an enabled Custom Location explicitly uses Reverse Proxy.
let needs_proxy_pool = host.source.is_reverse_proxy()
|| host.routes.iter().any(|route| {
route.enabled
@@ -119,8 +116,6 @@ fn compile_routing(
None
} else {
let mut pool_settings = host.load_balancing.clone();
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none()
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|| host.backends.iter().any(|member| {
@@ -369,7 +364,6 @@ fn compile_routing(
if host.enabled && route.enabled {
let (source, route_pool, backend) =
if route.response.is_some() || route.redirect.is_some() {
// Local return/redirect is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
+21 -36
View File
@@ -1,13 +1,8 @@
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);
}
// Skip the final dot: only one label follows it.
let last_dot = host.rfind('.')?;
let mut wildcard = [0u8; 256];
wildcard[..2].copy_from_slice(b"*.");
@@ -25,7 +20,6 @@ impl Snapshot {
return Some(value);
}
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
// Preserve behavior for unusual overlong internal host inputs.
return Some(value);
}
}
@@ -40,7 +34,6 @@ impl Snapshot {
})
.unwrap_or(true)
}
/// RFC 8441 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
@@ -51,7 +44,6 @@ impl Snapshot {
})
})
}
/// RFC 9220 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
@@ -62,8 +54,6 @@ impl Snapshot {
})
})
}
/// Production routing always uses the TCP connection's local endpoint.
/// Exact hostnames win over the longest matching wildcard hostname.
pub fn select_on(
&self,
host: &str,
@@ -73,7 +63,6 @@ impl Snapshot {
) -> Option<&CompiledRoute> {
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
}
/// Shared compiled-route handle for middleware and forward().
pub fn select_on_arc(
&self,
host: &str,
@@ -87,7 +76,6 @@ impl Snapshot {
.iter()
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
})?;
// The claimed vhost owns its fallback ahead of broader wildcards.
self.routes
.get(pattern)
.and_then(|routes| {
@@ -112,7 +100,6 @@ impl Snapshot {
})
})
}
/// Resolve protocol-error attribution with the routing precedence and bindings.
pub fn resolve_logging_vhost(
&self,
local: SocketAddr,
@@ -136,11 +123,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;
};
@@ -156,7 +139,6 @@ impl Snapshot {
}
None
}
/// Select redirects using accepted socket, TLS state and normalized path.
pub fn redirect_on(
&self,
host: &str,
@@ -189,7 +171,6 @@ impl Snapshot {
})
})
}
/// Select a host-owned local 404/static response using the accepted socket.
pub fn not_found_host_on(
&self,
host: &str,
@@ -209,10 +190,12 @@ 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 +252,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,10 +280,11 @@ 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.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
use crate::vhost::RewriteMode;
let rewritten = match self.rewrite.mode {
@@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool {
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4.
pub fn normalize_host(value: &str) -> Result<String> {
let host = value
.strip_suffix('.')
@@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result<String> {
);
Ok(host)
}
/// Configured frontend patterns may use a single leading `*.` wildcard.
/// Runtime Host values remain exact and are normalized by `normalize_host`.
pub fn normalize_host_pattern(value: &str) -> Result<String> {
let value = value.trim();
if let Some(base) = value.strip_prefix("*.") {
@@ -395,13 +384,10 @@ pub fn normalize_host_pattern(value: &str) -> Result<String> {
pub fn request_host(value: &str) -> Result<String> {
ensure!(!value.contains('@'), "invalid Host");
let authority: Authority = value.parse().context("invalid Host authority")?;
// Authority::port() returns None for both an absent and an invalid port.
// Inspect the explicit suffix so an out-of-range port cannot bypass validation.
let raw_host = authority.host();
let suffix = &authority.as_str()[raw_host.len()..];
if !suffix.is_empty() {
let port = suffix.strip_prefix(':').context("invalid Host port")?;
// HTTP permits an empty port suffix, which selects the default port.
if !port.is_empty() {
let number: u16 = port.parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port");
@@ -461,7 +447,6 @@ pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
!(s[0] == 0x2001 && s[1] == 0x0db8),
"documentation address is not a backend"
);
// Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges.
let ula = s[0] & 0xfe00 == 0xfc00;
let global =
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
+1138 -1063
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
mod transport_tests {
use crate::config::{compile, demo_config, ValidationPolicy};
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
#[test]
fn local_listener_backend_requires_explicit_risk_acceptance() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let backend = &mut config.vhosts[0].backends[0];
backend.url = "http://127.0.0.1:9090".into();
backend.allow_private = true;
backend.accept_local_listener_risk = false;
let error = compile(config.clone(), 1, &policy())
.expect_err("local administration listener must be rejected by default");
let detail = format!("{error:#}");
assert!(
detail.contains("accept_local_listener_risk=true"),
"{detail}"
);
config.vhosts[0].backends[0].accept_local_listener_risk = true;
compile(config, 2, &policy())
.expect("explicit risk acceptance should permit the local listener target");
}
#[test]
fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].websockets = true;
config.vhosts[0].websocket_rfc8441 = true;
config.vhosts[0].http2_enabled = true;
let error = compile(config.clone(), 10, &policy())
.expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener");
assert!(error.to_string().contains("HTTP/2-capable listener"));
config.listeners.endpoints[0].binding.h2c = true;
let snapshot = compile(config, 11, &policy())
.expect("explicit h2c prior-knowledge listener should satisfy RFC 8441");
assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false));
}
}