v0.29.0
This commit is contained in:
1 parent
65e082b9d1
commit
76cb4acef0
72 files changed
+6134
-4709
No files matched your search
@@ -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(());
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user