This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 15:59:37 +02:00
1 parent 65e082b9d1
commit 76cb4acef0
72 files changed
+6134 -4709

No files matched your search

+44
View File
@@ -367,6 +367,50 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
ensure!(
matches!(redirect.status, 301 | 302 | 303 | 307 | 308),
"location redirect status must be 301, 302, 303, 307 or 308"
);
ensure!(
!redirect.target.is_empty()
&& redirect.target.len() <= 2048
&& !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 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 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");
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")?)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
status: redirect.status,
target: redirect.target.clone(),
rewrite_regex,
preserve_query: redirect.preserve_query,
}))
}
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
let Some(response) = response else {
return Ok(());
+12
View File
@@ -182,6 +182,14 @@ pub enum CompiledSource {
WebServer(Arc<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledLocationRedirect {
pub status: u16,
pub target: String,
pub rewrite_regex: Option<regex::Regex>,
pub preserve_query: bool,
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
@@ -195,6 +203,7 @@ pub struct CompiledRoute {
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub redirect: Option<CompiledLocationRedirect>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl_profile_id: Option<String>,
@@ -213,6 +222,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>,
@@ -242,6 +253,7 @@ pub struct CompiledFrontend {
pub security: crate::security::CompiledSecurity,
pub compression: crate::compression::Overrides,
pub cache: crate::cache::Settings,
pub force_cache: crate::cache::UnsafeForce,
pub transform: crate::transform::Settings,
pub pages: crate::pages::VhostPages,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
+27 -7
View File
@@ -16,6 +16,8 @@ fn compile_routing(
let mut pools = Vec::new();
let mut vhost_metrics = std::collections::BTreeMap::new();
for host in &mut config.vhosts {
let routing_hosts = host.routing_hosts()?;
let www_redirect = host.www_redirect_pair()?;
let host_bindings = host.frontend.resolved(&config.listeners)?;
let tls_ports: HashSet<u16> = host_bindings
.iter()
@@ -76,7 +78,7 @@ fn compile_routing(
"prefixed affinity cookies require TLS on every resolved frontend"
);
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &host.frontend.hosts {
for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
for b in &host_bindings {
@@ -107,9 +109,22 @@ fn compile_routing(
let pool = if compiled_members.is_empty() {
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
{
pool_settings.health_check.host = host
.frontend
.hosts
.iter()
.find(|name| !name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err())
.cloned();
}
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
pool_settings,
&host.backends,
compiled_members,
)?;
@@ -159,6 +174,7 @@ fn compile_routing(
websocket_rfc8441: host.websocket_rfc8441,
websocket_rfc9220: host.websocket_rfc9220,
force_ssl: host.force_ssl,
www_redirect,
forward_client_ip: host.forward_client_ip,
https_redirect_port,
http2_enabled: host.http2_enabled,
@@ -192,6 +208,7 @@ fn compile_routing(
security: host.security_headers.compile()?,
compression: host.compression.clone(),
cache: host.cache.resolve(&config.cache)?,
force_cache: host.cache.unsafe_force.clone(),
transform: host.transform.resolve(&config.transform)?,
pages: host.pages.clone(),
proxy_timeouts: host.proxy_timeouts.clone(),
@@ -229,6 +246,7 @@ fn compile_routing(
path_regex: None,
rewrite: crate::vhost::PathRewrite::default(),
response: None,
redirect: None,
source: fallback_source,
backend: fallback_backend,
acl_profile_id: None,
@@ -240,7 +258,7 @@ fn compile_routing(
headers: crate::headers::CompiledHeaders::default(),
unavailable: host.transient_unavailable,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
fallbacks
.entry(name.clone())
.or_default()
@@ -293,6 +311,7 @@ fn compile_routing(
);
validate_route_rewrite(route, path_regex.as_ref())?;
validate_location_response(route.response.as_ref())?;
let compiled_redirect = validate_location_redirect(route.redirect.as_ref())?;
let (route_profile_acl, route_profile_country_access) =
match route.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
@@ -317,8 +336,8 @@ fn compile_routing(
}
}
if host.enabled && route.enabled {
let (source, route_pool, backend) = if route.response.is_some() {
// Local return is handled before source dispatch and needs no origin.
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 {
@@ -364,6 +383,7 @@ fn compile_routing(
path_regex,
rewrite: route.rewrite.clone(),
response: route.response.clone(),
redirect: compiled_redirect,
source,
backend,
acl_profile_id: route.acl_profile_id.clone(),
@@ -375,7 +395,7 @@ fn compile_routing(
headers,
unavailable: false,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
routes
.entry(name.clone())
.or_default()
@@ -384,7 +404,7 @@ fn compile_routing(
}
}
if host.enabled {
for name in &host.frontend.hosts {
for name in &routing_hosts {
frontends
.entry(name.clone())
.or_default()
+23
View File
@@ -261,6 +261,29 @@ where
}
impl CompiledRoute {
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");
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")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
Some(existing) if !existing.is_empty() => format!("{existing}&{query}"),
_ => query.to_owned(),
};
target.set_query(Some(&merged));
}
}
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>> {
+115
View File
@@ -205,3 +205,118 @@ fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
assert!(c.administration.listen.is_none());
assert!(compile(c, 1, &p).is_ok());
}
#[test]
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
c.vhosts[0].www_redirect = true;
let snapshot = compile(c, 1, &policy()).unwrap();
assert!(snapshot.select("www.example.test", "/path").is_some());
let frontend = snapshot
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&("www.example.test".into(), "example.test".into()))
);
}
#[test]
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec![
"suba.local".into(), "subb.local".into(), "site.test".into(),
];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("site.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
}
for hostname in ["suba.local", "subb.local", "www.site.test"] {
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&("www.site.test".into(), "site.test".into())));
}
}
#[test]
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for host in ["suba.local", "subb.local", "www.subb.local"] {
assert!(snapshot.select(host, "/path").is_some(), "{host}");
}
assert!(snapshot.select("www.suba.local", "/path").is_none());
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
}
#[test]
fn selected_www_redirect_target_must_belong_to_vhost() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = None;
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("app.example.test")
);
}
#[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("health.example.test")
);
}
#[test]
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
id: "archives".into(),
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
match_type: crate::vhost::PathMatch::RegexInsensitive,
response: None,
redirect: Some(crate::vhost::LocationRedirect {
status: 301,
target: "https://storage.example.test/o/$1".into(),
rewrite_pattern: Some(r"^/(.*)$".into()),
preserve_query: true,
}),
..Default::default()
}];
let snapshot = compile(c, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
let location = route
.redirect_location("/repo/file.tar.gz", &uri)
.unwrap()
.unwrap();
assert_eq!(
location,
"https://storage.example.test/o/repo/file.tar.gz?download=1"
);
}