0.34.13_cleaup_code

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-26 14:14:41 +01:00
1 parent ec46a96a29
commit 5c2e50ec06
87 files changed
+4890 -1338

No files matched your search

+36 -14
View File
@@ -58,13 +58,17 @@ 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));
@@ -94,9 +98,16 @@ pub fn allows_profiles_with_country(
let mut explicit_allow = false;
let mut defaults_allow = true;
for profile in profiles {
let ip_action = profile.acl.as_ref().and_then(|acl| acl.explicit_action(peer));
let ip_action = profile
.acl
.as_ref()
.and_then(|acl| acl.explicit_action(peer));
let country_listed = country.is_some_and(|code| {
profile.countries.countries.iter().any(|value| value.eq_ignore_ascii_case(code))
profile
.countries
.countries
.iter()
.any(|value| value.eq_ignore_ascii_case(code))
});
if ip_action == Some(Action::Deny)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed)
@@ -106,20 +117,32 @@ pub fn allows_profiles_with_country(
explicit_allow |= ip_action == Some(Action::Allow)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed);
defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow
&& profile.acl.as_ref().is_none_or(|acl| acl.default == Action::Allow);
&& profile
.acl
.as_ref()
.is_none_or(|acl| acl.default == Action::Allow);
}
explicit_allow || defaults_allow
}
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> {
pub fn profile_ids<'a>(
legacy: Option<&'a str>,
ids: &'a [String],
) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str))
}
pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> {
ensure!(ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, "at most 8 ACL profiles may be assigned");
ensure!(
ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8,
"at most 8 ACL profiles may be assigned"
);
let mut seen = std::collections::HashSet::new();
for id in profile_ids(legacy, ids) {
ensure!(crate::config::valid_id(id) && seen.insert(id), "invalid or duplicate ACL profile id");
ensure!(
crate::config::valid_id(id) && seen.insert(id),
"invalid or duplicate ACL profile id"
);
}
Ok(())
}
@@ -181,9 +204,7 @@ 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,
}
}
pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
@@ -220,7 +241,8 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
);
}
ensure!(
!raw.split('/').any(|segment| segment == "." || segment == ".."),
!raw.split('/')
.any(|segment| segment == "." || segment == ".."),
"ambiguous path segments"
);
Ok(std::borrow::Cow::Borrowed(raw))
+6 -3
View File
@@ -21,13 +21,16 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
| "/api/metrics/json"
| "/api/metrics/summary"
| "/api/cache/bans"
) || (method == Method::GET
&& path.strip_prefix("/api/config/")
)
|| (method == Method::GET
&& path
.strip_prefix("/api/config/")
.is_some_and(|section| ConfigSection::from_name(section).is_some()));
let write = permission == "rw"
&& ((method == Method::PUT
&& (path == "/api/config"
|| path.strip_prefix("/api/config/")
|| path
.strip_prefix("/api/config/")
.is_some_and(|section| ConfigSection::from_name(section).is_some())))
|| (method == Method::POST
&& matches!(
+108 -62
View File
@@ -1,6 +1,3 @@
// GET and PUT resolve exactly the same named sections. All writes go through
// App::update_as: full validation, revision check under the update lock, and
// one durable transaction. These handlers do not implement partial patches.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConfigSection {
Listeners,
@@ -93,66 +90,109 @@ impl ConfigSection {
}
}
fn replace(self, config: &mut crate::config::Config, name: &str, value: Value) -> Result<(), ApiError> {
fn replace(
self,
config: &mut crate::config::Config,
name: &str,
value: Value,
) -> Result<(), ApiError> {
match self {
Self::Listeners => config.listeners = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::DockerAgents => config.docker_agents = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Http2 => config.http2 = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Pages => config.pages = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Cache => config.cache = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Transform => config.transform = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Forwarding => config.forwarding = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Headers => config.headers = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Prometheus => config.prometheus = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Geoip => config.geoip_updates = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Administration => config.administration = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Tls => config.tls = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Vhosts => config.vhosts = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::WebServices => config.web_services = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::PhpFpmProfiles => config.php_fpm_profiles = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Redirects => config.redirects = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::NotFoundHosts => config.not_found_hosts = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Streams => config.streams = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::AclProfiles => config.acl_profiles = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::HostPolicies => config.host_policies = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?,
Self::Listeners => {
config.listeners =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::DockerAgents => {
config.docker_agents =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Http2 => {
config.http2 =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Pages => {
config.pages =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Cache => {
config.cache =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Transform => {
config.transform =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Forwarding => {
config.forwarding =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Headers => {
config.headers =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Prometheus => {
config.prometheus =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Geoip => {
config.geoip_updates =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Administration => {
config.administration =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Tls => {
config.tls =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Vhosts => {
config.vhosts =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::WebServices => {
config.web_services =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::PhpFpmProfiles => {
config.php_fpm_profiles =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Redirects => {
config.redirects =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::NotFoundHosts => {
config.not_found_hosts =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Streams => {
config.streams =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::AclProfiles => {
config.acl_profiles =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::HostPolicies => {
config.host_policies =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Response => {
let part: ResponseSection = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?;
let part: ResponseSection =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
config.server_header = part.server_header;
config.diagnostic_headers = part.diagnostic_headers;
config.compression = part.compression;
}
Self::Logging => {
// A logging save must include both sinks; there is no implicit fallback
// to a single-field payload that could reset error_log accidentally.
let part: LoggingSection = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?;
let part: LoggingSection =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
config.logging = part.logging;
config.error_log = part.error_log;
}
Self::GlobalSecurity => {
let part: crate::config::GlobalSecurity = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?;
let part: crate::config::GlobalSecurity =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
if part.user_agent_blocking.is_none() {
return Err(ApiError(
StatusCode::BAD_REQUEST,
@@ -167,10 +207,12 @@ impl ConfigSection {
}
fn requested_config_section(name: &str) -> Result<ConfigSection, ApiError> {
ConfigSection::from_name(name).ok_or_else(|| ApiError(
StatusCode::NOT_FOUND,
"unknown configuration section".into(),
))
ConfigSection::from_name(name).ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"unknown configuration section".into(),
)
})
}
async fn configuration_section(
@@ -192,7 +234,6 @@ async fn update_section(
Path(section): Path<String>,
Json(input): Json<SectionUpdate>,
) -> Result<Json<crate::config::Envelope>, ApiError> {
// Resolve the name before taking the snapshot, including for stale requests.
let selected = requested_config_section(&section)?;
let current = state.config.load_full();
if current.envelope.revision != input.expected_revision {
@@ -203,12 +244,18 @@ async fn update_section(
}
let mut config = current.envelope.config.clone();
selected.replace(&mut config, &section, input.value)?;
state.update_as(Update { expected_revision: input.expected_revision, config }, session.username)
.await.map(Json)
state
.update_as(
Update {
expected_revision: input.expected_revision,
config,
},
session.username,
)
.await
.map(Json)
}
// GeoIP is an explicit side-effecting action within the geoip settings section.
// GET and PUT are only for desired settings; POST refreshes external data.
async fn geoip_update(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
@@ -239,4 +286,3 @@ async fn geoip_update(
.map_err(auth::internal)?;
Ok(Json(result))
}
+41 -22
View File
@@ -95,32 +95,37 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if directory_safe {
let entries = newest_log_files(std::fs::read_dir(&directory)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let plain = name.ends_with(".jsonl");
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
if (!plain && !rotated) || name.len() > 180 {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?;
Some((name, path, file_modified_ms(&meta)))
}), 256);
let entries = newest_log_files(
std::fs::read_dir(&directory)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let plain = name.ends_with(".jsonl");
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
if (!plain && !rotated) || name.len() > 180 {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?;
Some((name, path, file_modified_ms(&meta)))
}),
256,
);
for (name, path, _) in entries {
let id = if name.ends_with(".jsonl") {
format!("vhost:{}", name.trim_end_matches(".jsonl"))
} else {
format!("vhost-gz:{name}")
};
let vhost_name = name.split_once(".jsonl").map_or(name.as_str(), |(prefix, _)| prefix);
let vhost_name = name
.split_once(".jsonl")
.map_or(name.as_str(), |(prefix, _)| prefix);
let group_id = format!("vhost:{vhost_name}");
sources.push(source_from_path(
id,
@@ -144,7 +149,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
config.logging.file.enabled,
"JSONL",
));
sources.extend(rotated_gzip_sources(&access_path, "access", "Access log", "access", config.logging.file.enabled, "JSONL"));
sources.extend(rotated_gzip_sources(
&access_path,
"access",
"Access log",
"access",
config.logging.file.enabled,
"JSONL",
));
}
let error_format = match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
@@ -160,7 +172,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
config.error_log.enabled,
error_format,
));
sources.extend(rotated_gzip_sources(&error_path, "error", "Error log", "error", config.error_log.enabled, error_format));
sources.extend(rotated_gzip_sources(
&error_path,
"error",
"Error log",
"error",
config.error_log.enabled,
error_format,
));
sources
}
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
+1 -1
View File
@@ -14305,4 +14305,4 @@
}
},
"tags": []
}
}
+4 -1
View File
@@ -260,7 +260,10 @@ pub fn router(state: Arc<App>) -> Router {
post(restore_config_history).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/config/geoip/update", post(geoip_update))
.route("/api/config/{section}", get(configuration_section).put(update_section))
.route(
"/api/config/{section}",
get(configuration_section).put(update_section),
)
.route("/api/logs", get(logs))
.route("/api/live-logs", get(live_logs_stream))
.route("/api/file-logs", get(file_logs))
+180 -67
View File
@@ -654,11 +654,29 @@ mod tests {
#[test]
fn config_section_registry_read_and_replace_are_symmetric() {
let names = [
"listeners", "docker-agents", "http2", "pages", "cache", "transform",
"forwarding", "headers", "prometheus", "geoip", "administration",
"tls", "vhosts", "web-services", "php-fpm-profiles", "redirects",
"not-found-hosts", "streams", "acl-profiles", "host-policies",
"response", "logging", "global-security",
"listeners",
"docker-agents",
"http2",
"pages",
"cache",
"transform",
"forwarding",
"headers",
"prometheus",
"geoip",
"administration",
"tls",
"vhosts",
"web-services",
"php-fpm-profiles",
"redirects",
"not-found-hosts",
"streams",
"acl-profiles",
"host-policies",
"response",
"logging",
"global-security",
];
let mut original = Config::default();
original.global_security.compile().unwrap();
@@ -671,7 +689,14 @@ mod tests {
assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}");
assert_eq!(section.read(&updated), value, "{name}");
}
for name in ["user-agent-blocking", "error_log", "history", "storage", "unknown", "tls/nested"] {
for name in [
"user-agent-blocking",
"error_log",
"history",
"storage",
"unknown",
"tls/nested",
] {
assert!(ConfigSection::from_name(name).is_none(), "{name}");
}
}
@@ -681,17 +706,40 @@ mod tests {
let (router, store, _dir, secret, _csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for name in [
"listeners", "docker-agents", "http2", "pages", "cache", "transform",
"forwarding", "headers", "prometheus", "geoip", "administration", "tls",
"vhosts", "web-services", "php-fpm-profiles", "redirects", "not-found-hosts",
"streams", "acl-profiles", "host-policies", "response", "logging",
"listeners",
"docker-agents",
"http2",
"pages",
"cache",
"transform",
"forwarding",
"headers",
"prometheus",
"geoip",
"administration",
"tls",
"vhosts",
"web-services",
"php-fpm-profiles",
"redirects",
"not-found-hosts",
"streams",
"acl-profiles",
"host-policies",
"response",
"logging",
"global-security",
] {
let response = router.clone().oneshot(
request(&format!("/api/config/{name}"))
.header("cookie", &cookie)
.body(Body::empty()).unwrap(),
).await.unwrap();
let response = router
.clone()
.oneshot(
request(&format!("/api/config/{name}"))
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{name}");
let data = json_body(response).await;
assert_eq!(data["section"], name);
@@ -708,24 +756,57 @@ mod tests {
assert!(data["value"]["user_agent_blocking"].is_object());
}
}
let unknown = router.clone().oneshot(
request("/api/config/user-agent-blocking")
.header("cookie", &cookie)
.body(Body::empty()).unwrap(),
).await.unwrap();
let unknown = router
.clone()
.oneshot(
request("/api/config/user-agent-blocking")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
store.close().await;
}
#[test]
fn geoip_update_is_scoped_to_settings_for_general_api_tokens() {
assert!(general_api_token_allowed(&Method::GET, "/api/config/geoip", "ro"));
assert!(general_api_token_allowed(&Method::PUT, "/api/config/geoip", "rw"));
assert!(!general_api_token_allowed(&Method::PUT, "/api/config/geoip", "ro"));
assert!(general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "rw"));
assert!(!general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "ro"));
assert!(!general_api_token_allowed(&Method::POST, "/api/geoip/update", "rw"));
assert!(!general_api_token_allowed(&Method::GET, "/api/config/geoip/update", "rw"));
assert!(general_api_token_allowed(
&Method::GET,
"/api/config/geoip",
"ro"
));
assert!(general_api_token_allowed(
&Method::PUT,
"/api/config/geoip",
"rw"
));
assert!(!general_api_token_allowed(
&Method::PUT,
"/api/config/geoip",
"ro"
));
assert!(general_api_token_allowed(
&Method::POST,
"/api/config/geoip/update",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/config/geoip/update",
"ro"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/geoip/update",
"rw"
));
assert!(!general_api_token_allowed(
&Method::GET,
"/api/config/geoip/update",
"rw"
));
}
#[tokio::test]
@@ -736,9 +817,16 @@ mod tests {
("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED),
("/api/geoip/update", StatusCode::NOT_FOUND),
] {
let response = router.clone().oneshot(
request(path).header("cookie", &cookie).body(Body::empty()).unwrap(),
).await.unwrap();
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
store.close().await;
@@ -813,12 +901,10 @@ mod tests {
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(
serde_json::to_vec(
&json!({"expected_revision":0,"value":{
"logging":Config::default().logging,
"error_log":Config::default().error_log
}}),
)
serde_json::to_vec(&json!({"expected_revision":0,"value":{
"logging":Config::default().logging,
"error_log":Config::default().error_log
}}))
.unwrap(),
))
.unwrap(),
@@ -846,7 +932,6 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn global_security_section_saves_rules_and_existing_ua_atomically() {
let (router, _store, _dir, secret, csrf) = fixture().await;
@@ -858,53 +943,81 @@ mod tests {
"user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]}
});
let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap();
let response = router.clone().oneshot(
request("/api/config/global-security")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body)).unwrap(),
).await.unwrap();
let response = router
.clone()
.oneshot(
request("/api/config/global-security")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = json_body(response).await;
assert_eq!(json["revision"], 1);
assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "RU");
assert_eq!(json["config"]["global_security"]["security_headers"]["nosniff"], true);
assert_eq!(json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot");
assert_eq!(
json["config"]["global_security"]["country_access"]["countries"][0],
"RU"
);
assert_eq!(
json["config"]["global_security"]["security_headers"]["nosniff"],
true
);
assert_eq!(
json["config"]["global_security"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(json["config"].get("user_agent_blocking").is_none());
// A partial section update must not silently reset the existing UA policy.
let body = serde_json::to_vec(&json!({
"expected_revision":1,
"value": {"method_policy":{"mode":"off","methods":[]}}
})).unwrap();
let response = router.clone().oneshot(
request("/api/config/global-security")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body)).unwrap(),
).await.unwrap();
}))
.unwrap();
let response = router
.clone()
.oneshot(
request("/api/config/global-security")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// GET returns the same standalone section shape accepted by PUT, at the
// matching revision and without unrequested configuration fields.
let read = router.clone().oneshot(
request("/api/config/global-security")
.header("cookie", &cookie)
.body(Body::empty()).unwrap(),
).await.unwrap();
let read = router
.clone()
.oneshot(
request("/api/config/global-security")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::OK);
let view = json_body(read).await;
assert_eq!(view["revision"], 1);
assert_eq!(view["section"], "global-security");
assert_eq!(view["value"]["user_agent_blocking"]["patterns"][0], "bad-bot");
assert_eq!(
view["value"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(view.get("config").is_none());
assert!(view["value"].get("global_security").is_none());
}
+18 -4
View File
@@ -1505,8 +1505,22 @@ html[data-table-density=ultra] :is(table th, table td) {
gap: 6px;
max-width: 260px;
}
/* Permission guidance belongs to its generated command block, not the path inputs. */
#certificate-external-acl-example.path-permissions { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 15px; }
.path-permissions-commands { margin-bottom: 0; }
.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; }
.path-permissions-actions { margin-top: 10px; }
#certificate-external-acl-example.path-permissions {
margin-top: 20px;
border-top: 1px solid var(--line);
padding-top: 15px;
}
.path-permissions-commands {
margin-bottom: 0;
}
.path-permissions-commands+.path-permissions-description {
margin: 7px 0 0;
}
.path-permissions-actions {
margin-top: 10px;
}
+1 -1
View File
@@ -770,4 +770,4 @@ body[data-density=compact] .page-intro .lead {
to {
visibility: visible;
}
}
}
+10 -11
View File
@@ -231,8 +231,12 @@ fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
fn new_crypto(password: &str) -> Result<CryptoContext> {
let mut salt = [0u8; 16];
let mut nonce_prefix = [0u8; 4];
SysRng.try_fill_bytes(&mut salt).expect("secure operating system random source unavailable");
SysRng.try_fill_bytes(&mut nonce_prefix).expect("secure operating system random source unavailable");
SysRng
.try_fill_bytes(&mut salt)
.expect("secure operating system random source unavailable");
SysRng
.try_fill_bytes(&mut nonce_prefix)
.expect("secure operating system random source unavailable");
let header = CryptoHeader {
format_version: FORMAT_VERSION,
kdf: "Argon2id".into(),
@@ -395,7 +399,9 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroiz
fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
ensure!(master.len() == 32, "invalid secret store master key");
let mut nonce_bytes = [0u8; 12];
SysRng.try_fill_bytes(&mut nonce_bytes).expect("secure operating system random source unavailable");
SysRng
.try_fill_bytes(&mut nonce_bytes)
.expect("secure operating system random source unavailable");
let cipher = cipher(backup_key)?;
let mut encrypted = Zeroizing::new(master.to_vec());
cipher
@@ -462,13 +468,6 @@ fn hash_bytes(bytes: &[u8]) -> String {
fn excluded_directory(name: &str) -> bool {
matches!(
name,
"cache"
| "logs"
| "tmp"
| "temp"
| "work"
| "acme-http01"
| "acme-python"
| "acme_python"
"cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01" | "acme-python" | "acme_python"
)
}
+6 -3
View File
@@ -14,7 +14,9 @@ mod tests {
let crypto = new_crypto("correct horse battery staple").unwrap();
encrypt_archive(&input, &encrypted, &crypto).unwrap();
let error = decrypt_archive(&encrypted, &wrong_output, "wrong password").unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive"));
assert!(error
.to_string()
.contains("incorrect backup password or corrupted archive"));
decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap();
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
@@ -24,7 +26,9 @@ mod tests {
fs::write(&tampered, bytes).unwrap();
let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple")
.unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive"));
assert!(error
.to_string()
.contains("incorrect backup password or corrupted archive"));
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
}
@@ -98,7 +102,6 @@ mod tests {
assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1");
}
#[cfg(unix)]
#[test]
fn restore_owner_is_applied_recursively() {
+3 -1
View File
@@ -426,6 +426,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
pub fn generated_password() -> String {
let mut bytes = [0u8; 32];
SysRng.try_fill_bytes(&mut bytes).expect("secure operating system random source unavailable");
SysRng
.try_fill_bytes(&mut bytes)
.expect("secure operating system random source unavailable");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
+60 -53
View File
@@ -70,9 +70,13 @@ impl Cache {
continue;
};
let body_path = shard.join(&file.body_file);
let expected_scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&file.scope).copied().unwrap_or(0);
let expected_scope_revision = self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&file.scope)
.copied()
.unwrap_or(0);
if file.format != COLD_FORMAT_VERSION
|| file.revision != revision
|| file.scope_revision != expected_scope_revision
@@ -142,57 +146,60 @@ impl Cache {
let _ = std::fs::remove_file(&body_path);
continue;
}
loaded.push((key, ColdEntry {
base_key,
vary,
revision,
scope: file.scope,
scheme: file.scheme,
authority: if file.authority.is_empty() {
file.domain.clone()
} else {
file.authority
loaded.push((
key,
ColdEntry {
base_key,
vary,
revision,
scope: file.scope,
scheme: file.scheme,
authority: if file.authority.is_empty() {
file.domain.clone()
} else {
file.authority
},
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
body_path,
meta_path: path,
body_bytes: file.body_bytes,
body_sha256,
disk_bytes: body_meta.len().saturating_add(bytes.len() as u64),
stored_at,
last_access: if file.last_access_ms == 0 {
stored_at
} else {
system_time_ms(file.last_access_ms)
},
age_at_store_seconds: file.age_at_store_seconds,
resident_age_at_store_seconds: file.resident_age_at_store_seconds,
ttl: file.ttl,
stale_while_revalidate: file.stale_while_revalidate,
stale_if_error: file.stale_if_error,
must_revalidate: file.must_revalidate,
proxy_revalidate: file.proxy_revalidate,
has_validator: origin_etag.is_some() || origin_last_modified.is_some(),
hits: file.hits,
transformed: file.transformed,
sequence: 0,
generation: 0,
skip_compression: file.skip_compression,
requires_revalidation: file.requires_revalidation,
pinned_reads: 0,
},
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
body_path,
meta_path: path,
body_bytes: file.body_bytes,
body_sha256,
disk_bytes: body_meta.len().saturating_add(bytes.len() as u64),
stored_at,
last_access: if file.last_access_ms == 0 {
stored_at
} else {
system_time_ms(file.last_access_ms)
},
age_at_store_seconds: file.age_at_store_seconds,
resident_age_at_store_seconds: file.resident_age_at_store_seconds,
ttl: file.ttl,
stale_while_revalidate: file.stale_while_revalidate,
stale_if_error: file.stale_if_error,
must_revalidate: file.must_revalidate,
proxy_revalidate: file.proxy_revalidate,
has_validator: origin_etag.is_some() || origin_last_modified.is_some(),
hits: file.hits,
transformed: file.transformed,
sequence: 0,
generation: 0,
skip_compression: file.skip_compression,
requires_revalidation: file.requires_revalidation,
pinned_reads: 0,
}));
));
}
}
loaded.sort_by_key(|(_, entry)| entry.last_access);
+48 -14
View File
@@ -427,7 +427,12 @@ mod dashboard_tests {
);
let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_freshness(
&mut headers, "HIT", 3, -7, "cache_max_stale", false,
&mut headers,
"HIT",
3,
-7,
"cache_max_stale",
false,
);
assert_eq!(
headers["cache-status"],
@@ -435,7 +440,12 @@ mod dashboard_tests {
);
let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_freshness(
&mut headers, "HIT", 3, -5, "cache_stale_while_revalidate", false,
&mut headers,
"HIT",
3,
-5,
"cache_stale_while_revalidate",
false,
);
assert_eq!(
headers["cache-status"],
@@ -453,20 +463,38 @@ mod dashboard_tests {
let mut headers = HeaderMap::new();
headers.append("cache-status", HeaderValue::from_static("OriginCache; hit"));
settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 3, 0, "cache_stale_if_error", false,
"stale", Some(StatusCode::SERVICE_UNAVAILABLE),
&mut headers,
"HIT",
3,
0,
"cache_stale_if_error",
false,
"stale",
Some(StatusCode::SERVICE_UNAVAILABLE),
);
assert_eq!(headers["x-cache"], "HIT");
let values: Vec<_> = headers.get_all("cache-status").iter()
.map(|value| value.to_str().unwrap()).collect();
assert_eq!(values, vec![
"OriginCache; hit",
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"",
]);
let values: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
values,
vec![
"OriginCache; hit",
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"",
]
);
let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 2, 42, "cache_revalidated", false,
"request", Some(StatusCode::NOT_MODIFIED),
&mut headers,
"HIT",
2,
42,
"cache_revalidated",
false,
"request",
Some(StatusCode::NOT_MODIFIED),
);
assert_eq!(
headers["cache-status"],
@@ -474,8 +502,14 @@ mod dashboard_tests {
);
let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 2, 0, "cache_stale_if_error", false,
"stale", None,
&mut headers,
"HIT",
2,
0,
"cache_stale_if_error",
false,
"stale",
None,
);
assert_eq!(
headers["cache-status"],
+30 -9
View File
@@ -182,7 +182,15 @@ impl Settings {
ttl: u64,
reason: &'static str,
) {
self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), false, None);
self.annotate_internal(
headers,
outcome,
hits,
ttl as i64,
Some(reason),
false,
None,
);
}
pub fn annotate_reason_transformed(
@@ -194,7 +202,15 @@ impl Settings {
reason: &'static str,
transformed: bool,
) {
self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), transformed, None);
self.annotate_internal(
headers,
outcome,
hits,
ttl as i64,
Some(reason),
transformed,
None,
);
}
pub fn annotate_reason_transformed_freshness(
@@ -207,12 +223,16 @@ impl Settings {
transformed: bool,
) {
self.annotate_internal(
headers, outcome, hits, freshness_ttl, Some(reason), transformed, None,
headers,
outcome,
hits,
freshness_ttl,
Some(reason),
transformed,
None,
);
}
// RFC 9211 distinguishes whether this request was forwarded from whether
// the legacy X-Cache metric counted the reused representation as a HIT.
pub fn annotate_reason_transformed_forwarded(
&self,
headers: &mut HeaderMap,
@@ -285,8 +305,11 @@ impl Settings {
}
if self.expose_cache_status {
let forwarded = forwarded.or_else(|| {
matches!(reason, Some("cache_revalidated" | "cache_revalidated_uncached"))
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
matches!(
reason,
Some("cache_revalidated" | "cache_revalidated_uncached")
)
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
});
let mut value = if let Some((why, status)) = forwarded {
let mut value = format!("RustProxy; fwd={why}");
@@ -296,8 +319,6 @@ impl Settings {
value
} else {
match outcome {
// RFC 9211 permits a negative ttl when stale data is served
// without sending the current request to the origin.
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
"MISS" => "RustProxy; fwd=miss".to_owned(),
"BYPASS" if reason == Some("request_method") => {
+1 -1
View File
@@ -126,7 +126,7 @@ impl Drop for ColdReadGuard<'_> {
}
}
}
// RFC 9211 uses signed structured-field integers for freshness (negative when stale).
fn cache_status_ttl(ttl: u64, age: u64) -> i64 {
const MAX: i128 = 999_999_999_999_999;
(i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64
-2
View File
@@ -87,9 +87,7 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
pub varnish_compatible: bool,
/// Implicit grace in seconds for eligible fresh-at-storage cached responses.
pub varnish_grace_seconds: u64,
pub memory_preset: Option<MemoryPreset>,
pub max_bytes: usize,
+1 -2
View File
@@ -133,8 +133,7 @@ fn stale_mode(
}
let stale_for = age.saturating_sub(ttl);
let max_stale = client_max_stale(request_headers);
// An explicit client limit must also constrain implicit Varnish grace.
// In particular, max-stale=0 must not serve a stale representation.
if matches!(max_stale, Some(Some(limit)) if stale_for > limit) {
return None;
}
+32 -24
View File
@@ -44,9 +44,7 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
}
Some(result)
}
/// Keep all client cache restrictions except the hard-reload directives that
/// Varnish normally disregards for a fresh shared object. Never mutate the
/// actual request (it is still forwarded to the origin and used for Vary).
fn compatible_freshness_headers<'a>(
headers: &'a HeaderMap,
compatible: bool,
@@ -54,29 +52,31 @@ fn compatible_freshness_headers<'a>(
if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) {
return std::borrow::Cow::Borrowed(headers);
}
// An invalid cache-control header cannot be made valid by filtering it.
// Also avoid cloning the full map for ordinary requests with no reload
// directives, including min-fresh, max-stale and only-if-cached.
let Some(control) = directives(headers) else {
return std::borrow::Cow::Borrowed(headers);
};
let remove_reload = control.contains_key("no-cache")
|| control.get("max-age").and_then(|value| value.as_deref())
.and_then(|value| value.parse::<u64>().ok()) == Some(0);
|| control
.get("max-age")
.and_then(|value| value.as_deref())
.and_then(|value| value.parse::<u64>().ok())
== Some(0);
if !remove_reload && !pragma_no_cache(headers) {
return std::borrow::Cow::Borrowed(headers);
}
let mut freshness = headers.clone();
let mut changed = false;
if pragma_no_cache(headers) {
// Pragma only affects freshness through its `no-cache` token in this
// implementation. Preserve other tokens, if any.
freshness.remove("pragma");
for header in headers.get_all("pragma") {
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
let retained = text.split(',')
let retained = text
.split(',')
.map(str::trim)
.filter(|token| !token.eq_ignore_ascii_case("no-cache"))
.collect::<Vec<_>>();
@@ -95,15 +95,21 @@ fn compatible_freshness_headers<'a>(
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
let retained = text.split(',').map(str::trim).filter(|token| {
let (name, value) = token.split_once('=').unwrap_or((token, ""));
let name = name.trim();
let skip = name.eq_ignore_ascii_case("no-cache")
|| (name.eq_ignore_ascii_case("max-age")
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0));
if skip { changed = true; }
!skip
}).collect::<Vec<_>>();
let retained = text
.split(',')
.map(str::trim)
.filter(|token| {
let (name, value) = token.split_once('=').unwrap_or((token, ""));
let name = name.trim();
let skip = name.eq_ignore_ascii_case("no-cache")
|| (name.eq_ignore_ascii_case("max-age")
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0));
if skip {
changed = true;
}
!skip
})
.collect::<Vec<_>>();
if !retained.is_empty() {
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
return std::borrow::Cow::Borrowed(headers);
@@ -118,8 +124,11 @@ fn compatible_freshness_headers<'a>(
}
}
}
if changed { std::borrow::Cow::Owned(freshness) }
else { std::borrow::Cow::Borrowed(headers) }
if changed {
std::borrow::Cow::Owned(freshness)
} else {
std::borrow::Cow::Borrowed(headers)
}
}
pub fn request_freshness_headers<'a>(
@@ -243,8 +252,7 @@ pub fn request_policy(
return RequestPolicy::Bypass(reason);
}
}
// Compatibility only relaxes browser hard-reload freshness requests.
// Retain no-store, only-if-cached, min-fresh and max-stale semantics.
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
+17 -18
View File
@@ -127,24 +127,23 @@ pub fn plan(
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
None => now,
};
let declared_ttl =
if override_public_no_cache {
Some(settings.default_ttl_seconds)
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
Some(
httpdate::parse_http_date(expires.to_str().ok()?)
.ok()?
.duration_since(date)
.ok()?
.as_secs(),
)
} else if control.contains_key("public") {
Some(settings.default_ttl_seconds)
} else {
None
};
let declared_ttl = if override_public_no_cache {
Some(settings.default_ttl_seconds)
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
Some(
httpdate::parse_http_date(expires.to_str().ok()?)
.ok()?
.duration_since(date)
.ok()?
.as_secs(),
)
} else if control.contains_key("public") {
Some(settings.default_ttl_seconds)
} else {
None
};
let age = match headers.get("age") {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
+40 -13
View File
@@ -58,14 +58,29 @@ mod scope_tests {
fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() {
let cache = Cache::new(1);
cache.set_control_revision(7);
let ticket = |revision| cache.ticket_scoped_for_config(
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
);
let ticket = |revision| {
cache.ticket_scoped_for_config(
revision,
1,
"vhost-a",
"http",
"same.test",
"main",
"/resource",
&HeaderMap::new(),
)
};
assert!(ticket(7).is_some(), "the current snapshot can cache");
cache.set_control_revision(8);
assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
assert!(ticket(8).is_some(), "the new snapshot can reuse the same cache identity");
assert!(
ticket(7).is_none(),
"late request from the previous snapshot must bypass"
);
assert!(
ticket(8).is_some(),
"the new snapshot can reuse the same cache identity"
);
}
#[test]
@@ -82,18 +97,30 @@ mod scope_tests {
assert!(effective.enabled && effective.expose_status);
let mut globally_disabled = global.clone();
globally_disabled.enabled = false;
assert!(!Overrides { enabled: Some(true), ..Overrides::default() }
assert!(
!Overrides {
enabled: Some(true),
..Overrides::default()
}
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert!(!Overrides { enabled: Some(false), ..Overrides::default() }
.enabled
);
assert!(
!Overrides {
enabled: Some(false),
..Overrides::default()
}
.resolve(&global)
.unwrap()
.enabled);
assert!(!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled);
.enabled
);
assert!(
!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled
);
assert_eq!(effective.max_bytes, global.max_bytes);
assert_eq!(effective.max_ttl_seconds, 30);
assert!(Overrides {
+3 -1
View File
@@ -408,6 +408,8 @@ impl Ticket {
}
fn freshness_headers(&self) -> &HeaderMap {
self.freshness_headers.as_ref().unwrap_or(&self.request_headers)
self.freshness_headers
.as_ref()
.unwrap_or(&self.request_headers)
}
}
+551 -85
View File
@@ -4,7 +4,10 @@ mod varnish_compatible_tests {
fn headers(cache_control: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap());
h.insert(
"cache-control",
HeaderValue::from_str(cache_control).unwrap(),
);
h
}
@@ -24,7 +27,10 @@ mod varnish_compatible_tests {
assert_eq!(customized.varnish_grace_seconds, 17);
let mut invalid = customized.clone();
invalid.varnish_grace_seconds = 604_801;
assert!(invalid.validate().is_err(), "oversized grace must be rejected");
assert!(
invalid.validate().is_err(),
"oversized grace must be rejected"
);
}
#[test]
@@ -32,18 +38,58 @@ mod varnish_compatible_tests {
let mut compatible = Settings::default();
for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] {
let h = headers(value);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use);
assert_eq!(
request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use
);
let freshness = request_freshness_headers(&h, &compatible);
assert!(!freshness.contains_key("cache-control"), "hard reload must not constrain lookup: {value}");
assert!(h.contains_key("cache-control"), "origin request and Vary must remain unchanged");
assert!(
!freshness.contains_key("cache-control"),
"hard reload must not constrain lookup: {value}"
);
assert!(
h.contains_key("cache-control"),
"origin request and Vary must remain unchanged"
);
}
for value in ["max-age=1", "min-fresh=600", "max-stale=9999", "only-if-cached"] {
for value in [
"max-age=1",
"min-fresh=600",
"max-stale=9999",
"only-if-cached",
] {
let h = headers(value);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use);
assert!(request_freshness_headers(&h, &compatible).contains_key("cache-control"), "preserve {value}");
assert_eq!(
request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use
);
assert!(
request_freshness_headers(&h, &compatible).contains_key("cache-control"),
"preserve {value}"
);
}
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store"));
assert_eq!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid"));
assert_eq!(
request_policy(
&Method::GET,
&headers("no-store"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_no_store")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("invalid directive"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_cache_control_invalid")
);
assert!(only_if_cached(&headers("only-if-cached")));
let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached");
let freshness = request_freshness_headers(&h, &compatible);
@@ -55,12 +101,48 @@ mod varnish_compatible_tests {
assert!(parsed.contains_key("only-if-cached"));
let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token"));
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token");
assert_eq!(
request_policy(&Method::GET, &pragma, true, "/", false, &compatible),
RequestPolicy::Use
);
assert_eq!(
request_freshness_headers(&pragma, &compatible)["pragma"],
"custom-token"
);
compatible.varnish_compatible = false;
assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache"));
assert_eq!(request_policy(&Method::GET, &headers("max-age=0"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_max_age"));
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store"));
assert_eq!(
request_policy(
&Method::GET,
&headers("no-cache"),
true,
"/",
false,
&compatible
),
RequestPolicy::Revalidate("request_no_cache")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("max-age=0"),
true,
"/",
false,
&compatible
),
RequestPolicy::Revalidate("request_max_age")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("no-store"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_no_store")
);
}
#[test]
@@ -73,16 +155,55 @@ mod varnish_compatible_tests {
("upgrade", "websocket"),
] {
let mut h = headers("no-cache");
h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap());
assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_)));
h.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
assert!(matches!(
request_policy(&Method::GET, &h, true, "/index.html", false, &settings),
RequestPolicy::Bypass(_)
));
}
settings.bypass_query = true;
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded"));
assert_eq!(
request_policy(
&Method::GET,
&HeaderMap::new(),
true,
"/style.css",
true,
&settings
),
RequestPolicy::Bypass("query_excluded")
);
settings.bypass_query = false;
settings.bypass_prefixes = vec!["/private".into()];
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded"));
assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method"));
assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled");
assert_eq!(
request_policy(
&Method::GET,
&HeaderMap::new(),
true,
"/private/account",
false,
&settings
),
RequestPolicy::Bypass("path_excluded")
);
assert_eq!(
request_policy(
&Method::POST,
&HeaderMap::new(),
true,
"/style.css",
false,
&settings
),
RequestPolicy::Bypass("request_method")
);
assert!(
settings.ignore_cookie_for_path("/style.css"),
"existing static cookie feature remains enabled"
);
assert!(!settings.ignore_cookie_for_path("/index.html"));
}
@@ -92,7 +213,13 @@ mod varnish_compatible_tests {
let mut strict = compatible.clone();
strict.varnish_compatible = false;
for control in ["private, max-age=60", "no-store, max-age=60"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
assert!(plan(
StatusCode::OK,
&headers(control),
&compatible,
Duration::ZERO
)
.is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none());
}
let mut no_cache = headers("no-cache, max-age=60");
@@ -101,7 +228,13 @@ mod varnish_compatible_tests {
.expect("no-cache with ETag remains revalidatable without fallback");
assert!(validator_plan.requires_revalidation);
assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some());
assert!(plan(StatusCode::OK, &headers("no-cache, max-age=60"), &compatible, Duration::ZERO).is_none());
assert!(plan(
StatusCode::OK,
&headers("no-cache, max-age=60"),
&compatible,
Duration::ZERO
)
.is_none());
let mut fallback = compatible.clone();
fallback.default_ttl_seconds = 42;
@@ -111,21 +244,48 @@ mod varnish_compatible_tests {
assert_eq!(fallback_plan.ttl, 42);
assert!(!fallback_plan.requires_revalidation);
let nonpublic_no_cache = headers("no-cache, max-age=60");
assert!(plan(StatusCode::OK, &nonpublic_no_cache, &fallback, Duration::ZERO).is_none());
assert!(plan(
StatusCode::OK,
&nonpublic_no_cache,
&fallback,
Duration::ZERO
)
.is_none());
let mut surrogate = public_no_cache.clone();
surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE"));
surrogate.insert(
"surrogate-control",
HeaderValue::from_static("max-age=120; NO-STORE"),
);
assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none());
assert_eq!(response_reason(StatusCode::OK, &surrogate, &fallback), "response_surrogate_control");
assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), "strict no-cache without ETag remains uncached");
assert_eq!(
response_reason(StatusCode::OK, &surrogate, &fallback),
"response_surrogate_control"
);
assert!(
plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(),
"strict no-cache without ETag remains uncached"
);
let mut private = headers("public, private, no-cache");
private.insert("etag", HeaderValue::from_static("\"secret\""));
assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none());
for control in ["max-age=0", "max-age=60, s-maxage=0", "max-age=0, stale-if-error=120"] {
let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO)
.expect("zero TTL is retained for max-stale, validation or stale-if-error");
for control in [
"max-age=0",
"max-age=60, s-maxage=0",
"max-age=0, stale-if-error=120",
] {
let p = plan(
StatusCode::OK,
&headers(control),
&compatible,
Duration::ZERO,
)
.expect("zero TTL is retained for max-stale, validation or stale-if-error");
assert_eq!(p.ttl, 0, "{control}");
assert_eq!(p.stale_while_revalidate, 0, "zero TTL must not receive implicit grace");
assert_eq!(
p.stale_while_revalidate, 0,
"zero TTL must not receive implicit grace"
);
}
let mut cookie = headers("max-age=60");
cookie.insert("set-cookie", HeaderValue::from_static("session=private"));
@@ -138,24 +298,66 @@ mod varnish_compatible_tests {
#[test]
fn explicit_zero_grace_and_stale_if_error_are_preserved() {
let compatible = Settings::default();
let with_zero = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=0"), &compatible, Duration::ZERO).unwrap();
let with_zero = plan(
StatusCode::OK,
&headers("public, max-age=60, stale-while-revalidate=0"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_zero.stale_while_revalidate, 0);
let with_grace = plan(StatusCode::OK, &headers("public, max-age=60"), &compatible, Duration::ZERO).unwrap();
let with_grace = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_grace.stale_while_revalidate, 60);
let mut custom_settings = compatible.clone();
custom_settings.varnish_grace_seconds = 25;
let with_custom_default = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
let with_custom_default = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_custom_default.stale_while_revalidate, 25);
custom_settings.varnish_grace_seconds = 0;
let grace_disabled = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
let grace_disabled = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(grace_disabled.stale_while_revalidate, 0);
custom_settings.varnish_compatible = false;
custom_settings.varnish_grace_seconds = 60;
let strict = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
let strict = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(strict.stale_while_revalidate, 0);
let with_custom = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30"), &compatible, Duration::ZERO).unwrap();
let with_custom = plan(
StatusCode::OK,
&headers("public, max-age=60, stale-while-revalidate=30"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_custom.stale_while_revalidate, 30);
let zero = plan(StatusCode::OK, &headers("public, max-age=0, stale-if-error=120"), &compatible, Duration::ZERO).unwrap();
let zero = plan(
StatusCode::OK,
&headers("public, max-age=0, stale-if-error=120"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(zero.ttl, 0);
assert_eq!(zero.stale_if_error, 120);
assert_eq!(zero.stale_while_revalidate, 0);
@@ -165,17 +367,47 @@ mod varnish_compatible_tests {
async fn origin_no_cache_etag_can_still_revalidate_without_fallback() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/backend.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("etag css"));
*response.headers_mut() = headers("public, no-cache, max-age=600");
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\""));
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-v1\""));
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert!(p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
assert!(cache.get(&ticket).is_none(), "backend no-cache cannot produce an unvalidated HIT");
let stored = cache.revalidation(&ticket).expect("validator and body retained for 304");
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&ticket).is_none(),
"backend no-cache cannot produce an unvalidated HIT"
);
let stored = cache
.revalidation(&ticket)
.expect("validator and body retained for 304");
let mut request = HeaderMap::new();
assert!(stored.apply_validators(&mut request), "conditional request can validate stored object");
assert!(
stored.apply_validators(&mut request),
"conditional request can validate stored object"
);
assert_eq!(request["if-none-match"], "\"origin-v1\"");
}
@@ -184,14 +416,36 @@ mod varnish_compatible_tests {
let cache = Cache::new(1);
let mut settings = Settings::default();
settings.default_ttl_seconds = 75;
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/backend.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, no-cache");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(p.ttl, 75);
assert!(!p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
let hit = cache.get(&ticket).expect("explicit public override allows fresh HIT without ETag");
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
let hit = cache
.get(&ticket)
.expect("explicit public override allows fresh HIT without ETag");
assert!(hit.remaining_ttl > 0);
}
@@ -199,59 +453,189 @@ mod varnish_compatible_tests {
async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fallback.css", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fallback.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public fallback"));
*response.headers_mut() = headers("public, max-age=0, stale-if-error=120");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
assert!(cache.get(&ticket).is_none(), "zero TTL must never create a fresh HIT");
assert!(cache.revalidation(&ticket).unwrap().can_stale_if_error(), "body remains available on backend failure");
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&ticket).is_none(),
"zero TTL must never create a fresh HIT"
);
assert!(
cache.revalidation(&ticket).unwrap().can_stale_if_error(),
"body remains available on backend failure"
);
}
#[tokio::test]
async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("css"));
*response.headers_mut() = headers("public, max-age=60");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(45);
}
}
let too_fresh = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-age=0, min-fresh=20")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&too_fresh).is_none(), "min-fresh requires 20s but only 15s remain");
let enough = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("min-fresh=10")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&enough).is_some(), "10s remaining freshness is available");
let too_fresh = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("no-cache, max-age=0, min-fresh=20"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&too_fresh).is_none(),
"min-fresh requires 20s but only 15s remain"
);
let enough = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("min-fresh=10"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&enough).is_some(),
"10s remaining freshness is available"
);
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(90);
}
}
let max_stale = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-stale=45")).unwrap().with_varnish_compatible(true);
let stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object");
let max_stale = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("no-cache, max-stale=45"),
)
.unwrap()
.with_varnish_compatible(true);
let stale = cache
.get(&max_stale)
.expect("max-stale explicitly allows retained stale object");
assert!(stale.stale);
assert!(!stale.background_revalidate, "explicit max-stale never triggers implicit background refresh");
let too_old = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("max-stale=10")).unwrap().with_varnish_compatible(true);
assert!(
!stale.background_revalidate,
"explicit max-stale never triggers implicit background refresh"
);
let too_old = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("max-stale=10"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(cache.get(&too_old).is_none());
let default = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap().with_varnish_compatible(true);
assert!(cache.get(&default).is_none(), "expired object cannot be served without stale allowance");
let default = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&HeaderMap::new(),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&default).is_none(),
"expired object cannot be served without stale allowance"
);
}
#[tokio::test]
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/style.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, max-age=600");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
@@ -260,27 +644,68 @@ mod varnish_compatible_tests {
}
let mut request = headers("no-cache, max-age=0");
request.insert("pragma", HeaderValue::from_static("no-cache"));
let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true);
let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT");
assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::<u64>().unwrap() >= 45);
let compatibility_ticket = cache
.ticket(1, "https", "example.test", "app", "/style.css", &request)
.unwrap()
.with_varnish_compatible(true);
let first = cache
.get(&compatibility_ticket)
.expect("hard reload should keep a fresh HIT");
assert!(
first
.response
.headers()
.get("age")
.unwrap()
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 45
);
assert_eq!(first.hits, 1);
let again = cache.get(&compatibility_ticket).unwrap();
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
assert_eq!(cache.view().entries, 1);
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap();
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation");
let strict = cache
.ticket(1, "https", "example.test", "app", "/style.css", &request)
.unwrap();
assert!(
cache.get(&strict).is_none(),
"strict max-age=0 requires revalidation"
);
}
#[tokio::test]
async fn sixty_second_grace_and_no_unconditional_stale_outside_window() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("js"));
*response.headers_mut() = headers("public, max-age=60");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(plan.stale_while_revalidate, 60);
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
@@ -289,12 +714,50 @@ mod varnish_compatible_tests {
}
let hit = cache.get(&ticket).expect("grace HIT");
assert!(hit.stale && hit.background_revalidate);
let max_stale_zero = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("max-stale=0")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&max_stale_zero).is_none(), "explicit max-stale=0 must override implicit grace");
let max_stale_small = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=3")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&max_stale_small).is_none(), "max-stale=3 rejects object stale by 5s, even in grace");
let max_stale_enough = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=9")).unwrap().with_varnish_compatible(true);
let accepted = cache.get(&max_stale_enough).expect("max-stale=9 admits object 5s stale");
let max_stale_zero = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&headers("max-stale=0"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&max_stale_zero).is_none(),
"explicit max-stale=0 must override implicit grace"
);
let max_stale_small = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&headers("no-cache, max-stale=3"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&max_stale_small).is_none(),
"max-stale=3 rejects object stale by 5s, even in grace"
);
let max_stale_enough = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&headers("no-cache, max-stale=9"),
)
.unwrap()
.with_varnish_compatible(true);
let accepted = cache
.get(&max_stale_enough)
.expect("max-stale=9 admits object 5s stale");
assert!(accepted.stale);
{
let mut store = cache.store.lock().unwrap();
@@ -302,6 +765,9 @@ mod varnish_compatible_tests {
entry.received = Instant::now() - Duration::from_secs(125);
}
}
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT");
assert!(
cache.get(&ticket).is_none(),
"expired beyond grace does not produce HIT"
);
}
}
+56 -20
View File
@@ -34,14 +34,26 @@ mod tests {
headers.append("accept-encoding", "deflate;q=0".parse().unwrap());
headers.append("cache-control", "max-age=0".parse().unwrap());
if let Some(name) = skip {
headers.append(name, if name == "range" { "bytes=0-3" } else { "no-transform" }.parse().unwrap());
headers.append(
name,
if name == "range" {
"bytes=0-3"
} else {
"no-transform"
}
.parse()
.unwrap(),
);
}
headers.insert("cookie", "session=private".parse().unwrap());
headers.insert("authorization", "Bearer test".parse().unwrap());
headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap());
let selected = request_headers(&headers);
for name in ["accept-encoding", "range", "cache-control"] {
assert_eq!(headers.get_all(name).iter().collect::<Vec<_>>(), selected.get_all(name).iter().collect::<Vec<_>>());
assert_eq!(
headers.get_all(name).iter().collect::<Vec<_>>(),
selected.get_all(name).iter().collect::<Vec<_>>()
);
}
assert!(!selected.contains_key("cookie"));
assert!(!selected.contains_key("authorization"));
@@ -49,7 +61,10 @@ mod tests {
let narrow = encode(response(), &selected, &method, &Settings::default()).await;
assert_eq!(full.status(), narrow.status());
assert_eq!(full.headers(), narrow.headers());
assert_eq!(full.into_body().collect().await.unwrap().to_bytes(), narrow.into_body().collect().await.unwrap().to_bytes());
assert_eq!(
full.into_body().collect().await.unwrap().to_bytes(),
narrow.into_body().collect().await.unwrap().to_bytes()
);
}
}
}
@@ -59,7 +74,10 @@ mod tests {
#[test]
fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() {
let global = Settings::default();
let force_on = Overrides { enabled: Some(true), ..Default::default() };
let force_on = Overrides {
enabled: Some(true),
..Default::default()
};
for offer in ["identity", "gzip;q=0, br;q=0, zstd;q=0", "deflate", "*;q=0"] {
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", offer.parse().unwrap());
@@ -86,10 +104,16 @@ mod tests {
assert!(!selective.gzip);
assert!(selective.br);
assert!(!selective.zstd);
assert!(!selective.deflate, "a scope cannot enable a globally disabled codec");
assert!(
!selective.deflate,
"a scope cannot enable a globally disabled codec"
);
let disabled = Overrides { enabled: Some(false), ..Default::default() }
.resolve(&global, &inherited);
let disabled = Overrides {
enabled: Some(false),
..Default::default()
}
.resolve(&global, &inherited);
assert!(!disabled.enabled());
let parent_disabled = global.scoped(false);
@@ -164,7 +188,16 @@ mod tests {
.get_all("vary")
.iter()
.any(|v| v.to_str().unwrap().contains("accept-encoding")));
assert!(response.into_body().collect().await.unwrap().to_bytes().len() < 1000);
assert!(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len()
< 1000
);
}
}
@@ -244,10 +277,9 @@ mod tests {
dynamic
.headers_mut()
.insert("set-cookie", "session=next; HttpOnly".parse().unwrap());
dynamic.headers_mut().insert(
"cache-control",
"private, no-store".parse().unwrap(),
);
dynamic
.headers_mut()
.insert("cache-control", "private, no-store".parse().unwrap());
let encoded = encode(dynamic, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip");
}
@@ -306,19 +338,23 @@ mod tests {
let mut no_transform_request = headers.clone();
no_transform_request.insert("cache-control", "no-transform".parse().unwrap());
assert!(!encode(response(), &no_transform_request, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
assert!(
!encode(response(), &no_transform_request, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding")
);
let mut no_transform_response = response();
no_transform_response
.headers_mut()
.insert("cache-control", "public, no-transform".parse().unwrap());
assert!(!encode(no_transform_response, &headers, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding"));
assert!(
!encode(no_transform_response, &headers, &Method::GET, &settings)
.await
.headers()
.contains_key("content-encoding")
);
let mut sse = response();
sse.headers_mut()
+7 -2
View File
@@ -72,7 +72,11 @@ pub struct Config {
pub host_policies: Vec<HostPolicy>,
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
// Migration happens in compile_with_active, before validation and persistence.
#[serde(default, rename = "user_agent_blocking", skip_serializing_if = "Option::is_none")]
#[serde(
default,
rename = "user_agent_blocking",
skip_serializing_if = "Option::is_none"
)]
pub legacy_user_agent_blocking: Option<crate::user_agent::Policy>,
#[serde(default)]
pub global_security: GlobalSecurity,
@@ -124,7 +128,8 @@ impl Config {
impl GlobalSecurity {
pub fn compile(&mut self) -> Result<CompiledGlobalSecurity> {
let user_agent_blocking = self.user_agent_blocking
let user_agent_blocking = self
.user_agent_blocking
.get_or_insert_with(Default::default)
.compile()?;
self.country_access.validate()?;
+68 -16
View File
@@ -1189,21 +1189,41 @@ mod tests {
fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() {
let config: Config = serde_json::from_value(serde_json::json!({
"user_agent_blocking": {"enabled":true,"patterns":["bad-bot"]}
})).unwrap();
assert_eq!(config.global_security.country_access.mode, crate::rate_limit::CountryMode::Off);
}))
.unwrap();
assert_eq!(
config.global_security.country_access.mode,
crate::rate_limit::CountryMode::Off
);
assert!(config.global_security.acl.is_none());
assert_eq!(config.global_security.method_policy.mode, crate::vhost::MethodPolicyMode::Off);
assert_eq!(
config.global_security.method_policy.mode,
crate::vhost::MethodPolicyMode::Off
);
assert!(!config.global_security.content_type_policy.enabled);
assert!(!config.global_security.rate_limit.enabled);
assert!(!config.global_security.security_headers.hsts.enabled);
let snapshot = compile(config, 1, &policy()).unwrap();
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("BAD-BOT/2"));
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers));
assert!(snapshot.envelope.config.legacy_user_agent_blocking.is_none());
headers.insert(
http::header::USER_AGENT,
http::HeaderValue::from_static("BAD-BOT/2"),
);
assert!(snapshot
.global_security
.user_agent_blocking
.blocks(&headers));
assert!(snapshot
.envelope
.config
.legacy_user_agent_blocking
.is_none());
let canonical = serde_json::to_value(&snapshot.envelope.config).unwrap();
assert!(canonical.get("user_agent_blocking").is_none());
assert_eq!(canonical["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot");
assert_eq!(
canonical["global_security"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(snapshot.global_security.country_access.allows(None));
}
@@ -1214,7 +1234,8 @@ mod tests {
"global_security": {
"user_agent_blocking": { "enabled":false,"patterns":[] }
}
})).unwrap();
}))
.unwrap();
let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string();
assert!(err.contains("conflicting legacy"), "{err}");
}
@@ -1228,12 +1249,20 @@ mod tests {
});
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("user_agent_blocking").is_none());
assert!(json["global_security"]["user_agent_blocking"]["enabled"].as_bool().unwrap());
assert!(json["global_security"]["user_agent_blocking"]["enabled"]
.as_bool()
.unwrap());
let restored: Config = serde_json::from_value(json).unwrap();
let snapshot = compile(restored, 1, &policy()).unwrap();
let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("trusted-test-bot/1"));
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers));
headers.insert(
http::header::USER_AGENT,
http::HeaderValue::from_static("trusted-test-bot/1"),
);
assert!(snapshot
.global_security
.user_agent_blocking
.blocks(&headers));
}
#[test]
@@ -1260,16 +1289,35 @@ mod tests {
let snapshot = compile(config, 2, &policy()).unwrap();
let rules = &snapshot.global_security;
assert_eq!(snapshot.envelope.config.global_security.country_access.countries, vec!["RU"]);
assert_eq!(
snapshot
.envelope
.config
.global_security
.country_access
.countries,
vec!["RU"]
);
assert!(!rules.country_access.allows(Some("RU")));
assert!(rules.country_access.allows(None));
assert!(!rules.acl.as_ref().unwrap().allows("192.0.2.2".parse().unwrap()));
assert!(rules.acl.as_ref().unwrap().allows("198.51.100.1".parse().unwrap()));
assert!(!rules
.acl
.as_ref()
.unwrap()
.allows("192.0.2.2".parse().unwrap()));
assert!(rules
.acl
.as_ref()
.unwrap()
.allows("198.51.100.1".parse().unwrap()));
assert!(!rules.method_policy.allows(&http::Method::TRACE));
assert!(rules.method_policy.allows(&http::Method::GET));
assert!(rules.rate_limit.enabled);
let mut headers = http::HeaderMap::new();
headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/x-danger; charset=utf-8"));
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/x-danger; charset=utf-8"),
);
assert!(rules.content_type_policy.blocks(&headers));
headers.clear();
rules.headers.apply(&mut headers, true);
@@ -1292,7 +1340,11 @@ mod tests {
config.global_security.security_headers.hsts.preload = true;
assert!(compile(config.clone(), 1, &policy()).is_err());
config.global_security.security_headers.hsts.enabled = true;
config.global_security.security_headers.hsts.include_subdomains = true;
config
.global_security
.security_headers
.hsts
.include_subdomains = true;
assert!(compile(config, 1, &policy()).is_ok());
}
+2 -2
View File
@@ -1,10 +1,10 @@
use crate::config::{Config, Envelope};
mod api_tokens;
mod certificate_api;
mod dashboard_presets;
mod history;
mod api_tokens;
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
use anyhow::{ensure, Context, Result};
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
use serde::Serialize;
use sqlx::{
+2 -1
View File
@@ -21,7 +21,8 @@ impl Diagnostics {
tokio::spawn(async move {
let mut previous: Option<(View, Counters)> = None;
loop {
let sample = tokio::task::spawn_blocking(move || system_stats::sample(previous)).await;
let sample =
tokio::task::spawn_blocking(move || system_stats::sample(previous)).await;
if let Ok((view, counters)) = sample {
*sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone();
previous = Some((view, counters));
+68 -29
View File
@@ -221,8 +221,15 @@ impl Compiled {
include_original_url: bool,
) -> Result<Context> {
self.context_with_peer(
incoming, peer, local, tls, authority, uri, include_original_url,
None, false,
incoming,
peer,
local,
tls,
authority,
uri,
include_original_url,
None,
false,
)
}
@@ -245,7 +252,9 @@ impl Compiled {
if !trusted_peer {
let scheme = if tls { "https" } else { "http" };
let authority_text = authority.to_str()?;
if !validated_host { crate::config::request_host(authority_text)?; }
if !validated_host {
crate::config::request_host(authority_text)?;
}
let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{target}")
@@ -267,12 +276,19 @@ impl Compiled {
};
let by_text = cached.is_none().then(|| node(canonical(local.ip())));
let peer_node = cached.map_or_else(
|| if peer_ip.is_ipv4() { peer_text.as_deref().unwrap_or("") }
else { ipv6_node.as_deref().unwrap_or("") },
|| {
if peer_ip.is_ipv4() {
peer_text.as_deref().unwrap_or("")
} else {
ipv6_node.as_deref().unwrap_or("")
}
},
|cached| cached.peer_node.as_str(),
);
let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""),
|cached| cached.by_node.as_str());
let by_node = cached.map_or_else(
|| by_text.as_deref().unwrap_or(""),
|cached| cached.by_node.as_str(),
);
let forwarded = format!(
"for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\""
);
@@ -282,11 +298,14 @@ impl Compiled {
values.insert("x-real-ip", peer_header);
values.insert("x-forwarded-host", authority.clone());
values.insert("x-forwarded-proto", HeaderValue::from_static(scheme));
values.insert("x-forwarded-port", if let Some(cached) = cached {
cached.port_header.clone()
} else {
local.port().to_string().parse()?
});
values.insert(
"x-forwarded-port",
if let Some(cached) = cached {
cached.port_header.clone()
} else {
local.port().to_string().parse()?
},
);
if let Some(peer) = cached {
peer.remember_untrusted(authority, tls, &values);
}
@@ -320,7 +339,9 @@ impl Compiled {
};
}
let authority_text = authority.to_str()?;
if !validated_host { crate::config::request_host(authority_text)?; }
if !validated_host {
crate::config::request_host(authority_text)?;
}
let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url {
format!("{scheme}://{authority_text}{original_target}")
@@ -331,24 +352,36 @@ impl Compiled {
let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len());
let mut xff = String::with_capacity(ips.len() * 44);
for (index, ip) in ips.iter().enumerate() {
if index > 0 { forwarded.push_str(", "); xff.push_str(", "); }
if index > 0 {
forwarded.push_str(", ");
xff.push_str(", ");
}
forwarded.push_str("for=");
if index + 1 == ips.len() {
if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); }
else { forwarded.push_str(&node(*ip)); }
if let Some(cached) = cached {
forwarded.push_str(&cached.peer_node);
} else {
forwarded.push_str(&node(*ip));
}
} else {
forwarded.push_str(&node(*ip));
}
if index + 1 == ips.len() {
if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); }
else { write!(&mut xff, "{ip}")?; }
if let Some(cached) = cached {
xff.push_str(cached.peer_header.to_str()?);
} else {
write!(&mut xff, "{ip}")?;
}
} else {
write!(&mut xff, "{ip}")?;
}
}
forwarded.push_str(";by=");
if let Some(cached) = cached { forwarded.push_str(&cached.by_node); }
else { forwarded.push_str(&node(canonical(local.ip()))); }
if let Some(cached) = cached {
forwarded.push_str(&cached.by_node);
} else {
forwarded.push_str(&node(canonical(local.ip())));
}
write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?;
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
@@ -364,11 +397,14 @@ impl Compiled {
} else {
local.port()
};
values.insert("x-forwarded-port", if cached.is_some() && !(trusted_peer && self.trust_proto) {
cached.expect("tested above").port_header.clone()
} else {
port.to_string().parse()?
});
values.insert(
"x-forwarded-port",
if cached.is_some() && !(trusted_peer && self.trust_proto) {
cached.expect("tested above").port_header.clone()
} else {
port.to_string().parse()?
},
);
values.insert("x-forwarded-uri", original_target.parse()?);
Ok(Context {
client_ip,
@@ -423,14 +459,15 @@ impl Context {
for name in headers.keys() {
let text = name.as_str();
if matches!(text, "x-original-url" | "x-original-method")
|| (text.starts_with("x-forwarded-")
&& !self.values.contains_key(text))
|| (text.starts_with("x-forwarded-") && !self.values.contains_key(text))
{
remove.get_or_insert_with(Vec::new).push(name.clone());
}
}
if let Some(remove) = remove {
for name in remove { headers.remove(name); }
for name in remove {
headers.remove(name);
}
}
if !include_client_ip {
for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
@@ -438,7 +475,9 @@ impl Context {
}
}
for (name, value) in &self.values {
if !include_client_ip && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") {
if !include_client_ip
&& matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip")
{
continue;
}
headers.insert(name.clone(), value.clone());
+25 -6
View File
@@ -1,11 +1,11 @@
mod mmdb;
mod update;
mod update_http;
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
pub use update::{
UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL,
DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL,
};
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
@@ -46,7 +46,10 @@ impl Default for DatabaseFormat {
#[derive(Debug)]
enum Backend {
Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> },
Csv {
ipv4: Box<[V4Range]>,
ipv6: Box<[V6Range]>,
},
Mmdb(mmdb::Database),
}
@@ -64,7 +67,12 @@ pub struct View {
}
impl Database {
pub fn empty() -> Self {
Self { backend: Backend::Csv { ipv4: Box::new([]), ipv6: Box::new([]) } }
Self {
backend: Backend::Csv {
ipv4: Box::new([]),
ipv6: Box::new([]),
},
}
}
pub fn load(directory: &Path) -> Result<Self> {
if directory.join("country.mmdb").is_file() {
@@ -78,11 +86,22 @@ impl Database {
DatabaseFormat::CsvPair => {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty");
Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } })
ensure!(
!ipv4.is_empty() && !ipv6.is_empty(),
"GeoIP country database is empty"
);
Ok(Self {
backend: Backend::Csv {
ipv4: ipv4.into_boxed_slice(),
ipv6: ipv6.into_boxed_slice(),
},
})
}
DatabaseFormat::Mmdb => Ok(Self {
backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?),
backend: Backend::Mmdb(mmdb::Database::load(
&directory.join("country.mmdb"),
MAX_FILE_BYTES,
)?),
}),
}
}
+174 -40
View File
@@ -36,7 +36,10 @@ struct Budget {
impl Budget {
fn charge(&mut self) -> Result<()> {
self.values = self.values.saturating_add(1);
ensure!(self.values <= MAX_VALUES, "MMDB value traversal limit exceeded");
ensure!(
self.values <= MAX_VALUES,
"MMDB value traversal limit exceeded"
);
Ok(())
}
}
@@ -45,20 +48,56 @@ impl Database {
pub(super) fn load(path: &Path, max_bytes: u64) -> Result<Self> {
let metadata = fs::symlink_metadata(path)
.with_context(|| format!("open GeoIP MMDB {}", path.display()))?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP MMDB must be a regular file");
ensure!(metadata.len() <= max_bytes, "GeoIP MMDB exceeds configured maximum size");
let bytes = fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?;
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"GeoIP MMDB must be a regular file"
);
ensure!(
metadata.len() <= max_bytes,
"GeoIP MMDB exceeds configured maximum size"
);
let bytes =
fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?;
let marker = metadata_marker(&bytes).context("MMDB metadata marker not found")?;
let metadata_offset = marker + METADATA_MARKER.len();
let base = metadata_offset;
let node_count = u32::try_from(read_uint_path(&bytes, metadata_offset, base, &["node_count"])?).context("MMDB node_count is too large")?;
let record_size = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["record_size"])?).context("MMDB record_size is too large")?;
let ip_version = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["ip_version"])?).context("MMDB ip_version is too large")?;
let major = read_uint_path(&bytes, metadata_offset, base, &["binary_format_major_version"])?;
let node_count = u32::try_from(read_uint_path(
&bytes,
metadata_offset,
base,
&["node_count"],
)?)
.context("MMDB node_count is too large")?;
let record_size = u16::try_from(read_uint_path(
&bytes,
metadata_offset,
base,
&["record_size"],
)?)
.context("MMDB record_size is too large")?;
let ip_version = u16::try_from(read_uint_path(
&bytes,
metadata_offset,
base,
&["ip_version"],
)?)
.context("MMDB ip_version is too large")?;
let major = read_uint_path(
&bytes,
metadata_offset,
base,
&["binary_format_major_version"],
)?;
let database_type = read_string_path(&bytes, metadata_offset, base, &["database_type"])?;
ensure!(node_count > 0, "MMDB node_count must be positive");
ensure!(matches!(record_size, 24 | 28 | 32), "unsupported MMDB record_size {record_size}");
ensure!(matches!(ip_version, 4 | 6), "invalid MMDB ip_version {ip_version}");
ensure!(
matches!(record_size, 24 | 28 | 32),
"unsupported MMDB record_size {record_size}"
);
ensure!(
matches!(ip_version, 4 | 6),
"invalid MMDB ip_version {ip_version}"
);
ensure!(major == 2, "unsupported MMDB major format version {major}");
let node_bytes = usize::from(record_size)
.checked_mul(2)
@@ -67,9 +106,17 @@ impl Database {
let search_tree_size = (node_count as usize)
.checked_mul(node_bytes)
.context("MMDB search tree size overflow")?;
let data_section_start = search_tree_size.checked_add(16).context("MMDB data section overflow")?;
ensure!(data_section_start <= marker, "MMDB search tree overlaps metadata");
ensure!(bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]), "MMDB data-section separator is invalid");
let data_section_start = search_tree_size
.checked_add(16)
.context("MMDB data section overflow")?;
ensure!(
data_section_start <= marker,
"MMDB search tree overlaps metadata"
);
ensure!(
bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]),
"MMDB data-section separator is invalid"
);
Ok(Self {
bytes,
node_count,
@@ -100,7 +147,8 @@ impl Database {
}
}
let mut budget = Budget::default();
if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget) {
if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget)
{
if let Some(code) = super::normalize_country_code(&code) {
return Some(String::from_utf8_lossy(&code).into_owned());
}
@@ -135,17 +183,27 @@ impl Database {
if node == self.node_count {
return Ok(None);
}
ensure!(node >= self.node_count.saturating_add(16), "invalid MMDB data pointer");
ensure!(
node >= self.node_count.saturating_add(16),
"invalid MMDB data pointer"
);
let offset = (node as usize)
.checked_sub(self.node_count as usize)
.and_then(|value| value.checked_add(self.search_tree_size))
.context("MMDB data pointer overflow")?;
ensure!(offset >= self.data_section_start && offset < self.metadata_offset - METADATA_MARKER.len(), "MMDB data pointer is out of bounds");
ensure!(
offset >= self.data_section_start
&& offset < self.metadata_offset - METADATA_MARKER.len(),
"MMDB data pointer is out of bounds"
);
Ok(Some(offset))
}
fn read_node(&self, node: u32, side: usize) -> Result<u32> {
ensure!((node as usize) < self.node_count as usize, "MMDB node is out of range");
ensure!(
(node as usize) < self.node_count as usize,
"MMDB node is out of range"
);
let base = match self.record_size {
24 => node as usize * 6,
28 => node as usize * 7,
@@ -170,7 +228,9 @@ impl Database {
}
fn metadata_marker(bytes: &[u8]) -> Option<usize> {
let start = bytes.len().saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len());
let start = bytes
.len()
.saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len());
bytes[start..]
.windows(METADATA_MARKER.len())
.rposition(|window| window == METADATA_MARKER)
@@ -179,17 +239,26 @@ fn metadata_marker(bytes: &[u8]) -> Option<usize> {
fn read_uint_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<u64> {
let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB metadata field missing")?;
let value = find_path(bytes, offset, base, path, 0, &mut budget)?
.context("MMDB metadata field missing")?;
read_uint(bytes, value, base, 0, &mut budget)
}
fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<String> {
let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB string field missing")?;
let value = find_path(bytes, offset, base, path, 0, &mut budget)?
.context("MMDB string field missing")?;
read_string(bytes, value, base, 0, &mut budget)
}
fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usize, budget: &mut Budget) -> Result<Option<usize>> {
fn find_path(
bytes: &[u8],
offset: usize,
base: usize,
path: &[&str],
depth: usize,
budget: &mut Budget,
) -> Result<Option<usize>> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let header = header(bytes, offset, base)?;
@@ -209,7 +278,9 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi
if path.len() == 1 {
return Ok(Some(value_offset));
}
if let Some(found) = find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)? {
if let Some(found) =
find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)?
{
return Ok(Some(found));
}
}
@@ -218,7 +289,13 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi
Ok(None)
}
fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<String> {
fn read_string(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<String> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
@@ -226,31 +303,61 @@ fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &
return read_string(bytes, pointer, base, depth + 1, budget);
}
ensure!(h.kind == 2, "MMDB value is not a string");
ensure!(h.size <= MAX_STRING_BYTES, "MMDB string exceeds the decode limit");
let data = bytes.get(h.payload..h.next).context("MMDB string is truncated")?;
Ok(std::str::from_utf8(data).context("MMDB string is not UTF-8")?.to_owned())
ensure!(
h.size <= MAX_STRING_BYTES,
"MMDB string exceeds the decode limit"
);
let data = bytes
.get(h.payload..h.next)
.context("MMDB string is truncated")?;
Ok(std::str::from_utf8(data)
.context("MMDB string is not UTF-8")?
.to_owned())
}
fn read_uint(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<u64> {
fn read_uint(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<u64> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
if let Some(pointer) = h.pointer {
return read_uint(bytes, pointer, base, depth + 1, budget);
}
ensure!(matches!(h.kind, 5 | 6 | 9), "MMDB value is not an unsigned integer");
ensure!(
matches!(h.kind, 5 | 6 | 9),
"MMDB value is not an unsigned integer"
);
ensure!(h.size <= 8, "MMDB integer is too wide");
read_be(bytes, h.payload, h.size)
}
fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> {
let h = header(bytes, offset, base)?;
ensure!(h.pointer.is_none() && h.kind == 2, "MMDB map key is not an inline string");
let value = bytes.get(h.payload..h.next).context("MMDB map key is truncated")?;
Ok((std::str::from_utf8(value).context("MMDB map key is not UTF-8")?, h.next))
ensure!(
h.pointer.is_none() && h.kind == 2,
"MMDB map key is not an inline string"
);
let value = bytes
.get(h.payload..h.next)
.context("MMDB map key is truncated")?;
Ok((
std::str::from_utf8(value).context("MMDB map key is not UTF-8")?,
h.next,
))
}
fn skip(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<usize> {
fn skip(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<usize> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
@@ -290,18 +397,30 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result<Header> {
let raw = read_be(bytes, payload, count)? as usize;
let pointer = match class {
0 => (low << 8) | raw,
1 => ((low << 16) | raw).checked_add(2048).context("MMDB pointer overflow")?,
2 => ((low << 24) | raw).checked_add(526_336).context("MMDB pointer overflow")?,
1 => ((low << 16) | raw)
.checked_add(2048)
.context("MMDB pointer overflow")?,
2 => ((low << 24) | raw)
.checked_add(526_336)
.context("MMDB pointer overflow")?,
3 => raw,
_ => unreachable!(),
};
let target = base.checked_add(pointer).context("MMDB pointer overflow")?;
ensure!(target < bytes.len(), "MMDB pointer is out of bounds");
return Ok(Header { kind: 1, size: 0, payload, next: payload + count, pointer: Some(target) });
return Ok(Header {
kind: 1,
size: 0,
payload,
next: payload + count,
pointer: Some(target),
});
}
let mut cursor = offset + 1;
let kind = if primary == 0 {
let extended = *bytes.get(cursor).context("MMDB extended type is truncated")?;
let extended = *bytes
.get(cursor)
.context("MMDB extended type is truncated")?;
cursor += 1;
extended.checked_add(7).context("MMDB type overflow")?
} else {
@@ -314,20 +433,35 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result<Header> {
31 => 65_821 + read_be(bytes, cursor, 3)? as usize,
_ => unreachable!(),
};
cursor += match size_bits { 29 => 1, 30 => 2, 31 => 3, _ => 0 };
cursor += match size_bits {
29 => 1,
30 => 2,
31 => 3,
_ => 0,
};
let next = match kind {
7 | 11 | 14 => cursor,
_ => cursor.checked_add(size).context("MMDB field size overflow")?,
_ => cursor
.checked_add(size)
.context("MMDB field size overflow")?,
};
ensure!(next <= bytes.len(), "MMDB field is out of bounds");
Ok(Header { kind, size, payload: cursor, next, pointer: None })
Ok(Header {
kind,
size,
payload: cursor,
next,
pointer: None,
})
}
fn read_be(bytes: &[u8], offset: usize, count: usize) -> Result<u64> {
ensure!(count <= 8, "MMDB integer is too wide");
let end = offset.checked_add(count).context("MMDB read overflow")?;
let data = bytes.get(offset..end).context("MMDB value is truncated")?;
Ok(data.iter().fold(0u64, |value, byte| (value << 8) | u64::from(*byte)))
Ok(data
.iter()
.fold(0u64, |value, byte| (value << 8) | u64::from(*byte)))
}
#[cfg(test)]
+12 -3
View File
@@ -59,7 +59,10 @@ mod tests {
std::fs::write(&path, fixture("PL")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.database_type(), "GeoLite2-Country");
assert_eq!(database.country("192.0.2.1".parse().unwrap()).as_deref(), Some("PL"));
assert_eq!(
database.country("192.0.2.1".parse().unwrap()).as_deref(),
Some("PL")
);
assert!(database.country("2001:db8::1".parse().unwrap()).is_none());
}
@@ -69,7 +72,10 @@ mod tests {
let path = directory.path().join("country.mmdb");
std::fs::write(&path, sapics_fixture("DE")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("198.51.100.8".parse().unwrap()).as_deref(), Some("DE"));
assert_eq!(
database.country("198.51.100.8".parse().unwrap()).as_deref(),
Some("DE")
);
}
#[test]
@@ -78,7 +84,10 @@ mod tests {
let path = directory.path().join("country.mmdb");
std::fs::write(&path, fixture_with_record(string("FR"))).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("192.0.2.44".parse().unwrap()).as_deref(), Some("FR"));
assert_eq!(
database.country("192.0.2.44".parse().unwrap()).as_deref(),
Some("FR")
);
}
#[test]
+28 -7
View File
@@ -6,14 +6,32 @@ mod tests {
assert_eq!(std::mem::size_of::<V6Range>(), 34);
assert_eq!(std::mem::size_of::<V4Range>(), 12);
let ranges = [
V6Range { start: 0u128.to_be_bytes(), end: 127u128.to_be_bytes(), country: *b"PL" },
V6Range { start: 256u128.to_be_bytes(), end: 65535u128.to_be_bytes(), country: *b"DE" },
V6Range { start: (1u128 << 120).to_be_bytes(), end: u128::MAX.to_be_bytes(), country: *b"US" },
V6Range {
start: 0u128.to_be_bytes(),
end: 127u128.to_be_bytes(),
country: *b"PL",
},
V6Range {
start: 256u128.to_be_bytes(),
end: 65535u128.to_be_bytes(),
country: *b"DE",
},
V6Range {
start: (1u128 << 120).to_be_bytes(),
end: u128::MAX.to_be_bytes(),
country: *b"US",
},
];
for (address, expected) in [
(0, Some(*b"PL")), (127, Some(*b"PL")), (128, None),
(255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")),
(65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")),
(0, Some(*b"PL")),
(127, Some(*b"PL")),
(128, None),
(255, None),
(256, Some(*b"DE")),
(65535, Some(*b"DE")),
(65536, None),
(1u128 << 120, Some(*b"US")),
(u128::MAX, Some(*b"US")),
] {
assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected);
}
@@ -33,7 +51,10 @@ mod tests {
)
.unwrap();
let db = Database::load_format(directory.path(), DatabaseFormat::CsvPair).unwrap();
assert_eq!(db.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
assert_eq!(
db.country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
assert!(db.country("198.51.100.1".parse().unwrap()).is_none());
}
}
+178 -50
View File
@@ -94,9 +94,12 @@ impl UpdateSettings {
}
}
}
self.ipv4_checksum_url = validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?;
self.ipv6_checksum_url = validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?;
self.mmdb_checksum_url = validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?;
self.ipv4_checksum_url =
validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?;
self.ipv6_checksum_url =
validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?;
self.mmdb_checksum_url =
validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?;
ensure!(
(1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes),
"GeoIP maximum file size must be between 1 MiB and 256 MiB"
@@ -166,8 +169,14 @@ fn validate_direct_url(label: &str, value: &str) -> Result<String> {
let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https", "{label} must use https://");
ensure!(parsed.host_str().is_some(), "{label} must contain a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain URL credentials");
ensure!(parsed.fragment().is_none(), "{label} must not contain a fragment");
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"{label} must not contain URL credentials"
);
ensure!(
parsed.fragment().is_none(),
"{label} must not contain a fragment"
);
Ok(parsed.to_string())
}
@@ -183,9 +192,18 @@ fn validate_optional_url(label: &str, value: Option<String>) -> Result<Option<St
fn validate_legacy_base(label: &str, value: &str) -> Result<String> {
let mut parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https" && parsed.host_str().is_some(), "{label} must be an HTTPS URL");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain credentials");
ensure!(parsed.query().is_none() && parsed.fragment().is_none(), "{label} must not contain a query or fragment");
ensure!(
parsed.scheme() == "https" && parsed.host_str().is_some(),
"{label} must be an HTTPS URL"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"{label} must not contain credentials"
);
ensure!(
parsed.query().is_none() && parsed.fragment().is_none(),
"{label} must not contain a query or fragment"
);
if !parsed.path().ends_with('/') {
parsed.set_path(&format!("{}/", parsed.path()));
}
@@ -208,7 +226,9 @@ fn try_update_lock(data_dir: &Path) -> Result<Option<UpdateLock>> {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(&path).with_context(|| format!("open GeoIP update lock {}", path.display()))?;
let file = options
.open(&path)
.with_context(|| format!("open GeoIP update lock {}", path.display()))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(UpdateLock(file))),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
@@ -220,8 +240,12 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
let mut observed: Option<UpdateSettings> = None;
let mut next_attempt = tokio::time::Instant::now();
loop {
if shutdown.is_cancelled() { return; }
let Some(app) = app.upgrade() else { return; };
if shutdown.is_cancelled() {
return;
}
let Some(app) = app.upgrade() else {
return;
};
let settings = app.config.load().envelope.config.geoip_updates.clone();
if observed.as_ref() != Some(&settings) {
next_attempt = tokio::time::Instant::now();
@@ -235,7 +259,9 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
Duration::from_secs(settings.interval_seconds)
}
Ok(UpdateOutcome::Current) => Duration::from_secs(settings.interval_seconds),
Ok(UpdateOutcome::Busy) => RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)),
Ok(UpdateOutcome::Busy) => {
RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds))
}
Err(error) => {
tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected");
RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds))
@@ -244,7 +270,9 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
next_attempt = tokio::time::Instant::now() + interval;
}
let wait = if settings.enabled {
next_attempt.saturating_duration_since(tokio::time::Instant::now()).min(CONFIG_POLL_INTERVAL)
next_attempt
.saturating_duration_since(tokio::time::Instant::now())
.min(CONFIG_POLL_INTERVAL)
} else {
CONFIG_POLL_INTERVAL
};
@@ -256,7 +284,11 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
}
}
enum UpdateOutcome { Updated(Vec<String>), Current, Busy }
enum UpdateOutcome {
Updated(Vec<String>),
Current,
Busy,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateResult {
@@ -273,12 +305,25 @@ pub async fn update_now(app: &Arc<crate::runtime::App>) -> Result<UpdateResult>
UpdateOutcome::Current => ("current", Vec::new()),
UpdateOutcome::Busy => ("busy", Vec::new()),
};
Ok(UpdateResult { outcome, updated_files, database: app.geoip.load().view() })
Ok(UpdateResult {
outcome,
updated_files,
database: app.geoip.load().view(),
})
}
async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings) -> Result<UpdateOutcome> {
let data_dir = app.policy.data_dir.as_deref().context("GeoIP updater requires a data directory")?;
let Some(_lock) = try_update_lock(data_dir)? else { return Ok(UpdateOutcome::Busy); };
async fn update_once(
app: &Arc<crate::runtime::App>,
settings: &UpdateSettings,
) -> Result<UpdateOutcome> {
let data_dir = app
.policy
.data_dir
.as_deref()
.context("GeoIP updater requires a data directory")?;
let Some(_lock) = try_update_lock(data_dir)? else {
return Ok(UpdateOutcome::Busy);
};
let directory = data_dir.join("geoip");
ensure_safe_directory(&directory)?;
if settings.format == super::DatabaseFormat::Mmdb && settings.mmdb_url.trim().is_empty() {
@@ -295,9 +340,16 @@ async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings)
}
let mut current = Vec::with_capacity(assets.len());
for asset in &assets {
current.push(file_digest(&directory.join(asset.name), settings.max_bytes)?);
current.push(file_digest(
&directory.join(asset.name),
settings.max_bytes,
)?);
}
if assets.iter().enumerate().all(|(index, _)| expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest))) {
if assets.iter().enumerate().all(|(index, _)| {
expected[index]
.as_ref()
.is_some_and(|digest| current[index].as_deref() == Some(digest))
}) {
reload_if_needed(app, directory, settings.format).await?;
return Ok(UpdateOutcome::Current);
}
@@ -307,35 +359,64 @@ async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings)
let result = async {
for (index, asset) in assets.iter().enumerate() {
let target = stage.join(asset.name);
if expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest)) {
if expected[index]
.as_ref()
.is_some_and(|digest| current[index].as_deref() == Some(digest))
{
copy_private(&directory.join(asset.name), &target, settings.max_bytes)?;
} else {
super::update_http::download(&asset.url, &target, settings.max_bytes, expected[index].as_deref()).await?;
super::update_http::download(
&asset.url,
&target,
settings.max_bytes,
expected[index].as_deref(),
)
.await?;
}
}
let validation_stage = stage.clone();
let format = settings.format;
tokio::task::spawn_blocking(move || super::validate_directory_format(&validation_stage, format))
.await.context("GeoIP validation worker failed")??;
tokio::task::spawn_blocking(move || {
super::validate_directory_format(&validation_stage, format)
})
.await
.context("GeoIP validation worker failed")??;
let mut changed = Vec::new();
for (index, asset) in assets.iter().enumerate() {
let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?.context("staged GeoIP file disappeared")?;
let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?
.context("staged GeoIP file disappeared")?;
if current[index].as_deref() != Some(staged.as_str()) {
publish_private(&stage.join(asset.name), &directory.join(asset.name), settings.max_bytes)?;
publish_private(
&stage.join(asset.name),
&directory.join(asset.name),
settings.max_bytes,
)?;
changed.push(asset.name.to_string());
}
}
reload(app, directory.clone(), settings.format).await?;
Ok::<Vec<String>, anyhow::Error>(changed)
}.await;
}
.await;
let _ = fs::remove_dir_all(&stage);
let changed = result?;
if changed.is_empty() { Ok(UpdateOutcome::Current) } else { Ok(UpdateOutcome::Updated(changed)) }
if changed.is_empty() {
Ok(UpdateOutcome::Current)
} else {
Ok(UpdateOutcome::Updated(changed))
}
}
async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> {
let expected = match format { super::DatabaseFormat::CsvPair => "csv_pair", super::DatabaseFormat::Mmdb => "mmdb" };
async fn reload_if_needed(
app: &Arc<crate::runtime::App>,
directory: PathBuf,
format: super::DatabaseFormat,
) -> Result<()> {
let expected = match format {
super::DatabaseFormat::CsvPair => "csv_pair",
super::DatabaseFormat::Mmdb => "mmdb",
};
let view = app.geoip.load().view();
if !view.loaded || view.format != expected {
reload(app, directory, format).await?;
@@ -343,9 +424,15 @@ async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, fo
Ok(())
}
async fn reload(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> {
let database = tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format))
.await.context("GeoIP reload worker failed")??;
async fn reload(
app: &Arc<crate::runtime::App>,
directory: PathBuf,
format: super::DatabaseFormat,
) -> Result<()> {
let database =
tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format))
.await
.context("GeoIP reload worker failed")??;
publish_database(&app.geoip, database);
Ok(())
}
@@ -365,7 +452,10 @@ async fn publisher_checksum(url: &str) -> Result<String> {
fn ensure_safe_directory(directory: &Path) -> Result<()> {
match fs::symlink_metadata(directory) {
Ok(metadata) => ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must be a real directory"),
Ok(metadata) => ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"GeoIP directory must be a real directory"
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => private_directory(directory)?,
Err(error) => return Err(error.into()),
}
@@ -375,7 +465,10 @@ fn ensure_safe_directory(directory: &Path) -> Result<()> {
fn private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let metadata = fs::symlink_metadata(path)?;
ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must not be a symlink");
ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"GeoIP directory must not be a symlink"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -390,15 +483,24 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result<Option<String>> {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { return Ok(None); }
let mut options = OpenOptions::new(); options.read(true);
#[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); }
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes {
return Ok(None);
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 { break; }
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(Some(format!("{:x}", digest.finalize())))
@@ -406,11 +508,20 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result<Option<String>> {
fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let metadata = fs::symlink_metadata(source)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP source must be a regular file");
ensure!(metadata.len() <= max_bytes, "GeoIP source exceeds configured maximum size");
let mut source_options = OpenOptions::new(); source_options.read(true);
let mut target_options = OpenOptions::new(); target_options.write(true).create_new(true);
#[cfg(unix)] {
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"GeoIP source must be a regular file"
);
ensure!(
metadata.len() <= max_bytes,
"GeoIP source exceeds configured maximum size"
);
let mut source_options = OpenOptions::new();
source_options.read(true);
let mut target_options = OpenOptions::new();
target_options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
source_options.custom_flags(libc::O_NOFOLLOW);
target_options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
@@ -419,7 +530,10 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let mut input = source.take(max_bytes.saturating_add(1));
let mut output = target_options.open(target)?;
let copied = std::io::copy(&mut input, &mut output)?;
ensure!(copied <= max_bytes, "GeoIP source exceeds configured maximum size");
ensure!(
copied <= max_bytes,
"GeoIP source exceeds configured maximum size"
);
output.sync_all()?;
Ok(())
}
@@ -427,12 +541,26 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
fn publish_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let parent = target.parent().context("GeoIP target has no parent")?;
ensure_safe_directory(parent)?;
let temporary = parent.join(format!(".{}.tmp-{:016x}", target.file_name().unwrap_or_default().to_string_lossy(), rand::random::<u64>()));
if let Err(error) = copy_private(source, &temporary, max_bytes) { let _ = fs::remove_file(&temporary); return Err(error); }
if let Err(error) = fs::rename(&temporary, target) { let _ = fs::remove_file(&temporary); return Err(error.into()); }
#[cfg(unix)] {
let temporary = parent.join(format!(
".{}.tmp-{:016x}",
target.file_name().unwrap_or_default().to_string_lossy(),
rand::random::<u64>()
));
if let Err(error) = copy_private(source, &temporary, max_bytes) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
if let Err(error) = fs::rename(&temporary, target) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let directory = OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY).open(parent)?;
let directory = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY)
.open(parent)?;
directory.sync_all()?;
}
Ok(())
+10 -13
View File
@@ -9,12 +9,7 @@ use hyper_util::{
};
use rustls::ClientConfig;
use sha2::{Digest, Sha256};
use std::{
fs::OpenOptions,
path::Path,
sync::Arc,
time::Duration,
};
use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration};
use tokio::io::AsyncWriteExt;
use url::Url;
@@ -66,12 +61,11 @@ pub(super) async fn download(
fn client() -> Result<Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>> {
let roots = crate::update_check::load_root_store();
let tls = ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let tls =
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_connect_timeout(Some(REQUEST_TIMEOUT));
@@ -123,7 +117,10 @@ fn redirect(
.to_str()
.context("GeoIP redirect Location is not UTF-8")?;
let target = crate::update_check::redirect_target(current, location)?;
ensure!(target.scheme() == "https", "GeoIP redirect must remain HTTPS");
ensure!(
target.scheme() == "https",
"GeoIP redirect must remain HTTPS"
);
Ok(target)
}
+22 -5
View File
@@ -76,19 +76,36 @@ mod tests {
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,PL\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,PL\n").unwrap();
let slot = arc_swap::ArcSwap::from_pointee(
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(),
crate::geoip::Database::load_format(
directory.path(),
crate::geoip::DatabaseFormat::CsvPair,
)
.unwrap(),
);
let previous = slot.load_full();
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
assert_eq!(
slot.load().country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,DE\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap();
publish_database(
&slot,
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(),
crate::geoip::Database::load_format(
directory.path(),
crate::geoip::DatabaseFormat::CsvPair,
)
.unwrap(),
);
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("DE"));
assert_eq!(previous.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
assert_eq!(
slot.load().country("192.0.2.7".parse().unwrap()).as_deref(),
Some("DE")
);
assert_eq!(
previous.country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
}
}
+4 -1
View File
@@ -35,7 +35,10 @@ mod tests {
response: vec![],
};
assert!(rules.compile().is_err(), "{name}");
let response_rules = HeaderRules { request: vec![], response: rules.request };
let response_rules = HeaderRules {
request: vec![],
response: rules.request,
};
assert!(response_rules.compile().is_err(), "response {name}");
}
let rules = HeaderRules {
+4 -4
View File
@@ -3,8 +3,8 @@ pub mod acme;
pub mod admin;
#[path = "auth/mod.rs"]
pub mod auth;
pub mod basic_auth;
pub mod backup;
pub mod basic_auth;
pub mod config;
pub mod db;
pub mod demo;
@@ -17,26 +17,26 @@ pub mod runtime;
pub mod server;
pub mod tls;
pub mod traffic;
pub mod upstream;
pub mod update_check;
pub mod upstream;
pub mod user_agent;
pub mod cache;
pub mod compression;
pub mod transform;
pub mod diagnostics;
pub mod docker;
pub mod pages;
pub mod settings;
pub mod stream;
pub mod system_stats;
pub mod transform;
pub mod balance;
pub mod forwarding;
pub mod geoip;
pub mod http3;
pub mod security;
pub mod secret_store;
pub mod security;
pub mod tinyauth;
pub mod vhost;
pub mod web_server;
+10 -2
View File
@@ -119,7 +119,11 @@ fn render_backends(
),
("read_timeouts_total", "read_timeouts_total", "counter"),
("send_timeouts_total", "send_timeouts_total", "counter"),
("upstream_client_generation", "upstream_client_generation", "gauge"),
(
"upstream_client_generation",
"upstream_client_generation",
"gauge",
),
(
"upstream_client_generation_requests",
"upstream_client_generation_requests",
@@ -414,7 +418,11 @@ fn render_docker(
),
("read_timeouts_total", "read_timeouts_total", "counter"),
("send_timeouts_total", "send_timeouts_total", "counter"),
("upstream_client_generation", "upstream_client_generation", "gauge"),
(
"upstream_client_generation",
"upstream_client_generation",
"gauge",
),
(
"upstream_client_generation_requests",
"upstream_client_generation_requests",
+13 -6
View File
@@ -50,9 +50,8 @@ mod tests {
async fn optimized_empty_request_keeps_exchange_until_response() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request(
Body::empty(), &exchange, Duration::from_secs(1),
);
let body =
super::super::wrap_observed_request(Body::empty(), &exchange, Duration::from_secs(1));
assert!(body.is_end_stream());
drop(body);
assert_eq!(slots.available_permits(), 0);
@@ -66,7 +65,9 @@ mod tests {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request(
Body::from("payload"), &exchange, Duration::from_secs(1),
Body::from("payload"),
&exchange,
Duration::from_secs(1),
);
assert!(!body.is_end_stream());
drop(exchange);
@@ -272,7 +273,10 @@ mod tests {
assert_eq!(entries[0].route.as_deref(), Some("route-1"));
assert_eq!(entries[0].client_ip.as_deref(), Some("198.51.100.9"));
assert_eq!(entries[0].country.as_deref(), Some("DE"));
assert_eq!(entries[0].upstream_error.as_deref(), Some("upstream closed"));
assert_eq!(
entries[0].upstream_error.as_deref(),
Some("upstream closed")
);
assert_eq!(entries[0].cache_reason, "cache_disabled");
assert_eq!(entries[0].websocket_error, Some("upstream_http_error"));
assert_eq!(entries[0].h3_reset_code, Some("h3_general_protocol_error"));
@@ -296,7 +300,10 @@ mod tests {
assert_eq!(entries[0].host, "localhost");
assert_eq!(entries[0].path, "/echo");
assert_eq!(entries[0].request_id.len(), 64);
assert_eq!(ExchangeSource::name(ExchangeSource::ReverseProxy as u8), "reverse_proxy");
assert_eq!(
ExchangeSource::name(ExchangeSource::ReverseProxy as u8),
"reverse_proxy"
);
assert_eq!(read_effective(0), None);
assert_eq!(read_effective(1), Some(false));
assert_eq!(read_effective(2), Some(true));
+4 -1
View File
@@ -268,7 +268,10 @@ fn finish_headers(
active: &[crate::vhost::Listener],
) {
// Global security headers form a baseline; explicitly set vhost values take precedence.
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
if let Some(frontend) = frontend {
frontend.security.apply(response.headers_mut(), tls);
}
+103 -29
View File
@@ -9,19 +9,28 @@ fn cache_http_invalidation_request(
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter();
let mut lengths = request
.headers()
.get_all(http::header::CONTENT_LENGTH)
.iter();
if let Some(length) = lengths.next() {
if lengths.next().is_some() || length.as_bytes() != b"0" {
return Err("BAN/PURGE must not contain a request body");
}
}
if request.headers().contains_key("upgrade")
|| request.headers().contains_key(http::header::TRANSFER_ENCODING)
|| request
.headers()
.contains_key(http::header::TRANSFER_ENCODING)
|| !request.body().is_end_stream()
{
return Err("BAN/PURGE must not contain a request body or upgrade");
}
let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
let target = request
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
if target.len() > 4096 || !target.starts_with('/') {
return Err("invalid BAN/PURGE target");
}
@@ -35,23 +44,40 @@ fn cache_http_invalidation_request(
} else {
crate::cache::InvalidationSelector::Exact
};
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1
if request
.headers()
.get_all("x-rustproxy-ban-ttl")
.iter()
.count()
> 1
|| request
.headers()
.get_all("x-rustproxy-confirm-wide")
.iter()
.count()
> 1
{
return Err("duplicate BAN/PURGE control header");
}
let mut ttl_seconds = None;
if method.as_str() == "BAN" {
if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") {
let seconds = value.to_str().ok().and_then(|v| v.parse::<u64>().ok())
let seconds = value
.to_str()
.ok()
.and_then(|v| v.parse::<u64>().ok())
.ok_or("invalid BAN TTL")?;
if !(1..=604_800).contains(&seconds) { return Err("BAN TTL must be 1..604800"); }
if !(1..=604_800).contains(&seconds) {
return Err("BAN TTL must be 1..604800");
}
ttl_seconds = Some(seconds);
}
} else if request.headers().contains_key("x-rustproxy-ban-ttl") {
return Err("BAN TTL is only supported for BAN");
}
let confirm_wide = request.headers().get("x-rustproxy-confirm-wide")
let confirm_wide = request
.headers()
.get("x-rustproxy-confirm-wide")
.is_some_and(|value| value == "1");
if target == "/" && method.as_str() == "BAN" && !confirm_wide {
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
@@ -71,9 +97,13 @@ fn cache_http_invalidation_request(
host.to_owned()
};
Ok(crate::cache::InvalidationRequest {
operation, selector, value: format!("{scheme}://{authority}{target}"),
host: Some(host.to_owned()), vhost: Some(vhost.to_owned()),
ttl_seconds, confirm_wide,
operation,
selector,
value: format!("{scheme}://{authority}{target}"),
host: Some(host.to_owned()),
vhost: Some(vhost.to_owned()),
ttl_seconds,
confirm_wide,
})
}
@@ -88,51 +118,95 @@ async fn cache_http_invalidation(
scheme: &str,
) -> Response {
let Some(frontend) = frontend else {
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n");
return error(
StatusCode::NOT_FOUND,
"RustProxy: no matching vhost for cache invalidation\n",
);
};
if snapshot.forwarding.trusted(peer_ip)
&& crate::vhost::canonical_ip(peer_ip) == client_ip
if snapshot.forwarding.trusted(peer_ip) && crate::vhost::canonical_ip(peer_ip) == client_ip {
return error(
StatusCode::FORBIDDEN,
"RustProxy: missing trusted proxy client identity\n",
);
}
if !snapshot
.envelope
.config
.cache
.permits_http_invalidation(client_ip)
{
return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity\n");
return error(
StatusCode::FORBIDDEN,
"RustProxy: BAN/PURGE client IP is not allowed\n",
);
}
if !snapshot.envelope.config.cache.permits_http_invalidation(client_ip) {
return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n");
}
let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) {
let requested = match cache_http_invalidation_request(
request.method(),
host,
&frontend.id,
scheme,
&request,
) {
Ok(requested) => requested,
Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
};
let method = request.method().clone();
drop(request);
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n");
return error(
StatusCode::TOO_MANY_REQUESTS,
"RustProxy: cache maintenance busy\n",
);
};
let detail = format!(
"client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}",
method, frontend.id, requested.value
);
if let Err(audit_error) = state.store.audit(&format!("http:{client_ip}"), "cache.http_invalidate", &detail).await {
if let Err(audit_error) = state
.store
.audit(
&format!("http:{client_ip}"),
"cache.http_invalidate",
&detail,
)
.await
{
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n");
return error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: cache maintenance unavailable\n",
);
}
let cache = state.cache.clone();
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
cache.invalidate(requested)
}).await;
})
.await;
let result = match result {
Ok(Ok(result)) => result,
Ok(Err(invalid)) => {
tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected");
return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n");
},
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"),
return error(
StatusCode::UNPROCESSABLE_ENTITY,
"RustProxy: invalid cache maintenance request\n",
);
}
Err(_) => {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: cache maintenance failed\n",
)
}
};
let code = if result.operation == crate::cache::InvalidationOperation::Ban {
StatusCode::ACCEPTED
} else { StatusCode::OK };
} else {
StatusCode::OK
};
let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response();
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response
}
+60 -19
View File
@@ -2,14 +2,25 @@ mod http_invalidation_request_tests {
use super::*;
fn request(method: &str, target: &str) -> Request {
Request::builder().method(method).uri(target).body(Body::empty()).unwrap()
Request::builder()
.method(method)
.uri(target)
.body(Body::empty())
.unwrap()
}
fn parse(method: &str, host: &str, scheme: &str, request: &Request)
-> Result<crate::cache::InvalidationRequest, &'static str>
{
fn parse(
method: &str,
host: &str,
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
cache_http_invalidation_request(
&Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request,
&Method::from_bytes(method.as_bytes()).unwrap(),
host,
"frontend-1",
scheme,
request,
)
}
@@ -34,36 +45,66 @@ mod http_invalidation_request_tests {
fn wide_ban_must_be_confirmed_and_ttl_is_bounded() {
assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err());
let mut wide = request("BAN", "/");
wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide);
wide.headers_mut()
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(
parse("BAN", "a.example", "https", &wide)
.unwrap()
.confirm_wide
);
let mut ttl = request("BAN", "/foo");
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
ttl.headers_mut()
.insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
assert_eq!(parse("BAN", "a.example", "https", &ttl).unwrap().ttl_seconds, Some(30));
ttl.headers_mut().append("x-rustproxy-ban-ttl", "60".parse().unwrap());
ttl.headers_mut()
.insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
assert_eq!(
parse("BAN", "a.example", "https", &ttl)
.unwrap()
.ttl_seconds,
Some(30)
);
ttl.headers_mut()
.append("x-rustproxy-ban-ttl", "60".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
}
#[test]
fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() {
let mut body = request("PURGE", "/foo");
body.headers_mut().insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err());
body.headers_mut().insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_ok());
body.headers_mut().append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
body.headers_mut()
.append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err());
let mut transfer = request("PURGE", "/foo");
transfer.headers_mut().insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
transfer
.headers_mut()
.insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &transfer).is_err());
let mut unexpected = request("PURGE", "/foo");
unexpected.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &unexpected).is_err());
unexpected = request("BAN", "/foo");
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &unexpected).is_err());
assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err());
assert!(parse(
"PURGE",
"a.example",
"https",
&request("PURGE", &format!("/{}", "a".repeat(4097)))
)
.is_err());
}
}
+85 -21
View File
@@ -16,19 +16,33 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
let settings = settings();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("private"));
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number"));
headers.insert(
"cache-control",
HeaderValue::from_static("max-age=not-a-number"),
);
for reason in [
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
"tinyauth", "sticky_cookie", "request_upgrade",
"cache_disabled",
"route_disabled",
"admin_control_plane",
"basic_auth",
"tinyauth",
"sticky_cookie",
"request_upgrade",
] {
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
panic!("disabled cache must not match force-cache rules")
});
assert!(!plan.force_cache());
assert!(!plan.ignore_cookie());
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
let policy =
plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
assert_eq!(policy, RequestPolicy::Bypass(reason));
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy);
assert_eq!(
combine_policy(policy, || panic!(
"disabled cache must not parse post-rule policy"
)),
policy
);
}
}
@@ -38,23 +52,49 @@ fn cache_on_keeps_all_existing_request_policy_outcomes() {
for forced in [false, true] {
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
for (name, value) in [
("x-irrelevant", "yes"), ("cache-control", "no-store"),
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"), ("authorization", "private"),
("cookie", "session=private"), ("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
("if-match", "\"etag\""), ("upgrade", "websocket"),
("x-irrelevant", "yes"),
("cache-control", "no-store"),
("cache-control", "no-cache"),
("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"),
("authorization", "private"),
("cookie", "session=private"),
("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"),
("if-range", "\"etag\""),
("if-match", "\"etag\""),
("upgrade", "websocket"),
] {
let mut headers = HeaderMap::new();
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
for method in [Method::GET, Method::HEAD, Method::POST] {
for body_empty in [false, true] {
let expected = if forced {
crate::cache::forced_request_policy(&method, &headers, body_empty)
} else {
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings)
crate::cache::request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings,
)
};
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected);
assert_eq!(
plan.request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings
),
expected
);
}
}
}
@@ -73,8 +113,12 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
for original in cases {
for rewritten in cases {
let expected = match (original, rewritten) {
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => {
RequestPolicy::Bypass(reason)
}
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => {
RequestPolicy::Revalidate(reason)
}
_ => RequestPolicy::Use,
};
assert_eq!(combine_policy(original, || rewritten), expected);
@@ -85,16 +129,36 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
#[test]
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
for method in [Method::GET, Method::HEAD, Method::POST] {
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] {
for range in [
None,
Some("bytes=0-2"),
Some("bytes=0-2,4-5"),
Some("broken"),
Some("bytes=99-"),
] {
let mut headers = HeaderMap::new();
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); }
if let Some(range) = range {
headers.insert("range", range.parse().unwrap());
}
headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\""));
headers.insert(
"if-none-match",
HeaderValue::from_static("\"client-validator\""),
);
let original = headers.clone();
let baseline = crate::cache::request_range(&method, &headers);
assert_eq!(range_selection(true, &method, &headers), baseline);
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
assert_eq!(
matches!(
range_selection(false, &method, &headers),
RangeSelection::None
),
matches!(baseline, RangeSelection::None)
);
assert_eq!(
headers, original,
"origin must receive the original Range/conditional fields"
);
}
}
}
+15 -7
View File
@@ -35,10 +35,7 @@ impl ErrorFallbackRequest {
transport: request.extensions().get::<Transport>().copied(),
snapshot: snapshot.clone(),
exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
connection_lease: request
.extensions()
.get::<Arc<ConnectionLease>>()
.cloned(),
connection_lease: request.extensions().get::<Arc<ConnectionLease>>().cloned(),
stop: request.extensions().get::<ServerShutdown>().cloned(),
depth: request
.extensions()
@@ -48,7 +45,13 @@ impl ErrorFallbackRequest {
}
}
fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request {
fn rebuild(
&self,
body: Bytes,
peer: PeerAddr,
local: LocalAddr,
dispatch: InternalFallbackRoute,
) -> Request {
let mut request = Request::new(Body::from(body));
*request.method_mut() = self.method.clone();
*request.uri_mut() = self.uri.clone();
@@ -83,7 +86,10 @@ enum ErrorFallbackBody {
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result<ErrorFallbackBody, Response> {
async fn prepare_error_fallback_body(
request: &mut Request,
enabled: bool,
) -> Result<ErrorFallbackBody, Response> {
if !enabled {
return Ok(ErrorFallbackBody::Disabled);
}
@@ -193,6 +199,8 @@ fn intercept_route_origin_error(
} else {
response
};
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
response
.extensions_mut()
.insert(crate::pages::OriginProvidedResponse);
response
}
+23 -8
View File
@@ -735,7 +735,10 @@ mod cache_fast_path_tests {
let fixture = Fixture::new(config).await;
let first = fixture.get("/vary", &[]).await;
assert_eq!(first.headers["x-cache"], "MISS");
assert!(cache_chain(&first.headers).last().unwrap().starts_with("RustProxy; fwd=miss"));
assert!(cache_chain(&first.headers)
.last()
.unwrap()
.starts_with("RustProxy; fwd=miss"));
let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await;
assert_eq!(revalidated.status, StatusCode::OK);
assert_eq!(revalidated.headers["x-cache"], "HIT");
@@ -743,7 +746,9 @@ mod cache_fast_path_tests {
cache_chain(&revalidated.headers).last().copied(),
Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""),
);
let posted = fixture.send(Method::POST, "/echo", &[], Body::from("posted")).await;
let posted = fixture
.send(Method::POST, "/echo", &[], Body::from("posted"))
.await;
assert_eq!(posted.status, StatusCode::OK);
assert_eq!(posted.body, "posted");
assert_eq!(
@@ -783,9 +788,10 @@ mod cache_fast_path_tests {
let stale = fixture.get("/stale-no-validator", &[]).await;
assert_eq!(stale.status, StatusCode::OK);
assert_eq!(stale.headers["x-cache"], "MISS");
assert!(
cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"),
);
assert!(cache_chain(&stale.headers)
.last()
.unwrap()
.starts_with("RustProxy; fwd=stale"),);
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
fixture.stop().await;
}
@@ -942,9 +948,18 @@ mod cache_fast_path_tests {
);
let cache_status = cache_chain(&stale.headers);
let last = cache_status.last().unwrap();
let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap()
.split(';').next().unwrap().parse::<i64>().unwrap();
assert!(freshness <= 0, "expired response must not claim positive freshness");
let freshness = last
.strip_prefix("RustProxy; hit; ttl=")
.unwrap()
.split(';')
.next()
.unwrap()
.parse::<i64>()
.unwrap();
assert!(
freshness <= 0,
"expired response must not claim positive freshness"
);
assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\""));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
+56 -18
View File
@@ -106,7 +106,11 @@ async fn forward_request(
} else {
snapshot.frontend(&host, local.0, tls)
};
if snapshot.global_security.content_type_policy.blocks(request.headers()) {
if snapshot
.global_security
.content_type_policy
.blocks(request.headers())
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: request Content-Type denied by global policy\n",
@@ -118,7 +122,10 @@ async fn forward_request(
"RustProxy: request Content-Type denied by vhost policy\n",
);
}
if snapshot.global_security.user_agent_blocking.blocks(request.headers())
if snapshot
.global_security
.user_agent_blocking
.blocks(request.headers())
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
{
return error(
@@ -170,7 +177,9 @@ async fn forward_request(
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
);
}
if let Some(response) = method_policy_denial(&snapshot.global_security.method_policy, request.method()) {
if let Some(response) =
method_policy_denial(&snapshot.global_security.method_policy, request.method())
{
return response;
}
if !cache_http_invalidation_method(request.method()) {
@@ -239,11 +248,24 @@ async fn forward_request(
}
}
// Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist.
if !snapshot.global_security.country_access.allows(country.as_deref())
|| !snapshot.global_security.rate_limit.country_allowed(country.as_deref())
|| snapshot.global_security.acl.as_ref().is_some_and(|acl| !acl.allows(context.client_ip))
if !snapshot
.global_security
.country_access
.allows(country.as_deref())
|| !snapshot
.global_security
.rate_limit
.country_allowed(country.as_deref())
|| snapshot
.global_security
.acl
.as_ref()
.is_some_and(|acl| !acl.allows(context.client_ip))
{
return error(StatusCode::FORBIDDEN, "RustProxy: denied by global access policy\n");
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by global access policy\n",
);
}
if internal_dispatch.is_none() {
if let Err(limited) = state.rate_limiter.check_with_user_agent(
@@ -251,7 +273,10 @@ async fn forward_request(
"\u{1f}rustproxy-global-security",
context.client_ip,
country.as_deref(),
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()),
request
.headers()
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
&snapshot.global_security.rate_limit,
) {
let mut response = error(
@@ -265,12 +290,13 @@ async fn forward_request(
}
}
if let Some(frontend) = frontend {
if !country_resolved && (capture_country
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip())
if !country_resolved
&& (capture_country
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip())
{
country = cloudflare_country
.clone()
@@ -331,14 +357,20 @@ async fn forward_request(
if let Some(mut response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response;
}
if !tls {
if let Some(mut response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
{
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response;
}
}
@@ -364,7 +396,10 @@ async fn forward_request(
"RustProxy: redirect target could not be rendered\n",
);
};
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(RedirectResponse {
http3_enabled: _redirect.http3_enabled,
});
@@ -372,7 +407,10 @@ async fn forward_request(
}
if let Some(item) = local_host {
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(LocalHostResponse {
http3_enabled: item.http3_enabled,
});
+19 -6
View File
@@ -23,7 +23,9 @@ mod tests {
fn common_connection_tokens_do_not_request_websocket_upgrade() {
for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] {
let mut headers = HeaderMap::new();
if let Some(value) = connection { headers.insert("connection", value.parse().unwrap()); }
if let Some(value) = connection {
headers.insert("connection", value.parse().unwrap());
}
assert!(!requests_upgrade(&headers));
}
let mut headers = HeaderMap::new();
@@ -52,20 +54,29 @@ mod tests {
headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap());
headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap());
strip_admin_set_cookie(&mut headers);
let cookies: Vec<_> = headers.get_all("set-cookie").iter()
.map(|value| value.to_str().unwrap()).collect();
let cookies: Vec<_> = headers
.get_all("set-cookie")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]);
}
#[test]
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
let mut h = HeaderMap::new();
h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap());
h.insert(
"cookie",
"a=1; rustproxy_session=secret; b=2".parse().unwrap(),
);
let admin = strip_admin_cookie(&mut h);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
assert_eq!(admin, vec!["rustproxy_session=secret"]);
restore_admin_cookie(&mut h, &admin);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret");
assert_eq!(
h.get("cookie").unwrap(),
"a=1; b=2; rustproxy_session=secret"
);
}
#[test]
@@ -87,7 +98,9 @@ mod tests {
fn absent_and_close_connection_preserve_non_hop_headers() {
for value in [None, Some("close"), Some("CLOSE")] {
let mut headers = HeaderMap::new();
if let Some(value) = value { headers.insert("connection", value.parse().unwrap()); }
if let Some(value) = value {
headers.insert("connection", value.parse().unwrap());
}
headers.insert("x-request-policy", "allowed".parse().unwrap());
headers.insert("transfer-encoding", "chunked".parse().unwrap());
strip_hop_by_hop(&mut headers);
+30 -10
View File
@@ -13,9 +13,20 @@ struct SelectedRouteContext {
fn take_routing_identity(
request: &mut Request,
tls: bool,
) -> Result<(Option<SelectedRouteContext>, HeaderValue, String, Option<String>, bool), Response> {
) -> Result<
(
Option<SelectedRouteContext>,
HeaderValue,
String,
Option<String>,
bool,
),
Response,
> {
let mut selected_context = request.extensions_mut().remove::<SelectedRouteContext>();
let validated = selected_context.as_mut().and_then(|context| context.validated.take());
let validated = selected_context
.as_mut()
.and_then(|context| context.validated.take());
let host_already_validated = validated.is_some();
let (original_host, host, decoded_path) = match validated {
Some(validated) => (
@@ -24,16 +35,25 @@ fn take_routing_identity(
validated.decoded_path,
),
None => {
let original_host = effective_host(request, tls).map_err(|_| error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or conflicting request authority\n",
))?;
let host = original_host.to_str().ok()
let original_host = effective_host(request, tls).map_err(|_| {
error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or conflicting request authority\n",
)
})?;
let host = original_host
.to_str()
.ok()
.and_then(|v| request_host(v).ok())
.ok_or_else(|| error(StatusCode::BAD_REQUEST,
"RustProxy: invalid hostname\n"))?;
.ok_or_else(|| error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"))?;
(original_host, host, None)
}
};
Ok((selected_context, original_host, host, decoded_path, host_already_validated))
Ok((
selected_context,
original_host,
host,
decoded_path,
host_already_validated,
))
}
+11 -4
View File
@@ -29,7 +29,10 @@ fn canonical_host_redirect(
} else {
("http", local_port)
};
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
let location = if default_port {
format!("{scheme}://{to}{path}")
@@ -37,8 +40,13 @@ fn canonical_host_redirect(
format!("{scheme}://{to}:{port}{path}")
};
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(
http::header::LOCATION,
HeaderValue::from_str(&location).ok()?,
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
@@ -60,4 +68,3 @@ fn force_ssl_redirect(
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
+1 -3
View File
@@ -32,9 +32,7 @@ mod request_id_tests {
#[test]
fn independent_sequential_ids_keep_full_length_and_unique_values() {
let ids: std::collections::HashSet<_> = (0..4096u64)
.map(request_id_hash)
.collect();
let ids: std::collections::HashSet<_> = (0..4096u64).map(request_id_hash).collect();
assert_eq!(ids.len(), 4096);
assert!(ids.iter().all(|id| id.len() == 64));
}
+9 -6
View File
@@ -129,12 +129,15 @@ pub(super) fn finish_web_revalidation(
if *method == Method::HEAD {
*response.body_mut() = Body::empty();
}
let request_forces_validation = crate::cache::request_policy(
method, client, true, "", false, settings,
).revalidation_reason().is_some()
|| cached.request_freshness_revalidation_reason(
&crate::cache::request_freshness_headers(client, settings),
).is_some();
let request_forces_validation =
crate::cache::request_policy(method, client, true, "", false, settings)
.revalidation_reason()
.is_some()
|| cached
.request_freshness_revalidation_reason(&crate::cache::request_freshness_headers(
client, settings,
))
.is_some();
settings.annotate_reason_transformed_forwarded(
response.headers_mut(),
"HIT",
+3 -1
View File
@@ -154,7 +154,9 @@ async fn strict_browser_refresh_uses_request_forward_reason() {
let mut client = HeaderMap::new();
client.insert("cache-control", HeaderValue::from_static("no-cache"));
let response = validate(&service, &cache, &ticket, &settings, Method::GET, client)
.await.unwrap().response;
.await
.unwrap()
.response;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-cache"], "HIT");
assert_eq!(
+54 -21
View File
@@ -49,10 +49,13 @@ mod tests {
assert!(limiter.check(7, "site", first, None, &settings).is_ok());
{
let mut state = lock(&limiter.state);
let bucket = state.buckets.get_mut("site").unwrap().get_mut(&first).unwrap();
bucket.started = Instant::now()
.checked_sub(Duration::from_secs(2))
let bucket = state
.buckets
.get_mut("site")
.unwrap()
.get_mut(&first)
.unwrap();
bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
state.last_sweep = None;
}
assert!(limiter.check(7, "site", second, None, &settings).is_ok());
@@ -82,9 +85,7 @@ mod tests {
.unwrap()
.get_mut(&ip)
.unwrap();
bucket.started = Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap();
bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
}
limiter.maintenance();
let state = lock(&limiter.state);
@@ -116,44 +117,76 @@ mod tests {
requests: 3,
burst: 0,
user_agent_limits: vec![UserAgentLimit {
pattern: "*BadBot*".into(), requests: 1, window_seconds: 60, burst: 0,
pattern: "*BadBot*".into(),
requests: 1,
window_seconds: 60,
burst: 0,
}],
..Default::default()
};
settings.validate().unwrap();
let limiter = Limiter::new();
let ip = "198.51.100.12".parse().unwrap();
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings).is_ok());
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings).is_err());
assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings)
.is_ok());
assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings)
.is_err());
for _ in 0..3 {
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_ok());
assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings)
.is_ok());
}
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_err());
assert!(limiter.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok());
assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings)
.is_err());
assert!(limiter
.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings)
.is_ok());
}
#[test]
fn user_agent_overrides_work_without_base_rate_or_geoip() {
let mut settings = Settings {
enabled: false,
user_agent_limits: vec![UserAgentLimit { pattern: "scanner".into(), requests: 1, window_seconds: 60, burst: 0 }],
user_agent_limits: vec![UserAgentLimit {
pattern: "scanner".into(),
requests: 1,
window_seconds: 60,
burst: 0,
}],
..Default::default()
};
settings.validate().unwrap();
let limiter = Limiter::new();
let ip = "192.0.2.100".parse().unwrap();
assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("ScanNER"),&settings).is_ok());
assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("scanner"),&settings).is_err());
assert!(limiter.check_with_user_agent(1,"site",ip,None,None,&settings).is_ok());
assert!(limiter
.check_with_user_agent(1, "site", ip, None, Some("ScanNER"), &settings)
.is_ok());
assert!(limiter
.check_with_user_agent(1, "site", ip, None, Some("scanner"), &settings)
.is_err());
assert!(limiter
.check_with_user_agent(1, "site", ip, None, None, &settings)
.is_ok());
}
#[test]
fn user_agent_override_validation_is_bounded_and_unique() {
let mut settings = Settings { user_agent_limits: vec![
UserAgentLimit { pattern: "TEST*".into(), ..Default::default() },
UserAgentLimit { pattern: "test*".into(), ..Default::default() },
], ..Default::default() };
let mut settings = Settings {
user_agent_limits: vec![
UserAgentLimit {
pattern: "TEST*".into(),
..Default::default()
},
UserAgentLimit {
pattern: "test*".into(),
..Default::default()
},
],
..Default::default()
};
assert!(settings.validate().is_err());
settings.user_agent_limits[1].pattern = "other".into();
assert!(settings.validate().is_ok());
}
}
+84 -25
View File
@@ -8,15 +8,21 @@ mod cache_revision_tests {
let identity = cache_revision(&base);
let mut file_transport = base.clone();
file_transport.web_services.push(crate::web_server::Service {
id: "static-test".into(), name: "Static Test".into(),
document_root: "/var/www/static-test".into(),
..Default::default()
});
file_transport
.web_services
.push(crate::web_server::Service {
id: "static-test".into(),
name: "Static Test".into(),
document_root: "/var/www/static-test".into(),
..Default::default()
});
let mut zero_copy = file_transport.clone();
zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto;
assert_eq!(cache_revision(&file_transport), cache_revision(&zero_copy));
assert!(matches!(cache_invalidation(&file_transport, &zero_copy), CacheInvalidation::None));
assert!(matches!(
cache_invalidation(&file_transport, &zero_copy),
CacheInvalidation::None
));
let mut logging = base.clone();
logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into();
@@ -88,24 +94,43 @@ mod cache_revision_tests {
protocol_only.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
}
assert_ne!(
(protocol_only.vhosts[0].http2_enabled, protocol_only.vhosts[0].http3_enabled),
(
protocol_only.vhosts[0].http2_enabled,
protocol_only.vhosts[0].http3_enabled
),
(base.vhosts[0].http2_enabled, base.vhosts[0].http3_enabled),
"{label} toggle did not change a transport switch"
);
assert_eq!(cache_revision(&protocol_only), identity, "{label} rotated cache");
assert!(matches!(cache_invalidation(&base, &protocol_only), CacheInvalidation::None));
assert!(matches!(cache_invalidation(&protocol_only, &base), CacheInvalidation::None));
assert_eq!(
cache_revision(&protocol_only),
identity,
"{label} rotated cache"
);
assert!(matches!(
cache_invalidation(&base, &protocol_only),
CacheInvalidation::None
));
assert!(matches!(
cache_invalidation(&protocol_only, &base),
CacheInvalidation::None
));
}
let mut both_protocols = base.clone();
both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled;
both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
assert_eq!(cache_revision(&both_protocols), identity);
assert!(matches!(cache_invalidation(&base, &both_protocols), CacheInvalidation::None));
assert!(matches!(
cache_invalidation(&base, &both_protocols),
CacheInvalidation::None
));
let mut vhost_diagnostics = base.clone();
vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true);
assert_eq!(cache_revision(&vhost_diagnostics), identity);
assert!(matches!(cache_invalidation(&base, &vhost_diagnostics), CacheInvalidation::None));
assert!(matches!(
cache_invalidation(&base, &vhost_diagnostics),
CacheInvalidation::None
));
let mut cache = base.clone();
cache.cache.max_entries += 1;
@@ -118,12 +143,30 @@ mod cache_revision_tests {
let mut operational = base.clone();
operational.vhosts[0].load_balancing.keepalive.enabled = false;
operational.vhosts[0].load_balancing.keepalive.idle_timeout_seconds = 90;
operational.vhosts[0].load_balancing.keepalive.max_idle_per_host = 32;
operational.vhosts[0].load_balancing.keepalive.tcp_keepalive_seconds = 90;
operational.vhosts[0].load_balancing.keepalive.pool_recycle_requests = 1_000;
operational.vhosts[0].load_balancing.keepalive.pool_recycle_seconds = 1_800;
operational.vhosts[0].load_balancing.keepalive.http1_max_buffer_bytes = 65_536;
operational.vhosts[0]
.load_balancing
.keepalive
.idle_timeout_seconds = 90;
operational.vhosts[0]
.load_balancing
.keepalive
.max_idle_per_host = 32;
operational.vhosts[0]
.load_balancing
.keepalive
.tcp_keepalive_seconds = 90;
operational.vhosts[0]
.load_balancing
.keepalive
.pool_recycle_requests = 1_000;
operational.vhosts[0]
.load_balancing
.keepalive
.pool_recycle_seconds = 1_800;
operational.vhosts[0]
.load_balancing
.keepalive
.http1_max_buffer_bytes = 65_536;
operational.vhosts[0].backends[0].weight += 1;
operational.vhosts[0].backends[0].max_concurrent_requests = 16;
operational.vhosts[0].backends[0].queue_timeout_ms = 100;
@@ -132,15 +175,26 @@ mod cache_revision_tests {
operational.vhosts[0].user_agent_blocking = Default::default();
operational.vhosts[0].proxy_timeouts.read_timeout_seconds = Some(180);
assert_eq!(cache_revision(&operational), identity);
assert_eq!(vhost_cache_revision(&operational.vhosts[0]), vhost_cache_revision(&base.vhosts[0]));
assert!(matches!(cache_invalidation(&base, &operational), CacheInvalidation::None));
assert!(matches!(cache_invalidation(&operational, &base), CacheInvalidation::None));
assert_eq!(
vhost_cache_revision(&operational.vhosts[0]),
vhost_cache_revision(&base.vhosts[0])
);
assert!(matches!(
cache_invalidation(&base, &operational),
CacheInvalidation::None
));
assert!(matches!(
cache_invalidation(&operational, &base),
CacheInvalidation::None
));
let mut vhost = base.clone();
vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into();
assert_eq!(cache_revision(&vhost), identity);
let affected_id = base.vhosts[0].id.clone();
assert!(matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id]));
assert!(
matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id])
);
let mut multiple = base.clone();
let mut another = multiple.vhosts[0].clone();
@@ -149,10 +203,15 @@ mod cache_revision_tests {
multiple.vhosts.push(another);
let mut changed_one = multiple.clone();
changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into();
assert!(matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
if ids == vec![base.vhosts[0].id.clone()]));
assert!(
matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
if ids == vec![base.vhosts[0].id.clone()])
);
let mut changed_global = multiple.clone();
changed_global.transform.max_body_bytes += 1;
assert!(matches!(cache_invalidation(&multiple, &changed_global), CacheInvalidation::All(_)));
assert!(matches!(
cache_invalidation(&multiple, &changed_global),
CacheInvalidation::All(_)
));
}
}
+10 -1
View File
@@ -153,7 +153,16 @@ fn log_protocol_error(
tls_sni: Option<String>,
) {
log_protocol_error_with_identity(
state, kind, detail, peer, local, transport, http_version, status, None, None,
state,
kind,
detail,
peer,
local,
transport,
http_version,
status,
None,
None,
tls_sni,
);
}
-1
View File
@@ -205,4 +205,3 @@ async fn connection<I>(
},
}
}
+3 -3
View File
@@ -201,12 +201,12 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
((length >> 16) & 0xff) as u8,
((length >> 8) & 0xff) as u8,
(length & 0xff) as u8,
0x1,
0x5,
0x1,
0x5,
0,
0,
0,
1,
1,
]);
frame.extend_from_slice(&block);
Ok(Bytes::from(frame))
+207 -58
View File
@@ -5,11 +5,14 @@ use std::{
io,
os::fd::{AsRawFd, RawFd},
pin::Pin,
sync::{Arc, Mutex},
sync::atomic::Ordering,
sync::{Arc, Mutex},
task::{Context, Poll},
};
use tokio::{io::{AsyncRead, AsyncWrite, Interest, ReadBuf}, net::TcpStream};
use tokio::{
io::{AsyncRead, AsyncWrite, Interest, ReadBuf},
net::TcpStream,
};
const TAG_LEN: usize = 32;
const FRAME_LEN: usize = 64 * 1024;
@@ -54,24 +57,37 @@ impl SendfileSlots {
return None;
}
let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
if registry.slots.len() >= MAX_PENDING { return None; }
if registry.slots.len() >= MAX_PENDING {
return None;
}
let mut tag = [0u8; TAG_LEN];
tag[0] = 0;
tag[0] = 0;
tag[1..].copy_from_slice(&rand::random::<[u8; TAG_LEN - 1]>());
if registry.slots.iter().any(|slot| slot.tag == tag) { return None; }
if registry.slots.iter().any(|slot| slot.tag == tag) {
return None;
}
let mut data = vec![0u8; FRAME_LEN];
for chunk in data.chunks_exact_mut(TAG_LEN) { chunk.copy_from_slice(&tag); }
for chunk in data.chunks_exact_mut(TAG_LEN) {
chunk.copy_from_slice(&tag);
}
registry.slots.push(Slot {
tag, file: candidate.file, length: candidate.length, written: 0,
tag,
file: candidate.file,
length: candidate.length,
written: 0,
});
Some(SendfileBody {
bytes: Bytes::from(data), remaining: candidate.length, web_metrics,
bytes: Bytes::from(data),
remaining: candidate.length,
web_metrics,
})
}
fn plan(&self, buf: &[u8]) -> WritePlan {
let registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
if registry.slots.is_empty() { return WritePlan::Plain(buf.len()); }
if registry.slots.is_empty() {
return WritePlan::Plain(buf.len());
}
if let Some(tag) = registry.active {
if let Some(slot) = registry.slots.iter().find(|s| s.tag == tag) {
return plan_file(slot, buf);
@@ -79,12 +95,16 @@ impl SendfileSlots {
return WritePlan::Invalid;
}
for start in 0..buf.len() {
if buf[start] != 0 { continue; }
if buf[start] != 0 {
continue;
}
for slot in &registry.slots {
let available = buf.len() - start;
let compare = available.min(TAG_LEN);
if buf[start..start + compare] == slot.tag[..compare] {
if start > 0 { return WritePlan::Plain(start); }
if start > 0 {
return WritePlan::Plain(start);
}
return plan_file(slot, buf);
}
}
@@ -107,36 +127,58 @@ impl SendfileSlots {
enum WritePlan {
Plain(usize),
File { tag: [u8; TAG_LEN], fd: RawFd, file: Arc<File>, offset: u64, count: usize },
File {
tag: [u8; TAG_LEN],
fd: RawFd,
file: Arc<File>,
offset: u64,
count: usize,
},
Invalid,
}
fn plan_file(slot: &Slot, buf: &[u8]) -> WritePlan {
let expected = (slot.length - slot.written).min(buf.len() as u64) as usize;
let matched = buf[..expected].iter().enumerate()
let matched = buf[..expected]
.iter()
.enumerate()
.take_while(|(n, byte)| **byte == slot.tag[(slot.written as usize + n) % TAG_LEN])
.count();
if matched == 0 { return WritePlan::Invalid; }
if matched == 0 {
return WritePlan::Invalid;
}
WritePlan::File {
tag: slot.tag, fd: slot.file.as_raw_fd(), file: slot.file.clone(),
offset: slot.written, count: matched,
tag: slot.tag,
fd: slot.file.as_raw_fd(),
file: slot.file.clone(),
offset: slot.written,
count: matched,
}
}
impl HttpBody for SendfileBody {
type Data = Bytes;
type Error = io::Error;
fn poll_frame(mut self: Pin<&mut Self>, _cx: &mut Context<'_>)
-> Poll<Option<Result<Frame<Bytes>, io::Error>>>
{
if self.remaining == 0 { return Poll::Ready(None); }
fn poll_frame(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
if self.remaining == 0 {
return Poll::Ready(None);
}
let len = self.remaining.min(FRAME_LEN as u64) as usize;
self.remaining -= len as u64;
self.web_metrics.response_bytes.fetch_add(len as u64, Ordering::Relaxed);
self.web_metrics
.response_bytes
.fetch_add(len as u64, Ordering::Relaxed);
Poll::Ready(Some(Ok(Frame::data(self.bytes.slice(0..len)))))
}
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) }
fn is_end_stream(&self) -> bool { self.remaining == 0 }
fn size_hint(&self) -> SizeHint {
SizeHint::with_exact(self.remaining)
}
fn is_end_stream(&self) -> bool {
self.remaining == 0
}
}
pub(crate) struct SendfileIo {
@@ -146,22 +188,46 @@ pub(crate) struct SendfileIo {
impl SendfileIo {
pub fn new(stream: TcpStream) -> (Self, SendfileSlots) {
let slots = SendfileSlots::default();
(Self { stream, slots: slots.clone() }, slots)
(
Self {
stream,
slots: slots.clone(),
},
slots,
)
}
}
impl AsyncRead for SendfileIo {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().stream).poll_read(cx, buf)
}
}
impl AsyncWrite for SendfileIo {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
if buf.is_empty() { return Poll::Ready(Ok(0)); }
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
if buf.is_empty() {
return Poll::Ready(Ok(0));
}
match self.slots.plan(buf) {
WritePlan::Plain(count) => Pin::new(&mut self.stream).poll_write(cx, &buf[..count]),
WritePlan::Invalid => Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData,
"sendfile placeholder did not match its registered descriptor"))),
WritePlan::File { tag, fd, file, offset, count } => {
WritePlan::Invalid => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"sendfile placeholder did not match its registered descriptor",
))),
WritePlan::File {
tag,
fd,
file,
offset,
count,
} => {
let _keep_open = file;
let socket: RawFd = self.stream.as_raw_fd();
let mut at: libc::off_t = offset as libc::off_t;
@@ -171,13 +237,22 @@ impl AsyncWrite for SendfileIo {
Poll::Ready(Ok(())) => {
let result = self.stream.try_io(Interest::WRITABLE, || {
let n = unsafe { libc::sendfile(socket, fd, &mut at, count) };
if n < 0 { Err(io::Error::last_os_error()) }
else if n == 0 { Err(io::Error::new(io::ErrorKind::UnexpectedEof,
"static source truncated during sendfile")) }
else { Ok(n as usize) }
if n < 0 {
Err(io::Error::last_os_error())
} else if n == 0 {
Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"static source truncated during sendfile",
))
} else {
Ok(n as usize)
}
});
match result {
Ok(n) => { self.slots.commit(tag, n); Poll::Ready(Ok(n)) },
Ok(n) => {
self.slots.commit(tag, n);
Poll::Ready(Ok(n))
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending,
Err(e) => Poll::Ready(Err(e)),
}
@@ -192,9 +267,22 @@ impl AsyncWrite for SendfileIo {
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
fn is_write_vectored(&self) -> bool { self.stream.is_write_vectored() }
fn poll_write_vectored(mut self: Pin<&mut Self>, cx: &mut Context<'_>, bufs: &[io::IoSlice<'_>]) -> Poll<io::Result<usize>> {
if self.slots.0.lock().unwrap_or_else(|e| e.into_inner()).slots.is_empty() {
fn is_write_vectored(&self) -> bool {
self.stream.is_write_vectored()
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[io::IoSlice<'_>],
) -> Poll<io::Result<usize>> {
if self
.slots
.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.is_empty()
{
return Pin::new(&mut self.stream).poll_write_vectored(cx, bufs);
}
match bufs.iter().find(|part| !part.is_empty()) {
@@ -208,8 +296,8 @@ impl AsyncWrite for SendfileIo {
mod tests {
use super::*;
use http_body::Body;
use tempfile::tempfile;
use std::io::Write;
use tempfile::tempfile;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
@@ -218,19 +306,42 @@ mod tests {
file.write_all(&vec![42u8; 100_000]).unwrap();
let slots = SendfileSlots::default();
let counter = Arc::new(crate::web_server::metrics::PhpMetrics::default());
let mut body = slots.create_body(SendfileCandidate { file: Arc::new(file), length: 100_000 }, counter.clone()).unwrap();
let first = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) {
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"),
let mut body = slots
.create_body(
SendfileCandidate {
file: Arc::new(file),
length: 100_000,
},
counter.clone(),
)
.unwrap();
let first = match Pin::new(&mut body)
.poll_frame(&mut Context::from_waker(std::task::Waker::noop()))
{
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(),
_ => panic!("frame missing"),
};
assert_eq!(first.len(), FRAME_LEN);
match slots.plan(&first) { WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN), _ => panic!("missing kernel file plan") }
match slots.plan(&first) {
WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN),
_ => panic!("missing kernel file plan"),
}
let tag = slots.0.lock().unwrap().slots[0].tag;
slots.commit(tag, FRAME_LEN);
let second = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) {
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"),
let second = match Pin::new(&mut body)
.poll_frame(&mut Context::from_waker(std::task::Waker::noop()))
{
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(),
_ => panic!("frame missing"),
};
assert_eq!(second.len(), 34_464);
match slots.plan(&second) { WritePlan::File { count, offset, .. } => { assert_eq!(offset, 65_536); assert_eq!(count, 34_464); }, _ => panic!("second plan missing") }
match slots.plan(&second) {
WritePlan::File { count, offset, .. } => {
assert_eq!(offset, 65_536);
assert_eq!(count, 34_464);
}
_ => panic!("second plan missing"),
}
slots.commit(tag, second.len());
assert!(slots.0.lock().unwrap().slots.is_empty());
assert!(body.is_end_stream());
@@ -245,36 +356,74 @@ mod tests {
let (tcp, _) = listener.accept().await.unwrap();
let (mut socket, slots) = SendfileIo::new(tcp);
for round in 0..2 {
let expected = if round == 0 { vec![0, 1, 2, 3].repeat(25_000) }
else { vec![9u8; 100_003] };
let expected = if round == 0 {
vec![0, 1, 2, 3].repeat(25_000)
} else {
vec![9u8; 100_003]
};
let mut file = tempfile().unwrap();
file.write_all(&expected).unwrap();
let web = Arc::new(crate::web_server::metrics::PhpMetrics::default());
let body = slots.create_body(SendfileCandidate {
file: Arc::new(file), length: expected.len() as u64,
}, web.clone()).unwrap();
let first = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len());
let body = slots
.create_body(
SendfileCandidate {
file: Arc::new(file),
length: expected.len() as u64,
},
web.clone(),
)
.unwrap();
let first = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
expected.len()
);
let mut body = body;
let first_frame = http_body_util::BodyExt::frame(&mut body).await.unwrap().unwrap().into_data().unwrap();
let first_frame = http_body_util::BodyExt::frame(&mut body)
.await
.unwrap()
.unwrap()
.into_data()
.unwrap();
let mut joined = first.into_bytes();
joined.extend_from_slice(&first_frame);
socket.write_all(&joined).await.unwrap();
while let Some(frame) = http_body_util::BodyExt::frame(&mut body).await {
socket.write_all(&frame.unwrap().into_data().unwrap()).await.unwrap();
socket
.write_all(&frame.unwrap().into_data().unwrap())
.await
.unwrap();
}
assert_eq!(web.response_bytes.load(Ordering::Relaxed), expected.len() as u64);
assert_eq!(
web.response_bytes.load(Ordering::Relaxed),
expected.len() as u64
);
}
socket.shutdown().await.unwrap();
});
let mut client = tokio::net::TcpStream::connect(address).await.unwrap();
let mut actual = Vec::new();
tokio::time::timeout(std::time::Duration::from_secs(10), client.read_to_end(&mut actual)).await.unwrap().unwrap();
tokio::time::timeout(
std::time::Duration::from_secs(10),
client.read_to_end(&mut actual),
)
.await
.unwrap()
.unwrap();
server.await.unwrap();
let mut wanted = Vec::new();
for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] {
wanted.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len()).as_bytes());
wanted.extend_from_slice(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
expected.len()
)
.as_bytes(),
);
wanted.extend_from_slice(&expected);
}
assert_eq!(actual, wanted, "kernel sendfile must never leak placeholder markers");
assert_eq!(
actual, wanted,
"kernel sendfile must never leak placeholder markers"
);
}
}
+15 -5
View File
@@ -67,9 +67,12 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd
return;
}
let canonical_id = if tls { "https" } else { "http" };
if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| {
endpoint.id == canonical_id && endpoint.binding.tls == tls
}) {
if let Some(endpoint) = config
.listeners
.endpoints
.iter_mut()
.find(|endpoint| endpoint.id == canonical_id && endpoint.binding.tls == tls)
{
endpoint.binding.address = address;
endpoint.additional_addresses.clear();
endpoint.enabled = true;
@@ -166,7 +169,13 @@ pub struct PanelCompression {
pub gzip: bool,
}
impl Default for PanelCompression {
fn default() -> Self { Self { zstd: true, br: true, gzip: true } }
fn default() -> Self {
Self {
zstd: true,
br: true,
gzip: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -248,7 +257,8 @@ impl Administration {
*public_origin = normalize_public_admin_origin(public_origin)?;
}
if let Some(host) = self.public_origin_host().map(str::to_owned) {
self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host));
self.allowed_hosts
.retain(|allowed| !allowed.eq_ignore_ascii_case(&host));
}
Ok(())
}
+23 -8
View File
@@ -3,7 +3,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')),
"administration public_origin must be a single HTTPS origin without userinfo, query or fragment"
);
let uri = public_origin.parse::<http::Uri>()
let uri = public_origin
.parse::<http::Uri>()
.context("invalid administration public_origin")?;
ensure!(
uri.scheme_str() == Some("https")
@@ -11,7 +12,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
&& matches!(uri.path(), "" | "/"),
"administration public_origin must be HTTPS without a path or query"
);
let authority = uri.authority()
let authority = uri
.authority()
.context("administration public_origin requires an authority")?;
let host = crate::config::normalize_host(authority.host())?;
ensure!(
@@ -19,9 +21,13 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
"administration public_origin must name a DNS hostname"
);
let port = if authority.as_str().contains(':') {
let port = authority.port_u16()
let port = authority
.port_u16()
.context("invalid administration public_origin port")?;
ensure!(port > 0, "administration public_origin port must be nonzero");
ensure!(
port > 0,
"administration public_origin port must be nonzero"
);
format!(":{port}")
} else {
String::new()
@@ -31,17 +37,26 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
impl Administration {
pub fn public_origin_host(&self) -> Option<&str> {
self.public_origin.as_deref()
self.public_origin
.as_deref()
.and_then(|url| url.strip_prefix("https://"))
.and_then(|authority| authority.split(':').next())
}
pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool {
self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host))
|| self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host))
self.allowed_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
|| self
.public_origin_host()
.is_some_and(|name| name.eq_ignore_ascii_case(host))
}
pub fn include_public_origin_host(&mut self) {
if let Some(host) = self.public_origin_host().map(str::to_owned) {
if !self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(&host)) {
if !self
.allowed_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&host))
{
self.allowed_hosts.push(host);
}
}
+59 -12
View File
@@ -15,11 +15,29 @@ mod tests {
};
configured.validate().unwrap();
assert_eq!(configured.idle_timeout(), Duration::from_secs(45));
assert_eq!(configured.max_connection_lifetime(), Some(Duration::from_secs(3_600)));
assert_eq!(
configured.max_connection_lifetime(),
Some(Duration::from_secs(3_600))
);
assert_eq!(configured.drain_timeout(), Duration::from_secs(20));
assert!(Http2Connections { idle_timeout_seconds: 0, ..defaults }.validate().is_err());
assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err());
assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err());
assert!(Http2Connections {
idle_timeout_seconds: 0,
..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
max_connection_lifetime_seconds: 2_592_001,
..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
drain_timeout_seconds: 0,
..defaults
}
.validate()
.is_err());
}
#[test]
fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() {
@@ -120,13 +138,29 @@ mod tests {
locks.ensure_config(&mut config);
let http = config.listeners.endpoints.iter().find(|item| item.id == "http").unwrap();
let https = config.listeners.endpoints.iter().find(|item| item.id == "https").unwrap();
let http = config
.listeners
.endpoints
.iter()
.find(|item| item.id == "http")
.unwrap();
let https = config
.listeners
.endpoints
.iter()
.find(|item| item.id == "https")
.unwrap();
assert_eq!(http.binding.address, "0.0.0.0:8181".parse().unwrap());
assert!(http.additional_addresses.is_empty());
assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap());
assert!(https.additional_addresses.is_empty());
assert!(!config.listeners.endpoints.iter().filter(|item| item.enabled).flat_map(|item| item.bindings()).any(|binding| binding.address.port() == 80 || binding.address.port() == 443));
assert!(!config
.listeners
.endpoints
.iter()
.filter(|item| item.enabled)
.flat_map(|item| item.bindings())
.any(|binding| binding.address.port() == 80 || binding.address.port() == 443));
}
#[test]
@@ -264,19 +298,32 @@ mod tests {
..Administration::default()
};
admin.validate(&tls).unwrap();
assert_eq!(admin.public_origin.as_deref(), Some("https://panel.example.test:8443"));
assert_eq!(
admin.public_origin.as_deref(),
Some("https://panel.example.test:8443")
);
assert_eq!(admin.public_origin_host(), Some("panel.example.test"));
assert!(admin.allowed_hosts.is_empty(), "pin must not be stored twice");
assert!(
admin.allowed_hosts.is_empty(),
"pin must not be stored twice"
);
assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST"));
assert!(!admin.explicitly_allowed_panel_host("other.example.test"));
let mut runtime = admin.clone();
runtime.include_public_origin_host();
runtime.include_public_origin_host();
assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]);
assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged");
let active = Administration { public_origin: None, ..admin.clone() };
assert!(
admin.allowed_hosts.is_empty(),
"saved config remains unchanged"
);
let active = Administration {
public_origin: None,
..admin.clone()
};
assert!(admin.restart_differs(&active));
let mut legacy: Administration = serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap();
let mut legacy: Administration =
serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap();
legacy.validate(&tls).unwrap();
assert_eq!(legacy.public_origin, None);
assert!(legacy.explicitly_allowed_panel_host("panel.example.test"));
+3 -1
View File
@@ -132,7 +132,9 @@ pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Resu
#[derive(Debug, PartialEq, Eq)]
pub enum ExternalPairDeployment {
Unchanged { covered_hosts: Vec<String> },
Unchanged {
covered_hosts: Vec<String>,
},
Staged {
cert_file: String,
key_file: String,
+4 -1
View File
@@ -358,7 +358,10 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
.strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone());
if !settings.forced_mismatches.contains(&(cert.id.clone(), host.clone())) {
if !settings
.forced_mismatches
.contains(&(cert.id.clone(), host.clone()))
{
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
+52 -20
View File
@@ -50,11 +50,19 @@ mod tests {
analytics.push_lazy(|| {
made.fetch_add(1, Ordering::Relaxed);
Sample {
vhost: "site".into(), route: None, method: "GET".into(),
path: "/".into(), client_ip: None, status: 200,
duration_ms: 1, request_bytes: 0, response_bytes: 1040,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
vhost: "site".into(),
route: None,
method: "GET".into(),
path: "/".into(),
client_ip: None,
status: 200,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1040,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
}
});
}
@@ -78,15 +86,27 @@ mod tests {
let mut counters = Counters::default();
for status in [100, 200, 302, 404, 503, 701] {
counters.record(Sample {
vhost: "test".into(), route: None, method: "GET".into(),
path: "/".into(), client_ip: None, status,
duration_ms: 1, request_bytes: 0, response_bytes: 1,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
vhost: "test".into(),
route: None,
method: "GET".into(),
path: "/".into(),
client_ip: None,
status,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
});
}
let labels: std::collections::HashSet<_> = counters.view().statuses.into_iter()
.map(|entry| entry.value).collect();
let labels: std::collections::HashSet<_> = counters
.view()
.statuses
.into_iter()
.map(|entry| entry.value)
.collect();
for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] {
assert!(labels.contains(label));
}
@@ -113,12 +133,23 @@ mod tests {
let analytics = Analytics::start(shutdown.clone());
for id in 0..130 {
analytics.push(Sample {
vhost: if id % 2 == 0 { "left".into() } else { "right".into() },
route: None, method: "GET".into(), path: "/small".into(),
client_ip: None, status: 200, duration_ms: 1,
request_bytes: 0, response_bytes: 1040,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
vhost: if id % 2 == 0 {
"left".into()
} else {
"right".into()
},
route: None,
method: "GET".into(),
path: "/small".into(),
client_ip: None,
status: 200,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1040,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
});
}
tokio::time::timeout(std::time::Duration::from_secs(2), async {
@@ -127,7 +158,9 @@ mod tests {
{
tokio::task::yield_now().await;
}
}).await.expect("analytics should drain two batches");
})
.await
.expect("analytics should drain two batches");
let all = analytics.view(None).data;
assert_eq!(all.requests, 130);
assert_eq!(all.response_bytes, 130 * 1040);
@@ -136,5 +169,4 @@ mod tests {
assert_eq!(analytics.view(None).data.requests, 65);
shutdown.cancel();
}
}
+8 -2
View File
@@ -427,7 +427,13 @@ mod tests {
let result = css::minify(input.as_bytes()).expect("minify panel stylesheet");
let output = std::str::from_utf8(&result).expect("UTF-8 CSS");
assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator");
assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list");
assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration");
assert!(
output.contains(".card,.stat,dialog,.login-card,"),
"lost card selector list"
);
assert!(
output.contains("border-radius:var(--rp-radius-surface) !important"),
"lost radius declaration"
);
}
}
+9 -6
View File
@@ -49,7 +49,6 @@ mod tests {
assert_eq!(stats.generation, 1);
}
#[test]
fn generation_recycles_after_age_without_request() {
let backend = backend("rotation-age-test", 18083);
@@ -101,14 +100,18 @@ mod tests {
keepalive.pool_recycle_requests = 1_000;
keepalive.pool_recycle_seconds = 0;
let client = client_with_keepalive(
&backend, AddressPolicy::new(&backend).unwrap(), false, &keepalive,
).unwrap();
for _ in 0..1_000 { client.inner.client_for_request(); }
&backend,
AddressPolicy::new(&backend).unwrap(),
false,
&keepalive,
)
.unwrap();
for _ in 0..1_000 {
client.inner.client_for_request();
}
assert_eq!(client.rotation_stats().rotations, 0);
client.inner.client_for_request();
assert_eq!(client.rotation_stats().rotations, 1);
assert_eq!(client.rotation_stats().generation_requests, 1);
}
}
+45 -19
View File
@@ -232,9 +232,11 @@ async fn tls_authority_origin(
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der());
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions().unwrap()
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![pair.cert.der().clone()], key.into()).unwrap();
.with_single_cert(vec![pair.cert.der().clone()], key.into())
.unwrap();
config.alpn_protocols = if offer_h2 {
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
} else {
@@ -253,7 +255,9 @@ async fn tls_authority_origin(
async move {
if let Some(tx) = observed.lock().unwrap().take() {
let authority = request.uri().authority().map(|v| v.as_str().to_owned());
let host = request.headers().get("host")
let host = request
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_owned());
let _ = tx.send((request.version(), authority, host, sni));
}
@@ -262,10 +266,12 @@ async fn tls_authority_origin(
});
if offer_h2 {
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(tls), service).await;
.serve_connection(TokioIo::new(tls), service)
.await;
} else {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(tls), service).await;
.serve_connection(TokioIo::new(tls), service)
.await;
}
});
(address, observed_rx, server)
@@ -278,16 +284,23 @@ async fn strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_orig
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2)
let request = Request::builder()
.version(Version::HTTP_2)
.uri("https://unitraklub.pl/block_refresh/block/10")
.body(Body::empty()).unwrap();
.body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
.await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2);
assert_eq!(authority.as_deref(), Some("unitraklub.pl"));
assert!(host.is_none(), "Strict H2 must not duplicate :authority with Host");
assert!(
host.is_none(),
"Strict H2 must not duplicate :authority with Host"
);
assert_eq!(sni.as_deref(), Some("localhost"));
server.abort();
}
@@ -299,12 +312,16 @@ async fn auto_h1_fallback_preserves_public_host_without_resolving_it() {
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11)
let request = Request::builder()
.version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl")
.body(Body::empty()).unwrap();
.body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
.await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_11);
let (version, _authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_11);
@@ -320,12 +337,16 @@ async fn auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin() {
backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11)
let request = Request::builder()
.version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl")
.body(Body::empty()).unwrap();
.body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
.await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2);
@@ -352,15 +373,20 @@ async fn cleartext_h2c_preserves_public_authority_and_pins_socket() {
}
});
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), service).await;
.serve_connection(TokioIo::new(stream), service)
.await;
});
let backend = protocol_backend(address, "http", "h2");
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2)
let request = Request::builder()
.version(Version::HTTP_2)
.uri("http://unitraklub.pl/test")
.body(Body::empty()).unwrap();
.body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap();
.await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2);
assert_eq!(observed.await.unwrap().as_deref(), Some("unitraklub.pl"));
server.abort();
+45 -45
View File
@@ -1,53 +1,53 @@
use super::*;
use http::HeaderValue;
use super::*;
use http::HeaderValue;
fn headers(value: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(value) = value {
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
}
headers
fn headers(value: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(value) = value {
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
}
headers
}
#[test]
fn plain_patterns_match_case_insensitive_substrings() {
let policy = Policy {
enabled: true,
patterns: vec!["BadBot".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
#[test]
fn plain_patterns_match_case_insensitive_substrings() {
let policy = Policy {
enabled: true,
patterns: vec!["BadBot".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
}
#[test]
fn wildcard_patterns_can_anchor_or_span() {
let policy = Policy {
enabled: true,
patterns: vec!["curl/*".into(), "*scanner*".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("curl/8.4.0"))));
assert!(policy.blocks(&headers(Some("AcmeScanner/1"))));
assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0"))));
#[test]
fn wildcard_patterns_can_anchor_or_span() {
let policy = Policy {
enabled: true,
patterns: vec!["curl/*".into(), "*scanner*".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("curl/8.4.0"))));
assert!(policy.blocks(&headers(Some("AcmeScanner/1"))));
assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0"))));
}
#[test]
fn disabled_or_missing_user_agent_is_allowed() {
let disabled = Policy {
enabled: false,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!disabled.blocks(&headers(Some("anything"))));
let enabled = Policy {
enabled: true,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!enabled.blocks(&headers(None)));
#[test]
fn disabled_or_missing_user_agent_is_allowed() {
let disabled = Policy {
enabled: false,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!disabled.blocks(&headers(Some("anything"))));
let enabled = Policy {
enabled: true,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!enabled.blocks(&headers(None)));
}
+4 -2
View File
@@ -24,7 +24,10 @@ impl Default for ErrorFallback {
impl ErrorFallback {
pub fn validate(&mut self) -> Result<()> {
self.target = self.target.trim().to_owned();
ensure!(crate::config::valid_id(&self.target), "invalid error fallback target");
ensure!(
crate::config::valid_id(&self.target),
"invalid error fallback target"
);
ensure!(
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
"error fallback requires 1..={} status codes",
@@ -45,4 +48,3 @@ impl ErrorFallback {
self.statuses.contains(&status.as_u16())
}
}
-2
View File
@@ -26,5 +26,3 @@ impl Default for LocationRedirect {
}
}
}
+41 -13
View File
@@ -67,7 +67,10 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost"
);
ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname");
ensure!(
!self.frontend.hosts.is_empty(),
"vhost requires at least one hostname"
);
let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?;
@@ -155,7 +158,10 @@ impl Vhost {
"location cannot define both a local response and a redirect"
);
if let Some(id) = &route.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
ensure!(
crate::config::valid_id(id),
"invalid location ACL profile id"
);
}
route.country_access.validate()?;
route.method_policy.validate()?;
@@ -191,13 +197,21 @@ impl Vhost {
}
}
let mut starts = vec![None];
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
starts.extend(
self.routes
.iter()
.filter(|route| route.enabled)
.map(|route| Some(route.id.as_str())),
);
for start in starts {
let mut seen = std::collections::HashSet::new();
let mut current = start;
loop {
let key = current.unwrap_or("<main>");
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
ensure!(
seen.insert(key.to_owned()),
"recursive error fallback loop detected"
);
let fallback = match current {
None => self.error_fallback.as_ref(),
Some(id) => self
@@ -215,7 +229,9 @@ impl Vhost {
if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone();
settings.health_check = custom.clone();
settings.validate().with_context(|| format!("backend {} health check", member.id))?;
settings
.validate()
.with_context(|| format!("backend {} health check", member.id))?;
}
}
self.proxy_timeouts.validate()?;
@@ -233,7 +249,8 @@ impl Vhost {
"choose either Tinyauth or Basic authentication"
);
self.pages.validate()?;
self.transform.resolve(&crate::transform::Settings::default())?;
self.transform
.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?;
self.user_agent_blocking.compile()?;
self.headers.compile()?;
@@ -306,8 +323,10 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
}
fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") {
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname");
ensure!(
!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname"
);
crate::config::normalize_host(bare)
} else {
crate::config::normalize_host(&format!("www.{host}"))
@@ -315,14 +334,23 @@ fn www_partner(host: &str) -> Result<String> {
}
impl Vhost {
fn www_canonical_host(&self) -> Result<&str> {
let canonical = self.www_redirect_target.as_deref()
let canonical = self
.www_redirect_target
.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname");
ensure!(
!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname"
);
let partner = www_partner(canonical)?;
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost");
ensure!(
self.frontend
.hosts
.iter()
.any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost"
);
Ok(canonical)
}
+2 -2
View File
@@ -1,7 +1,7 @@
use super::htaccess;
use super::htaccess::Config as HtaccessConfig;
#[cfg(unix)]
use super::filesystem::open_root_directory;
use super::htaccess;
use super::htaccess::Config as HtaccessConfig;
use anyhow::{ensure, Context, Result};
use http::{HeaderName, HeaderValue, StatusCode};
use regex::Regex;
+51 -19
View File
@@ -11,9 +11,26 @@ pub(super) async fn php_response(
context: RequestContext,
) -> Response {
let start = Instant::now();
let mut response = php_response_inner(service, php, method, headers, original_uri, script_path, path_info, query, body, context).await;
php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
response.extensions_mut().insert(crate::web_server::metrics::ResponseKind::Php);
let mut response = php_response_inner(
service,
php,
method,
headers,
original_uri,
script_path,
path_info,
query,
body,
context,
)
.await;
php.metrics.record(
response.status(),
start.elapsed().as_micros().min(u64::MAX as u128) as u64,
);
response
.extensions_mut()
.insert(crate::web_server::metrics::ResponseKind::Php);
response
}
@@ -51,15 +68,19 @@ async fn php_response_inner(
return response;
}
};
let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
Ok(Ok(io)) => io,
Ok(Err(error)) => {
php.metrics.failure(2);
tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed");
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
}
Err(_) => { php.metrics.failure(1); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); },
};
let mut io =
match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
Ok(Ok(io)) => io,
Ok(Err(error)) => {
php.metrics.failure(2);
tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed");
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
}
Err(_) => {
php.metrics.failure(1);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
}
};
let params = match fastcgi_params(
service,
php,
@@ -91,8 +112,9 @@ async fn php_response_inner(
let mut body = body;
let mut body_bytes = 0u64;
while let Some(frame) = body.frame().await {
let frame = frame
.map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?;
let frame = frame.map_err(|error| {
FastCgiRequestWriteError::Io(io::Error::other(error.to_string()))
})?;
if let Ok(data) = frame.into_data() {
body_bytes = body_bytes.saturating_add(data.len() as u64);
if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes {
@@ -117,7 +139,10 @@ async fn php_response_inner(
tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed");
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
}
Err(_) => { php.metrics.failure(3); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); },
Err(_) => {
php.metrics.failure(3);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
}
}
let mut stdout = Vec::new();
@@ -128,7 +153,7 @@ async fn php_response_inner(
Ok(value) => value,
Err(error) if error.kind() == io::ErrorKind::TimedOut => {
php.metrics.failure(5);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method)
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
}
Err(error) => {
tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed");
@@ -218,17 +243,24 @@ pub(super) fn method_not_allowed_response() -> Response {
response
}
pub(super) fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> {
pub(super) fn php_for_path<'a>(
service: &'a CompiledService,
path: &Path,
) -> Option<&'a CompiledPhp> {
let php = service.php.as_ref()?;
let extension = path
.extension()
.and_then(|value| value.to_str())?
.to_ascii_lowercase();
(php.extensions.contains(&extension) || htaccess::php_extension(service.htaccess.as_ref(), path)).then_some(php)
(php.extensions.contains(&extension)
|| htaccess::php_extension(service.htaccess.as_ref(), path))
.then_some(php)
}
pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool {
if service.php.is_some() { return false; }
if service.php.is_some() {
return false;
}
path.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
+30 -10
View File
@@ -1,11 +1,11 @@
use super::{CompiledService, SymlinkPolicy};
#[cfg(unix)]
use std::{ffi::CString, sync::Arc};
use std::{
fs::File,
io,
path::{Path, PathBuf},
};
#[cfg(unix)]
use std::{ffi::CString, sync::Arc};
pub(super) struct FilesystemRoot {
root: PathBuf,
@@ -63,12 +63,18 @@ pub(super) fn open_root_directory(path: &Path) -> io::Result<File> {
pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> {
if !path.starts_with('/') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute"));
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path is not absolute",
));
}
let mut result = Vec::new();
for part in path.split('/').filter(|part| !part.is_empty()) {
if part == "." || part == ".." || part.contains('\\') || part.contains('\0') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path"));
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"ambiguous path",
));
}
result.push(part.to_owned());
}
@@ -93,7 +99,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) ->
}
let current = unsafe { File::from_raw_fd(fd) };
let metadata = current.metadata()?;
return Ok(Opened { file: current, metadata });
return Ok(Opened {
file: current,
metadata,
});
}
let mut current: Option<File> = None;
for (index, component) in components.iter().enumerate() {
@@ -135,7 +144,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) ->
"Web Server serves only regular files and directories",
));
}
Ok(Opened { file: current, metadata })
Ok(Opened {
file: current,
metadata,
})
}
#[cfg(target_os = "linux")]
@@ -192,7 +204,10 @@ pub(super) fn list_directory_sync(
let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory",
));
}
let raw = opened.file.try_clone()?.into_raw_fd();
let dir = unsafe { libc::fdopendir(raw) };
@@ -241,7 +256,10 @@ pub(super) fn list_directory_sync(
) -> io::Result<Vec<DirectoryEntry>> {
let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory",
));
}
let mut path = root.root.clone();
for component in components {
@@ -249,7 +267,10 @@ pub(super) fn list_directory_sync(
}
let canonical = std::fs::canonicalize(path)?;
if !canonical.starts_with(&root.root) {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root"));
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"path escaped document root",
));
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(canonical)? {
@@ -275,4 +296,3 @@ pub(super) fn list_directory_sync(
});
Ok(entries)
}
+127 -31
View File
@@ -1,7 +1,5 @@
use super::{
config::CompiledService,
filesystem::path_components,
request::RequestContext,
config::CompiledService, filesystem::path_components, request::RequestContext,
routing::open_components,
};
use anyhow::{bail, ensure, Context, Result};
@@ -119,9 +117,19 @@ struct RewriteCondition {
#[derive(Debug, Clone)]
enum ConditionMatcher {
Regex { regex: Regex, negate: bool },
Exact { value: String, negate: bool, insensitive: bool },
File { kind: FileTest, negate: bool },
Regex {
regex: Regex,
negate: bool,
},
Exact {
value: String,
negate: bool,
insensitive: bool,
},
File {
kind: FileTest,
negate: bool,
},
}
#[derive(Debug, Clone, Copy)]
@@ -198,7 +206,13 @@ fn tokens(value: &str) -> Result<Vec<String>> {
fn flags(value: Option<&str>) -> Vec<String> {
value
.and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']')))
.map(|value| value.split(',').map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()).collect())
.map(|value| {
value
.split(',')
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
.collect()
})
.unwrap_or_default()
}
@@ -227,16 +241,26 @@ fn supported_response_header(name: &HeaderName) -> bool {
fn parse_duration(value: &str) -> Result<Duration> {
let value = value.trim().trim_matches('"').trim();
if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a'))
if let Some(seconds) = value
.strip_prefix('A')
.or_else(|| value.strip_prefix('a'))
.filter(|seconds| !seconds.is_empty() && seconds.bytes().all(|byte| byte.is_ascii_digit()))
{
let seconds: u64 = seconds.parse().context("invalid Expires duration")?;
return Ok(Duration::from_secs(seconds));
}
let parts = value.split_whitespace().collect::<Vec<_>>();
ensure!(parts.len() >= 4, "Expires value must look like 'access plus 1 day' or A3600");
ensure!(parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), "only access-based Expires values are supported");
let count: u64 = parts[2].parse().context("invalid Expires duration number")?;
ensure!(
parts.len() >= 4,
"Expires value must look like 'access plus 1 day' or A3600"
);
ensure!(
parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"),
"only access-based Expires values are supported"
);
let count: u64 = parts[2]
.parse()
.context("invalid Expires duration number")?;
let unit = parts[3].to_ascii_lowercase();
let seconds = match unit.trim_end_matches('s') {
"second" => count,
@@ -252,32 +276,73 @@ fn parse_duration(value: &str) -> Result<Duration> {
}
fn parse_condition(parts: &[String]) -> Result<RewriteCondition> {
ensure!(parts.len() >= 3, "RewriteCond needs a test string and pattern");
ensure!(
parts.len() >= 3,
"RewriteCond needs a test string and pattern"
);
let condition_flags = flags(parts.get(3).map(String::as_str));
let insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC"));
let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR"));
let insensitive = condition_flags
.iter()
.any(|flag| flag.eq_ignore_ascii_case("NC"));
let or_next = condition_flags
.iter()
.any(|flag| flag.eq_ignore_ascii_case("OR"));
for flag in &condition_flags {
ensure!(flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), "unsupported RewriteCond flag [{flag}]");
ensure!(
flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"),
"unsupported RewriteCond flag [{flag}]"
);
}
let raw = parts[2].as_str();
let matcher = if let Some(value) = raw.strip_prefix("!=") {
ConditionMatcher::Exact { value: value.to_owned(), negate: true, insensitive }
ConditionMatcher::Exact {
value: value.to_owned(),
negate: true,
insensitive,
}
} else if let Some(value) = raw.strip_prefix('=') {
ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive }
ConditionMatcher::Exact {
value: value.to_owned(),
negate: false,
insensitive,
}
} else {
let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v));
match raw {
"-f" => ConditionMatcher::File { kind: FileTest::File, negate },
"-d" => ConditionMatcher::File { kind: FileTest::Directory, negate },
"-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate },
_ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate },
"-f" => ConditionMatcher::File {
kind: FileTest::File,
negate,
},
"-d" => ConditionMatcher::File {
kind: FileTest::Directory,
negate,
},
"-s" => ConditionMatcher::File {
kind: FileTest::NonEmptyFile,
negate,
},
_ => ConditionMatcher::Regex {
regex: ci_regex(raw, insensitive)?,
negate,
},
}
};
Ok(RewriteCondition { input: parts[1].clone(), matcher, or_next })
Ok(RewriteCondition {
input: parts[1].clone(),
matcher,
or_next,
})
}
fn parse_rewrite_rule(parts: &[String], conditions: Vec<RewriteCondition>, state: &mut ParseState) -> Result<RewriteRule> {
ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution");
fn parse_rewrite_rule(
parts: &[String],
conditions: Vec<RewriteCondition>,
state: &mut ParseState,
) -> Result<RewriteRule> {
ensure!(
parts.len() >= 3,
"RewriteRule needs a pattern and substitution"
);
let rule_flags = flags(parts.get(3).map(String::as_str));
let mut redirect = None;
let mut forbidden = false;
@@ -319,15 +384,30 @@ fn parse_rewrite_rule(parts: &[String], conditions: Vec<RewriteCondition>, state
fn normalize_extension(value: &str) -> Result<String> {
let value = value.trim().trim_start_matches('.').to_ascii_lowercase();
ensure!(!value.is_empty() && value.len() <= 32 && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "invalid extension in .htaccess");
ensure!(
!value.is_empty()
&& value.len() <= 32
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
"invalid extension in .htaccess"
);
Ok(value)
}
fn unsupported(state: &mut ParseState, config: &Config, file: &Path, line: usize, message: String) -> Result<()> {
fn unsupported(
state: &mut ParseState,
config: &Config,
file: &Path,
line: usize,
message: String,
) -> Result<()> {
if config.strict {
bail!("{}:{line}: {message}", file.display());
}
state.unsupported.push(format!("{}:{line}: {message}", file.display()));
state
.unsupported
.push(format!("{}:{line}: {message}", file.display()));
Ok(())
}
@@ -344,17 +424,33 @@ fn read_htaccess_nofollow(path: &Path) -> Result<String> {
#[cfg(not(unix))]
let mut file = {
let metadata = fs::symlink_metadata(path)?;
ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", path.display());
ensure!(
!metadata.file_type().is_symlink(),
"{} must not be a symlink",
path.display()
);
fs::File::open(path)?
};
let metadata = file.metadata()?;
ensure!(metadata.is_file(), "{} is not a regular file", path.display());
ensure!(metadata.len() <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display());
ensure!(
metadata.is_file(),
"{} is not a regular file",
path.display()
);
ensure!(
metadata.len() <= MAX_HTACCESS_FILE_BYTES,
"{} exceeds the 128 KiB .htaccess limit",
path.display()
);
let mut content = String::new();
(&mut file)
.take(MAX_HTACCESS_FILE_BYTES + 1)
.read_to_string(&mut content)
.with_context(|| format!("{} is not valid UTF-8", path.display()))?;
ensure!(content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display());
ensure!(
content.len() as u64 <= MAX_HTACCESS_FILE_BYTES,
"{} exceeds the 128 KiB .htaccess limit",
path.display()
);
Ok(content)
}
+92 -20
View File
@@ -1,5 +1,19 @@
pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledService, method: &Method, headers: &HeaderMap, uri: &Uri, context: &RequestContext, initial_path: &str, initial_query: Option<&str>) -> Result<RewriteOutcome> {
let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); };
pub async fn apply_rewrites(
compiled: Option<&Compiled>,
service: &CompiledService,
method: &Method,
headers: &HeaderMap,
uri: &Uri,
context: &RequestContext,
initial_path: &str,
initial_query: Option<&str>,
) -> Result<RewriteOutcome> {
let Some(compiled) = compiled else {
return Ok(RewriteOutcome::Internal {
path: initial_path.to_owned(),
query: initial_query.map(str::to_owned),
});
};
let mut path = initial_path.to_owned();
let mut query = initial_query.map(str::to_owned);
let mut exhausted = false;
@@ -10,37 +24,95 @@ pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledServi
'scope: for scope in scopes {
for rule in &scope.rewrites {
let relative = scope_relative_path(scope, &path);
if !rule.pattern.is_match(&relative) { continue; }
let (conditions_ok, condition_captures) = conditions_match(rule, service, method, headers, &path, query.as_deref(), uri, context).await;
if !conditions_ok { continue; }
let Some(captures) = rule.pattern.captures(&relative) else { continue; };
if rule.forbidden { return Ok(RewriteOutcome::Forbidden); }
if !rule.pattern.is_match(&relative) {
continue;
}
let (conditions_ok, condition_captures) = conditions_match(
rule,
service,
method,
headers,
&path,
query.as_deref(),
uri,
context,
)
.await;
if !conditions_ok {
continue;
}
let Some(captures) = rule.pattern.captures(&relative) else {
continue;
};
if rule.forbidden {
return Ok(RewriteOutcome::Forbidden);
}
let replacement = replacement_with_captures(rule, &captures, &condition_captures);
if replacement != "-" {
let (replacement_path, replacement_query) = if let Some((target, query_part)) = replacement.split_once('?') { (target, Some(query_part.to_owned())) } else { (replacement.as_str(), None) };
let target = if rule.redirect.is_some() && (replacement_path.starts_with("http://") || replacement_path.starts_with("https://")) { replacement_path.to_owned() } else { scoped_target(scope, replacement_path) };
let (replacement_path, replacement_query) =
if let Some((target, query_part)) = replacement.split_once('?') {
(target, Some(query_part.to_owned()))
} else {
(replacement.as_str(), None)
};
let target = if rule.redirect.is_some()
&& (replacement_path.starts_with("http://")
|| replacement_path.starts_with("https://"))
{
replacement_path.to_owned()
} else {
scoped_target(scope, replacement_path)
};
let next_query = if rule.qsa {
match (replacement_query, query.clone()) {
(Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => Some(format!("{new}&{old}")),
(Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => {
Some(format!("{new}&{old}"))
}
(Some(new), _) => Some(new),
(None, old) => old,
}
} else { replacement_query.or_else(|| query.clone()) };
} else {
replacement_query.or_else(|| query.clone())
};
if let Some(status) = rule.redirect {
let mut location = target;
if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); }
return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?));
if let Some(query) = next_query.filter(|q| !q.is_empty()) {
location.push('?');
location.push_str(&query);
}
return Ok(RewriteOutcome::Redirect(redirect_response(
status, &location,
)?));
}
ensure!(
target.starts_with('/'),
".htaccess internal rewrite must stay on an absolute local path"
);
if target != path || next_query != query {
changed = true;
path = target;
query = next_query;
}
ensure!(target.starts_with('/'), ".htaccess internal rewrite must stay on an absolute local path");
if target != path || next_query != query { changed = true; path = target; query = next_query; }
}
if rule.end { end = true; break 'scope; }
if rule.last { break 'scope; }
if rule.end {
end = true;
break 'scope;
}
if rule.last {
break 'scope;
}
}
}
if end || !changed { break; }
if pass == 7 { exhausted = true; }
if end || !changed {
break;
}
if pass == 7 {
exhausted = true;
}
}
ensure!(!exhausted, ".htaccess rewrite exceeded the 8-pass safety limit");
ensure!(
!exhausted,
".htaccess rewrite exceeded the 8-pass safety limit"
);
Ok(RewriteOutcome::Internal { path, query })
}
+4 -4
View File
@@ -28,15 +28,15 @@ pub(crate) struct PhpMetrics {
pub(crate) status_4xx: AtomicU64,
pub(crate) status_5xx: AtomicU64,
pub(crate) response_headers_us: AtomicU64,
pub(crate) response_bytes: AtomicU64,
pub(crate) response_bytes: AtomicU64,
pub(crate) in_flight_headers: AtomicU64,
pub(crate) static_requests: AtomicU64,
pub(crate) php_requests: AtomicU64,
pub(crate) other_requests: AtomicU64,
pub(crate) methods: [AtomicU64; 5],
pub(crate) selected_status: [AtomicU64; 8],
pub(crate) methods: [AtomicU64; 5],
pub(crate) selected_status: [AtomicU64; 8],
pub(crate) failures: [AtomicU64; 9],
pub(crate) compression: [AtomicU64; 4],
pub(crate) compression: [AtomicU64; 4],
}
pub(crate) struct HeadersInFlight<'a>(&'a AtomicU64);
-1
View File
@@ -10,7 +10,6 @@ pub struct RequestContext {
pub version: Version,
}
pub(super) fn server_protocol(version: http::Version) -> &'static str {
match version {
http::Version::HTTP_09 => "HTTP/0.9",
+16 -6
View File
@@ -161,13 +161,22 @@ async fn file_response_inner(
#[cfg(target_os = "linux")]
let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto
&& !multipart && single_range.is_none() && content_encoding.is_none()
&& !multipart
&& single_range.is_none()
&& content_encoding.is_none()
&& body_length >= 64 * 1024
{
opened.file.try_clone().ok().map(|file| crate::server::sendfile::SendfileCandidate {
file: std::sync::Arc::new(file), length: body_length,
})
} else { None };
opened
.file
.try_clone()
.ok()
.map(|file| crate::server::sendfile::SendfileCandidate {
file: std::sync::Arc::new(file),
length: body_length,
})
} else {
None
};
let mut file = tokio::fs::File::from_std(opened.file);
if let Some(boundary) = boundary {
let ranges = ranges.expect("multipart ranges");
@@ -203,7 +212,8 @@ async fn file_response_inner(
.await;
});
*response.body_mut() = Body::new(file_body::FileBody::new(
ReaderStream::with_capacity(reader, stream_capacity), response_length,
ReaderStream::with_capacity(reader, stream_capacity),
response_length,
));
return response;
}
+22 -7
View File
@@ -1,7 +1,11 @@
use bytes::Bytes;
use futures_util::Stream;
use http_body::{Body, Frame, SizeHint};
use std::{io, pin::Pin, task::{Context, Poll}};
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
pub(super) struct FileBody<S> {
stream: Option<S>,
@@ -10,7 +14,10 @@ pub(super) struct FileBody<S> {
impl<S> FileBody<S> {
pub(super) fn new(stream: S, length: u64) -> Self {
Self { stream: (length != 0).then_some(stream), remaining: length }
Self {
stream: (length != 0).then_some(stream),
remaining: length,
}
}
}
@@ -26,14 +33,17 @@ where
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
let this = self.get_mut();
let Some(stream) = this.stream.as_mut() else { return Poll::Ready(None) };
let Some(stream) = this.stream.as_mut() else {
return Poll::Ready(None);
};
match Pin::new(stream).poll_next(cx) {
Poll::Ready(Some(Ok(data))) => {
if data.len() as u64 > this.remaining {
this.stream = None;
this.remaining = 0;
return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::InvalidData, "local file body exceeded its declared length",
io::ErrorKind::InvalidData,
"local file body exceeded its declared length",
))));
}
this.remaining -= data.len() as u64;
@@ -46,7 +56,8 @@ where
this.stream = None;
this.remaining = 0;
Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, "local file body ended before its declared length",
io::ErrorKind::UnexpectedEof,
"local file body ended before its declared length",
))))
}
Poll::Ready(Some(Err(error))) => {
@@ -58,9 +69,13 @@ where
}
}
fn is_end_stream(&self) -> bool { self.stream.is_none() }
fn is_end_stream(&self) -> bool {
self.stream.is_none()
}
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) }
fn size_hint(&self) -> SizeHint {
SizeHint::with_exact(self.remaining)
}
}
#[cfg(test)]
+112 -27
View File
@@ -17,30 +17,54 @@ async fn final_file_frame_is_terminal_without_an_extra_eof_poll() {
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(6), 3), 6);
assert_eq!(body.size_hint().exact(), Some(6));
assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(body.size_hint().exact(), Some(3));
assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "def");
assert!(body.is_end_stream(), "the final data frame must end the body before another poll");
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"def"
);
assert!(
body.is_end_stream(),
"the final data frame must end the body before another poll"
);
assert_eq!(body.size_hint().exact(), Some(0));
assert!(body.stream.is_none(), "the reader/file is released at the length boundary");
assert!(
body.stream.is_none(),
"the reader/file is released at the length boundary"
);
assert!(body.frame().await.is_none());
}
#[tokio::test]
async fn short_file_and_local_stream_errors_never_become_successful_eof() {
let mut short = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abc"))]), 6);
assert_eq!(short.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(short.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
assert_eq!(
short.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(
short.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
assert!(short.is_end_stream());
assert!(short.frame().await.is_none());
let mut failed = FileBody::new(stream::iter([Err(io::Error::other("read failed"))]), 6);
assert_eq!(failed.frame().await.unwrap().unwrap_err().to_string(), "read failed");
assert_eq!(
failed.frame().await.unwrap().unwrap_err().to_string(),
"read failed"
);
assert!(failed.is_end_stream());
let mut oversized = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abcdef"))]), 3);
assert_eq!(oversized.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::InvalidData);
assert_eq!(
oversized.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::InvalidData
);
assert!(oversized.is_end_stream());
}
@@ -51,10 +75,24 @@ async fn file_shrunk_after_open_returns_unexpected_eof() {
tokio::fs::write(&path, b"abcdef").await.unwrap();
let file = tokio::fs::File::open(&path).await.unwrap();
let expected = file.metadata().await.unwrap().len();
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_len(3).unwrap();
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(expected), 3), expected);
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(body.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap()
.set_len(3)
.unwrap();
let mut body = FileBody::new(
ReaderStream::with_capacity(file.take(expected), 3),
expected,
);
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(
body.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
assert!(body.is_end_stream());
assert!(body.stream.is_none());
}
@@ -67,12 +105,22 @@ async fn zero_length_file_is_terminal_without_polling_the_reader() {
assert!(body.frame().await.is_none());
}
fn settings() -> Settings { Settings { enabled: true, ..Default::default() } }
fn settings() -> Settings {
Settings {
enabled: true,
..Default::default()
}
}
fn cacheable(body: AxumBody, length: u64) -> Response {
let mut response = Response::new(body);
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=600"));
response.headers_mut().insert("content-length", length.to_string().parse().unwrap());
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=600"),
);
response
.headers_mut()
.insert("content-length", length.to_string().parse().unwrap());
response
}
@@ -85,17 +133,33 @@ async fn fully_delivered_file_fills_cache_before_consumer_drops_without_polling_
let body = FileBody::new(ReaderStream::with_capacity(file.take(6), 6), 6);
let cache = Cache::new(1);
let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new())
.unwrap();
let response = cacheable(AxumBody::new(body), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body();
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abcdef");
let plan = cache::plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let mut body = cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abcdef"
);
drop(body);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().rejected_fills, 0);
assert_eq!(cache.view().fills_active, 0);
let hit = cache.get(&ticket).unwrap();
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "abcdef");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
"abcdef"
);
}
#[tokio::test]
@@ -103,14 +167,35 @@ async fn incomplete_or_cancelled_file_cannot_fill_cache() {
for short in [false, true] {
let cache = Cache::new(1);
let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap();
let chunks = if short { vec![Ok(Bytes::from_static(b"abc"))] }
else { vec![Ok(Bytes::from_static(b"abc")), Ok(Bytes::from_static(b"def"))] };
let ticket = cache
.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new())
.unwrap();
let chunks = if short {
vec![Ok(Bytes::from_static(b"abc"))]
} else {
vec![
Ok(Bytes::from_static(b"abc")),
Ok(Bytes::from_static(b"def")),
]
};
let response = cacheable(AxumBody::new(FileBody::new(stream::iter(chunks), 6)), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body();
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
if short { assert!(body.frame().await.unwrap().is_err()); }
let plan = cache::plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let mut body = cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
if short {
assert!(body.frame().await.unwrap().is_err());
}
drop(body);
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().rejected_fills, 1);
+12 -9
View File
@@ -8,7 +8,10 @@ use axum::{body::Body, response::Response};
use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode};
use std::io;
pub(super) async fn open_components(service: &CompiledService, components: Vec<String>) -> io::Result<Opened> {
pub(super) async fn open_components(
service: &CompiledService,
components: Vec<String>,
) -> io::Result<Opened> {
let root = FilesystemRoot::for_service(service);
tokio::task::spawn_blocking(move || secure_open_sync(&root, &components))
.await
@@ -28,7 +31,10 @@ fn target_path_and_query(
Some((path, query)) => (path.to_owned(), Some(query.to_owned())),
None => (expanded, query.map(str::to_owned)),
};
ensure!(path.starts_with('/'), "Web Server internal target must start with /");
ensure!(
path.starts_with('/'),
"Web Server internal target must start with /"
);
crate::acl::validate_decoded_routing_path(&path)
.map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?;
Ok((path, query))
@@ -54,11 +60,7 @@ pub(super) fn apply_rewrites(
.pattern
.replace(&path, rule.replacement.as_str())
.to_string();
let (next_path, next_query) = target_path_and_query(
&replacement,
&path,
query.as_deref(),
)?;
let (next_path, next_query) = target_path_and_query(&replacement, &path, query.as_deref())?;
if let Some(status) = rule.status {
let mut target = next_path;
if let Some(query) = next_query.filter(|value| !value.is_empty()) {
@@ -142,7 +144,9 @@ pub(super) async fn select_precompressed(
continue;
}
let mut sidecar = components.to_vec();
let Some(last) = sidecar.last_mut() else { continue };
let Some(last) = sidecar.last_mut() else {
continue;
};
last.push_str(suffix);
if let Ok(opened) = open_components(service, sidecar).await {
if opened.metadata.is_file() {
@@ -152,4 +156,3 @@ pub(super) async fn select_precompressed(
}
None
}
+8 -5
View File
@@ -35,8 +35,8 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
"id_ed25519",
];
let backup_suffixes = [
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql",
".sqlite", ".sqlite3", ".db",
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".sqlite", ".sqlite3",
".db",
];
path_components(path).ok().is_some_and(|components| {
components.iter().any(|component| {
@@ -46,14 +46,18 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
|| component.starts_with(".env.")
|| sensitive_names.contains(&component.as_str())
|| component.ends_with('~')
|| backup_suffixes.iter().any(|suffix| component.ends_with(*suffix))
|| backup_suffixes
.iter()
.any(|suffix| component.ends_with(*suffix))
})
})
}
pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool {
service.hardening.block_common_attacks
&& (method == Method::CONNECT || method == Method::TRACE || method.as_str().eq_ignore_ascii_case("TRACK"))
&& (method == Method::CONNECT
|| method == Method::TRACE
|| method.as_str().eq_ignore_ascii_case("TRACK"))
}
pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) {
@@ -73,4 +77,3 @@ pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &Compi
response.headers_mut().remove(name);
}
}
+32 -9
View File
@@ -122,7 +122,13 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
assert_eq!(missing.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!(missing.headers()["x-content-type-options"], "nosniff");
assert_eq!(
missing.into_body().collect().await.unwrap().to_bytes().as_ref(),
missing
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.as_ref(),
crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND)
.unwrap()
.as_bytes()
@@ -210,7 +216,9 @@ async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses(
.unwrap()
.as_bytes()
);
assert!(!body.windows(b"RustProxy:".len()).any(|w| w == b"RustProxy:"));
assert!(!body
.windows(b"RustProxy:".len())
.any(|w| w == b"RustProxy:"));
}
}
@@ -222,7 +230,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!(
invalid.into_body().collect().await.unwrap().to_bytes().as_ref(),
invalid
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.as_ref(),
crate::pages::builtin_gateway_error(StatusCode::BAD_REQUEST)
.unwrap()
.as_bytes()
@@ -255,7 +269,10 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
assert_eq!(fallback.status(), StatusCode::IM_A_TEAPOT);
assert_eq!(fallback.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!(fallback.headers()[CACHE_CONTROL], "no-store");
assert!(fallback.extensions().get::<crate::pages::PageServed>().is_none());
assert!(fallback
.extensions()
.get::<crate::pages::PageServed>()
.is_none());
let fallback_html = fallback.into_body().collect().await.unwrap().to_bytes();
let fallback_html = std::str::from_utf8(&fallback_html).unwrap();
assert!(fallback_html.contains("<h1>418 -"));
@@ -272,7 +289,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
head.headers()[CONTENT_LENGTH],
HeaderValue::from_str(&fallback_html.len().to_string()).unwrap()
);
assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty());
assert!(head
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[tokio::test]
@@ -1831,10 +1854,10 @@ async fn web_metrics_count_handler_status_method_and_consumed_body_bytes() {
assert_eq!(view["status_4xx"], 1);
assert_eq!(view["static_requests"], 2);
assert_eq!(view["other_requests"], 1);
assert_eq!(view["methods"][0], 2);
assert_eq!(view["methods"][4], 1);
assert_eq!(view["selected_status"][0], 2);
assert_eq!(view["selected_status"][4], 1);
assert_eq!(view["methods"][0], 2);
assert_eq!(view["methods"][4], 1);
assert_eq!(view["selected_status"][0], 2);
assert_eq!(view["selected_status"][4], 1);
assert_eq!(view["response_bytes"], 5 + missing_bytes as u64);
assert_eq!(view["active_headers"], 0);
}