v0.35.0
This commit is contained in:
1 parent
2c03fcf248
commit
d3b492874b
95 files changed
+1628
-743
No files matched your search
+13
-7
@@ -264,13 +264,19 @@ pub fn routing_path(raw: &str) -> Result<String> {
|
||||
let high = digit(bytes[index + 1]).context("invalid path escape")?;
|
||||
let low = digit(bytes[index + 2]).context("invalid path escape")?;
|
||||
let decoded = (high << 4) | low;
|
||||
ensure!(
|
||||
decoded >= 32
|
||||
&& decoded != 127
|
||||
&& !matches!(decoded, b'/' | b'\\' | b'%' | b'?' | b'#' | b';'),
|
||||
"ambiguous encoded path character"
|
||||
);
|
||||
out.push(decoded);
|
||||
if decoded == b'/' {
|
||||
if out.last().copied() != Some(b'/') {
|
||||
out.push(b'/');
|
||||
}
|
||||
} else {
|
||||
ensure!(
|
||||
decoded >= 32
|
||||
&& decoded != 127
|
||||
&& !matches!(decoded, b'\\' | b'%' | b'?' | b'#' | b';'),
|
||||
"ambiguous encoded path character"
|
||||
);
|
||||
out.push(decoded);
|
||||
}
|
||||
index += 3;
|
||||
} else {
|
||||
ensure!(
|
||||
|
||||
+74
-27
@@ -37,15 +37,34 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool {
|
||||
|
||||
pub(crate) fn hostname(value: &str) -> Result<String> {
|
||||
let candidate = value.strip_suffix('.').unwrap_or(value);
|
||||
ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname");
|
||||
ensure!(candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'), "invalid ACL DNS name");
|
||||
ensure!(
|
||||
candidate.len() <= 253 && candidate.contains('.'),
|
||||
"ACL DNS name requires a fully qualified hostname"
|
||||
);
|
||||
ensure!(
|
||||
candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'),
|
||||
"invalid ACL DNS name"
|
||||
);
|
||||
let labels: Vec<_> = candidate.split('.').collect();
|
||||
ensure!(labels.last().is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())), "invalid ACL DNS name suffix");
|
||||
ensure!(
|
||||
labels
|
||||
.last()
|
||||
.is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())),
|
||||
"invalid ACL DNS name suffix"
|
||||
);
|
||||
for label in labels {
|
||||
ensure!(!label.is_empty() && label.len() <= 63, "invalid ACL DNS label length");
|
||||
ensure!(label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
&& label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'), "invalid ACL DNS label");
|
||||
ensure!(
|
||||
!label.is_empty() && label.len() <= 63,
|
||||
"invalid ACL DNS label length"
|
||||
);
|
||||
ensure!(
|
||||
label.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'),
|
||||
"invalid ACL DNS label"
|
||||
);
|
||||
}
|
||||
Ok(candidate.to_ascii_lowercase())
|
||||
}
|
||||
@@ -55,27 +74,42 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
|
||||
let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> {
|
||||
if let Some(acl) = acl {
|
||||
for rule in &acl.rules {
|
||||
if rule.network.parse::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().is_ok() {
|
||||
if rule.network.parse::<ipnet::IpNet>().is_ok()
|
||||
|| rule.network.parse::<IpAddr>().is_ok()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = hostname(&rule.network)?;
|
||||
ensure!(rule.action == crate::acl::Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny");
|
||||
ensure!(
|
||||
rule.action == crate::acl::Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny"
|
||||
);
|
||||
names.insert(name);
|
||||
ensure!(names.len() <= MAX_NAMES, "too many distinct dynamic DNS ACL names (max 128)");
|
||||
ensure!(
|
||||
names.len() <= MAX_NAMES,
|
||||
"too many distinct dynamic DNS ACL names (max 128)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
inspect(config.global_security.acl.as_ref())?;
|
||||
for policy in &config.host_policies { inspect(Some(&policy.acl))?; }
|
||||
for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; }
|
||||
for policy in &config.host_policies {
|
||||
inspect(Some(&policy.acl))?;
|
||||
}
|
||||
for profile in &config.acl_profiles {
|
||||
inspect(profile.acl.as_ref())?;
|
||||
}
|
||||
for vhost in &config.vhosts {
|
||||
inspect(vhost.acl.as_ref())?;
|
||||
for route in &vhost.routes { inspect(route.acl.as_ref())?; }
|
||||
for route in &vhost.routes {
|
||||
inspect(route.acl.as_ref())?;
|
||||
}
|
||||
}
|
||||
for agent in &config.docker_agents.agents {
|
||||
for assignment in &agent.assignments { inspect(assignment.acl.as_ref())?; }
|
||||
for assignment in &agent.assignments {
|
||||
inspect(assignment.acl.as_ref())?;
|
||||
}
|
||||
}
|
||||
Ok(names.into_iter().collect())
|
||||
}
|
||||
@@ -85,7 +119,8 @@ async fn resolve(name: String) -> (String, Option<Addresses>) {
|
||||
let answer = tokio::time::timeout(
|
||||
Duration::from_secs(LOOKUP_SECONDS),
|
||||
tokio::net::lookup_host((lookup_name.as_str(), 0u16)),
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
let mut ips = match answer {
|
||||
Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::<Vec<_>>(),
|
||||
_ => {
|
||||
@@ -95,21 +130,29 @@ async fn resolve(name: String) -> (String, Option<Addresses>) {
|
||||
};
|
||||
ips.sort_unstable();
|
||||
ips.dedup();
|
||||
if ips.is_empty() || ips.len() > MAX_ADDRESSES || ips.iter().any(|ip| {
|
||||
matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some())
|
||||
}) {
|
||||
if ips.is_empty()
|
||||
|| ips.len() > MAX_ADDRESSES
|
||||
|| ips
|
||||
.iter()
|
||||
.any(|ip| matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()))
|
||||
{
|
||||
tracing::warn!(hostname = %name, "dynamic ACL DNS returned an invalid or excessive address set");
|
||||
return (name, None);
|
||||
}
|
||||
(name, Some(Addresses { ips, valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS) }))
|
||||
(
|
||||
name,
|
||||
Some(Addresses {
|
||||
ips,
|
||||
valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn config_changed() { CHANGED.notify_one(); }
|
||||
pub(crate) fn config_changed() {
|
||||
CHANGED.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn poll_loop(
|
||||
app: Weak<crate::runtime::App>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
pub(crate) async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationToken) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(REFRESH_SECONDS));
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
loop {
|
||||
@@ -118,9 +161,13 @@ pub(crate) async fn poll_loop(
|
||||
_ = interval.tick() => {},
|
||||
_ = CHANGED.notified() => {},
|
||||
}
|
||||
let Some(state) = app.upgrade() else { break; };
|
||||
let Some(state) = app.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let snapshot = state.config.load();
|
||||
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
|
||||
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else {
|
||||
continue;
|
||||
};
|
||||
drop(snapshot);
|
||||
let results: HashMap<_, _> = tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
|
||||
+21
-3
@@ -207,13 +207,29 @@ mod tests {
|
||||
routing_path("/static/js//events.js").unwrap(),
|
||||
"/static/js/events.js"
|
||||
);
|
||||
for (path, expected) in [
|
||||
("/x%2Fy", "/x/y"),
|
||||
("/x%2fy", "/x/y"),
|
||||
("/x%2F%2Fy", "/x/y"),
|
||||
("/x//y", "/x/y"),
|
||||
("/%2Ffoo", "/foo"),
|
||||
] {
|
||||
assert_eq!(routing_path(path).unwrap(), expected, "{path}");
|
||||
}
|
||||
for path in [
|
||||
"/%2e%2e/secret",
|
||||
"/a/%2E%2E/b",
|
||||
"/a%2fb",
|
||||
"/x%2F..%2Fy",
|
||||
"/x%2F%2E%2E%2Fy",
|
||||
"/a%5cb",
|
||||
"/%5Cadmin",
|
||||
"/%25admin",
|
||||
"/%3Fadmin",
|
||||
"/%23admin",
|
||||
"/%3Badmin",
|
||||
"/bad%",
|
||||
"/bad%2",
|
||||
"/%00",
|
||||
] {
|
||||
assert!(routing_path(path).is_err(), "{path}");
|
||||
}
|
||||
@@ -239,10 +255,12 @@ mod tests {
|
||||
"/static/js/events.js"
|
||||
);
|
||||
assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users");
|
||||
assert_eq!(routing_path("/%2fadmin").unwrap(), "/admin");
|
||||
for path in [
|
||||
"/api/../admin",
|
||||
"/api/%2e%2e/admin",
|
||||
"/%2fadmin",
|
||||
"/api%2F..%2Fadmin",
|
||||
"/api%2F%2E%2E%2Fadmin",
|
||||
"/%5cadmin",
|
||||
"/%2561dmin",
|
||||
"/%00",
|
||||
@@ -349,7 +367,7 @@ mod tests {
|
||||
for peer in ["10.1.2.3", "203.0.113.5"] {
|
||||
let peer = peer.parse().unwrap();
|
||||
assert_eq!(
|
||||
allows_profiles_with_country(&[single.clone()], peer, country),
|
||||
allows_profiles_with_country(std::slice::from_ref(&single), peer, country),
|
||||
allows_with_country(single.acl.as_ref(), &single.countries, peer, country),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ async fn certificate_deploy(
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
return Ok(Json(json!({
|
||||
Ok(Json(json!({
|
||||
"deployed": true,
|
||||
"operation": "metadata-updated",
|
||||
"created": false,
|
||||
@@ -207,7 +207,7 @@ async fn certificate_deploy(
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
|
||||
"administration_restart_required": false
|
||||
})));
|
||||
})))
|
||||
}
|
||||
crate::tls::ExternalPairDeployment::Staged {
|
||||
cert_file,
|
||||
|
||||
@@ -62,10 +62,12 @@ async fn dashboard_presets(
|
||||
tracing::warn!("skipped invalid saved Overview preset");
|
||||
continue;
|
||||
};
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = layout.dashboard_widgets;
|
||||
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
|
||||
prefs.dashboard_widget_sizes = layout.dashboard_widget_sizes;
|
||||
let mut prefs = AdminPreferences {
|
||||
dashboard_widgets: layout.dashboard_widgets,
|
||||
dashboard_hidden_widgets: layout.dashboard_hidden_widgets,
|
||||
dashboard_widget_sizes: layout.dashboard_widget_sizes,
|
||||
..Default::default()
|
||||
};
|
||||
reconcile_dashboard_widgets(&mut prefs);
|
||||
layout.dashboard_widgets = prefs.dashboard_widgets;
|
||||
layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets;
|
||||
|
||||
@@ -182,7 +182,7 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|
||||
|| query
|
||||
.aggregate_min_count
|
||||
.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|
||||
.is_some_and(|value| !(2..=FILE_LOG_SCAN_LINES).contains(&value))
|
||||
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|
||||
|| query
|
||||
.limit
|
||||
@@ -233,7 +233,7 @@ async fn file_logs_query(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
sources.sort_by_key(|source| std::cmp::Reverse(source.modified_ms));
|
||||
if sources.len() > FILE_LOG_MAX_SOURCES {
|
||||
truncated = true;
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.16",
|
||||
"version": "0.35.0",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -86,18 +86,20 @@ fn corner_style_defaults_and_rejects_unknown_presets() {
|
||||
|
||||
#[test]
|
||||
fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"requests".into(),
|
||||
"errors".into(),
|
||||
];
|
||||
prefs.dashboard_hidden_widgets = vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"errors".into(),
|
||||
];
|
||||
let mut prefs = AdminPreferences {
|
||||
dashboard_widgets: vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"requests".into(),
|
||||
"errors".into(),
|
||||
],
|
||||
dashboard_hidden_widgets: vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"errors".into(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
reconcile_dashboard_widgets(&mut prefs);
|
||||
assert_eq!(prefs.dashboard_widgets[0], "errors");
|
||||
assert_eq!(prefs.dashboard_widgets[1], "requests");
|
||||
@@ -210,9 +212,11 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
|
||||
|
||||
#[test]
|
||||
fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() {
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = default_admin_dashboard_widgets();
|
||||
prefs.dashboard_hidden_widgets.clear();
|
||||
let mut prefs = AdminPreferences {
|
||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||
dashboard_hidden_widgets: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
reconcile_dashboard_widgets(&mut prefs);
|
||||
assert!(prefs.dashboard_hidden_widgets.is_empty());
|
||||
assert!(prefs.dashboard_widgets.contains(&"cache_outcomes".into()));
|
||||
@@ -224,8 +228,10 @@ fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() {
|
||||
fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() {
|
||||
let old: AdminPreferences = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap();
|
||||
assert!(old.dashboard_widget_sizes.is_none());
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widget_sizes = Some(std::collections::BTreeMap::new());
|
||||
let mut prefs = AdminPreferences {
|
||||
dashboard_widget_sizes: Some(std::collections::BTreeMap::new()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_admin_preferences(&prefs).is_ok());
|
||||
for (id, span) in [
|
||||
("http_protocol", 4),
|
||||
|
||||
+18
-4
@@ -461,6 +461,7 @@ mod tests {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let old = request("/api/status")
|
||||
.header("authorization", format!("Bearer {}", "a".repeat(64)))
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1645,6 +1646,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -1663,7 +1665,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(forbidden_scope.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
json_body(forbidden_scope).await,
|
||||
json!({"error":"certificate_api.invalid_route"})
|
||||
);
|
||||
|
||||
let revoked = router
|
||||
.clone()
|
||||
@@ -1804,7 +1810,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
json_body(denied).await,
|
||||
json!({"error":"certificate_api.invalid_route"})
|
||||
);
|
||||
let no_csrf = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -2774,6 +2784,7 @@ mod tests {
|
||||
request(&path)
|
||||
.header("cookie", cookie)
|
||||
.header("authorization", format!("Bearer {secret}"))
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -3953,7 +3964,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(old_route.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
@@ -4103,7 +4114,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.status(), StatusCode::OK);
|
||||
assert_eq!(summary.headers()["cache-control"], "no-store");
|
||||
assert_eq!(
|
||||
summary.headers()["cache-control"],
|
||||
"private, no-store, no-cache, no-transform"
|
||||
);
|
||||
let summary = json_body(summary).await;
|
||||
assert!(summary.get("prometheus").is_none());
|
||||
assert_eq!(
|
||||
|
||||
@@ -58,7 +58,10 @@ pub struct Choice {
|
||||
}
|
||||
impl Choice {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
ensure!(self.provider.supports(self.method), "unsupported authentication provider and integration method combination");
|
||||
ensure!(
|
||||
self.provider.supports(self.method),
|
||||
"unsupported authentication provider and integration method combination"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -69,7 +72,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn supported_methods_are_explicit_and_reject_invalid_pairs() {
|
||||
for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, IdentityProvider::Authentik] {
|
||||
for provider in [
|
||||
IdentityProvider::Tinyauth,
|
||||
IdentityProvider::Authelia,
|
||||
IdentityProvider::Authentik,
|
||||
] {
|
||||
assert!(provider.supports(IntegrationMethod::ForwardAuth));
|
||||
assert!(provider.supports(IntegrationMethod::Oidc));
|
||||
}
|
||||
@@ -84,11 +91,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selection_serializes_as_independent_provider_and_method() {
|
||||
let selection = Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::Oidc };
|
||||
let selection = Choice {
|
||||
provider: IdentityProvider::Authelia,
|
||||
method: IntegrationMethod::Oidc,
|
||||
};
|
||||
let json = serde_json::to_value(selection).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"provider":"authelia","method":"oidc"}));
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({"provider":"authelia","method":"oidc"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Choice>(json).unwrap(), selection);
|
||||
assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"pocket_id","method":"forward_auth"})).unwrap().validate().is_err());
|
||||
assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"unknown","method":"oidc"})).is_err());
|
||||
assert!(serde_json::from_value::<Choice>(
|
||||
serde_json::json!({"provider":"pocket_id","method":"forward_auth"})
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(serde_json::from_value::<Choice>(
|
||||
serde_json::json!({"provider":"unknown","method":"oidc"})
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
+96
-43
@@ -88,6 +88,7 @@ impl Compiled {
|
||||
) -> Option<Route<'a>> {
|
||||
self.0.serve_reserved(request, context, shutdown)
|
||||
}
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
@@ -130,7 +131,9 @@ impl Selection<'_> {
|
||||
if let Some(choice) = self.choice {
|
||||
choice.validate()?;
|
||||
let matches_configuration = match choice.method {
|
||||
IntegrationMethod::Basic => self.basic_enabled && choice.provider == IdentityProvider::Basic,
|
||||
IntegrationMethod::Basic => {
|
||||
self.basic_enabled && choice.provider == IdentityProvider::Basic
|
||||
}
|
||||
IntegrationMethod::ForwardAuth => match choice.provider {
|
||||
IdentityProvider::Tinyauth => self.tinyauth.enabled,
|
||||
IdentityProvider::Authelia => self.authelia.enabled,
|
||||
@@ -139,7 +142,10 @@ impl Selection<'_> {
|
||||
},
|
||||
IntegrationMethod::Oidc => self.oidc.enabled,
|
||||
};
|
||||
ensure!(matches_configuration, "authentication selection does not match the enabled settings");
|
||||
ensure!(
|
||||
matches_configuration,
|
||||
"authentication selection does not match the enabled settings"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -159,9 +165,12 @@ impl Selection<'_> {
|
||||
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
|
||||
}
|
||||
if self.oidc.enabled {
|
||||
let (id, label) = self.choice.map_or(("oidc", "OIDC"),
|
||||
|choice| (choice.provider.id(), choice.provider.display_name()));
|
||||
return Ok(self.oidc.compile(policy, bindings)?
|
||||
let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| {
|
||||
(choice.provider.id(), choice.provider.display_name())
|
||||
});
|
||||
return Ok(self
|
||||
.oidc
|
||||
.compile(policy, bindings)?
|
||||
.map(|provider| Compiled::new(provider.with_provider(id, label))));
|
||||
}
|
||||
Ok(None)
|
||||
@@ -245,74 +254,118 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn oidc_is_exclusive_and_missing_legacy_values_remain_disabled() {
|
||||
let mut tinyauth = tinyauth::Settings::default();
|
||||
tinyauth.enabled = true;
|
||||
tinyauth.url = "http://127.0.0.1:3000".into();
|
||||
tinyauth.login_url = "https://auth.example.net".into();
|
||||
let tinyauth = tinyauth::Settings {
|
||||
enabled: true,
|
||||
url: "http://127.0.0.1:3000".into(),
|
||||
login_url: "https://auth.example.net".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let oidc = oidc::Settings {
|
||||
enabled: true, issuer_url: "https://auth.gruszczynski.cc".into(),
|
||||
enabled: true,
|
||||
issuer_url: "https://auth.example.com".into(),
|
||||
backend_url: "http://127.0.0.1:9091".into(),
|
||||
client_id: "test-client".into(), client_secret: "test-secret".into(),
|
||||
redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
|
||||
client_id: "test-client".into(),
|
||||
client_secret: "test-secret".into(),
|
||||
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
|
||||
session_secret: "long-session-secret-at-least-32-characters".into(),
|
||||
..oidc::Settings::default()
|
||||
};
|
||||
assert!(Selection {
|
||||
choice: None,
|
||||
basic_enabled: false, tinyauth: &tinyauth,
|
||||
basic_enabled: false,
|
||||
tinyauth: &tinyauth,
|
||||
authelia: &authelia::Settings::default(),
|
||||
authentik: &authentik::Settings::default(), oidc: &oidc,
|
||||
}.validate().is_err());
|
||||
authentik: &authentik::Settings::default(),
|
||||
oidc: &oidc,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn oidc_identity_provider_is_independent_of_integration_method() {
|
||||
let mut oidc = oidc::Settings::default();
|
||||
oidc.enabled = true;
|
||||
oidc.issuer_url = "https://auth.gruszczynski.cc".into();
|
||||
oidc.client_id = "rustproxy-test".into();
|
||||
oidc.client_secret = "test-client-secret".into();
|
||||
oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into();
|
||||
oidc.redirect_url = "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into();
|
||||
for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia,
|
||||
IdentityProvider::Authentik, IdentityProvider::PocketId,
|
||||
IdentityProvider::Custom] {
|
||||
let oidc = oidc::Settings {
|
||||
enabled: true,
|
||||
issuer_url: "https://auth.example.com".into(),
|
||||
client_id: "rustproxy-test".into(),
|
||||
client_secret: "test-client-secret".into(),
|
||||
session_secret: "a-long-random-value-for-testing-oidc-sessions".into(),
|
||||
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
|
||||
..Default::default()
|
||||
};
|
||||
for provider in [
|
||||
IdentityProvider::Tinyauth,
|
||||
IdentityProvider::Authelia,
|
||||
IdentityProvider::Authentik,
|
||||
IdentityProvider::PocketId,
|
||||
IdentityProvider::Custom,
|
||||
] {
|
||||
assert!(Selection {
|
||||
choice: Some(Choice { provider, method: IntegrationMethod::Oidc }),
|
||||
choice: Some(Choice {
|
||||
provider,
|
||||
method: IntegrationMethod::Oidc
|
||||
}),
|
||||
basic_enabled: false,
|
||||
tinyauth: &tinyauth::Settings::default(),
|
||||
authelia: &authelia::Settings::default(),
|
||||
authentik: &authentik::Settings::default(),
|
||||
oidc: &oidc,
|
||||
}.validate().is_ok());
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
assert!(Selection {
|
||||
choice: Some(Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::ForwardAuth }),
|
||||
basic_enabled: false, tinyauth: &tinyauth::Settings::default(),
|
||||
authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(),
|
||||
choice: Some(Choice {
|
||||
provider: IdentityProvider::Authelia,
|
||||
method: IntegrationMethod::ForwardAuth
|
||||
}),
|
||||
basic_enabled: false,
|
||||
tinyauth: &tinyauth::Settings::default(),
|
||||
authelia: &authelia::Settings::default(),
|
||||
authentik: &authentik::Settings::default(),
|
||||
oidc: &oidc,
|
||||
}.validate().is_err());
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_selection_cannot_silently_disable_or_switch_authentication() {
|
||||
let mut tinyauth = tinyauth::Settings::default();
|
||||
tinyauth.enabled = true;
|
||||
tinyauth.url = "http://10.0.0.2:3000".into();
|
||||
tinyauth.login_url = "https://auth.example.com".into();
|
||||
let tinyauth = tinyauth::Settings {
|
||||
enabled: true,
|
||||
url: "http://10.0.0.2:3000".into(),
|
||||
login_url: "https://auth.example.com".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let authelia = authelia::Settings::default();
|
||||
let authentik = authentik::Settings::default();
|
||||
let oidc = oidc::Settings::default();
|
||||
let selection = |choice| Selection {
|
||||
choice, basic_enabled: false, tinyauth: &tinyauth,
|
||||
authelia: &authelia, authentik: &authentik, oidc: &oidc,
|
||||
choice,
|
||||
basic_enabled: false,
|
||||
tinyauth: &tinyauth,
|
||||
authelia: &authelia,
|
||||
authentik: &authentik,
|
||||
oidc: &oidc,
|
||||
};
|
||||
assert!(selection(None).validate().is_ok());
|
||||
assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
|
||||
method: IntegrationMethod::ForwardAuth })).validate().is_ok());
|
||||
assert!(selection(Some(Choice { provider: IdentityProvider::Authelia,
|
||||
method: IntegrationMethod::ForwardAuth })).validate().is_err());
|
||||
assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
|
||||
method: IntegrationMethod::Oidc })).validate().is_err());
|
||||
assert!(selection(Some(Choice {
|
||||
provider: IdentityProvider::Tinyauth,
|
||||
method: IntegrationMethod::ForwardAuth
|
||||
}))
|
||||
.validate()
|
||||
.is_ok());
|
||||
assert!(selection(Some(Choice {
|
||||
provider: IdentityProvider::Authelia,
|
||||
method: IntegrationMethod::ForwardAuth
|
||||
}))
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(selection(Some(Choice {
|
||||
provider: IdentityProvider::Tinyauth,
|
||||
method: IntegrationMethod::Oidc
|
||||
}))
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -4,12 +4,13 @@ mod tests {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn settings(url: &str) -> Settings {
|
||||
let mut settings = Settings::default();
|
||||
settings.enabled = true;
|
||||
settings.url = url.into();
|
||||
settings.login_url = "https://auth.example.com".into();
|
||||
settings.allow_private = true;
|
||||
settings
|
||||
Settings(forward_auth::Settings {
|
||||
enabled: true,
|
||||
url: url.into(),
|
||||
login_url: "https://auth.example.com".into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() }
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ mod tests {
|
||||
};
|
||||
|
||||
fn settings(url: &str) -> Settings {
|
||||
let mut settings = Settings::default();
|
||||
settings.enabled = true;
|
||||
settings.url = url.into();
|
||||
settings.allow_private = true;
|
||||
settings
|
||||
Settings(forward_auth::Settings {
|
||||
enabled: true,
|
||||
url: url.into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn compile(url: &str) -> Compiled {
|
||||
settings(url).compile(&policy(), &[]).unwrap().unwrap()
|
||||
|
||||
@@ -56,18 +56,14 @@ struct Manifest {
|
||||
entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum EntryKind {
|
||||
#[default]
|
||||
File,
|
||||
Directory,
|
||||
Symlink,
|
||||
}
|
||||
impl Default for EntryKind {
|
||||
fn default() -> Self {
|
||||
Self::File
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ManifestEntry {
|
||||
|
||||
@@ -41,13 +41,13 @@ fn snapshot_metadata_unchanged(before: &std::fs::Metadata, after: &std::fs::Meta
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
return before.dev() == after.dev()
|
||||
before.dev() == after.dev()
|
||||
&& before.ino() == after.ino()
|
||||
&& before.size() == after.size()
|
||||
&& before.mtime() == after.mtime()
|
||||
&& before.mtime_nsec() == after.mtime_nsec()
|
||||
&& before.ctime() == after.ctime()
|
||||
&& before.ctime_nsec() == after.ctime_nsec();
|
||||
&& before.ctime_nsec() == after.ctime_nsec()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -92,7 +92,7 @@ fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
|
||||
!bytes.is_empty() && bytes.len() <= 4096,
|
||||
"invalid symlink target"
|
||||
);
|
||||
return Ok(bytes);
|
||||
Ok(bytes)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -117,7 +117,7 @@ fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
return Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())));
|
||||
Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -157,7 +157,7 @@ fn create_symlink(target: &Path, link: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(target, link)?;
|
||||
return Ok(());
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
|
||||
@@ -116,14 +116,15 @@ impl BackendState {
|
||||
return true;
|
||||
}
|
||||
let mut h = lock(&self.health);
|
||||
if !h.passive_healthy && h.active_healthy && !h.checked {
|
||||
if h.passive_ejected_at
|
||||
if !h.passive_healthy
|
||||
&& h.active_healthy
|
||||
&& !h.checked
|
||||
&& h.passive_ejected_at
|
||||
.is_some_and(|at| at.elapsed() >= h.passive_cooldown)
|
||||
{
|
||||
let previous = h.healthy;
|
||||
Self::clear_passive_ejection(&mut h);
|
||||
self.finish_transition(&mut h, previous);
|
||||
}
|
||||
{
|
||||
let previous = h.healthy;
|
||||
Self::clear_passive_ejection(&mut h);
|
||||
self.finish_transition(&mut h, previous);
|
||||
}
|
||||
h.healthy
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Settings {
|
||||
&& self
|
||||
.realm
|
||||
.bytes()
|
||||
.all(|c| c >= 32 && c < 127 && c != b'"' && c != b'\\'),
|
||||
.all(|c| (32..127).contains(&c) && c != b'"' && c != b'\\'),
|
||||
"basic.invalid_realm"
|
||||
);
|
||||
ensure!(self.users.len() <= 64, "basic.too_many_users");
|
||||
@@ -189,6 +189,7 @@ fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing<String>, Header
|
||||
))
|
||||
}
|
||||
impl Compiled {
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
|
||||
Vendored
+22
-14
@@ -735,9 +735,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_preset_intent_round_trips_with_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.memory_preset = Some(MemoryPreset::Recommended);
|
||||
settings.cold_preset = Some(ColdPreset::Recommended);
|
||||
let settings = Settings {
|
||||
memory_preset: Some(MemoryPreset::Recommended),
|
||||
cold_preset: Some(ColdPreset::Recommended),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(encoded["memory_preset"], "recommended");
|
||||
assert_eq!(encoded["cold_preset"], "recommended");
|
||||
@@ -749,9 +751,11 @@ mod tests {
|
||||
#[test]
|
||||
fn transient_budget_is_hard_and_released_with_lease() {
|
||||
let cache = Cache::new(1);
|
||||
let mut s = Settings::default();
|
||||
s.transient_max_bytes = 128 * 1024;
|
||||
s.cold_queue_max_bytes = 64 * 1024;
|
||||
let s = Settings {
|
||||
transient_max_bytes: 128 * 1024,
|
||||
cold_queue_max_bytes: 64 * 1024,
|
||||
..Default::default()
|
||||
};
|
||||
let first = cache
|
||||
.try_reserve_transient(96 * 1024, &s)
|
||||
.expect("first reservation");
|
||||
@@ -764,20 +768,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cold_queue_budget_cannot_exceed_transient_budget() {
|
||||
let mut s = Settings::default();
|
||||
s.transient_max_bytes = 8 * 1024 * 1024;
|
||||
s.cold_queue_max_bytes = 16 * 1024 * 1024;
|
||||
let s = Settings {
|
||||
transient_max_bytes: 8 * 1024 * 1024,
|
||||
cold_queue_max_bytes: 16 * 1024 * 1024,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
#[test]
|
||||
fn cache_limits_allow_explicit_multi_gibibyte_budgets() {
|
||||
let mut s = Settings::default();
|
||||
s.max_bytes = 4usize * 1024 * 1024 * 1024;
|
||||
s.max_object_bytes = 2usize * 1024 * 1024 * 1024;
|
||||
s.transient_max_bytes = 512usize * 1024 * 1024;
|
||||
s.cold_queue_max_bytes = 256usize * 1024 * 1024;
|
||||
let mut s = Settings {
|
||||
max_bytes: 4usize * 1024 * 1024 * 1024,
|
||||
max_object_bytes: 2usize * 1024 * 1024 * 1024,
|
||||
transient_max_bytes: 512usize * 1024 * 1024,
|
||||
cold_queue_max_bytes: 256usize * 1024 * 1024,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.validate().is_ok());
|
||||
s.max_object_bytes = s.max_bytes.saturating_add(1);
|
||||
assert!(s.validate().is_err());
|
||||
|
||||
Vendored
+4
-2
@@ -979,8 +979,10 @@ mod cold_tier_tests {
|
||||
|
||||
#[test]
|
||||
fn cold_path_cannot_escape_data_directory() {
|
||||
let mut settings = Settings::default();
|
||||
settings.cold_path = "../outside".into();
|
||||
let mut settings = Settings {
|
||||
cold_path: "../outside".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate().is_err());
|
||||
settings.cold_path = "/tmp/outside".into();
|
||||
assert!(settings.validate().is_err());
|
||||
|
||||
Vendored
+1
-3
@@ -97,9 +97,7 @@ impl Candidate {
|
||||
self.headers.insert("date", value);
|
||||
}
|
||||
}
|
||||
for name in ["x-request-id"] {
|
||||
self.headers.remove(name);
|
||||
}
|
||||
self.headers.remove("x-request-id");
|
||||
let vary = std::mem::take(&mut self.plan.vary);
|
||||
let (body, body_allocation) = if let Some(body) = self.shared.take() {
|
||||
(body, self.shared_allocated)
|
||||
|
||||
Vendored
+1
-3
@@ -181,9 +181,7 @@ impl Cache {
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
let Some(_read_lease) = self.try_reserve_transient(entry.body_bytes, settings) else {
|
||||
return None;
|
||||
};
|
||||
let _read_lease = self.try_reserve_transient(entry.body_bytes, settings)?;
|
||||
let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64);
|
||||
let body_vec = match tokio::fs::read(&entry.body_path).await {
|
||||
Ok(body) if cold_body_matches(&entry, &body) => body,
|
||||
|
||||
Vendored
+1
-3
@@ -27,9 +27,7 @@ impl Cache {
|
||||
headers.insert("date", value);
|
||||
}
|
||||
}
|
||||
for name in ["x-request-id"] {
|
||||
headers.remove(name);
|
||||
}
|
||||
headers.remove("x-request-id");
|
||||
let hits = cached.hits.saturating_add(1);
|
||||
let age = plan.initial_age.as_secs();
|
||||
let remaining_ttl = plan.ttl.saturating_sub(age);
|
||||
|
||||
Vendored
+1
@@ -296,6 +296,7 @@ struct ColdDraft {
|
||||
_memory_lease: TransientLease,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum ColdCommand {
|
||||
Store(ColdDraft),
|
||||
Delete(Vec<PathBuf>),
|
||||
|
||||
Vendored
+2
-2
@@ -171,9 +171,9 @@ impl Revalidation {
|
||||
self.reusable_without_validation
|
||||
}
|
||||
pub fn cache_status_forward_reason(&self, request_forces_validation: bool) -> &'static str {
|
||||
if self.age >= self.ttl {
|
||||
if self.age >= self.ttl || !self.reusable_without_validation {
|
||||
"stale"
|
||||
} else if request_forces_validation && self.reusable_without_validation {
|
||||
} else if request_forces_validation {
|
||||
"request"
|
||||
} else {
|
||||
"miss"
|
||||
|
||||
Vendored
+2
-6
@@ -48,9 +48,10 @@ fn release_allocator_memory() {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CollapseTimeoutFallback {
|
||||
#[default]
|
||||
Origin,
|
||||
StaleIfAvailable,
|
||||
}
|
||||
@@ -63,11 +64,6 @@ pub enum StaleErrorClass {
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl Default for CollapseTimeoutFallback {
|
||||
fn default() -> Self {
|
||||
Self::Origin
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
Vendored
+5
-5
@@ -3,7 +3,7 @@ fn parse_key_hex(value: &str) -> Option<Key> {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0_u8; 32];
|
||||
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
|
||||
for (index, chunk) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
|
||||
let text = std::str::from_utf8(chunk).ok()?;
|
||||
key[index] = u8::from_str_radix(text, 16).ok()?;
|
||||
}
|
||||
@@ -137,13 +137,13 @@ fn stale_mode(
|
||||
if matches!(max_stale, Some(Some(limit)) if stale_for > limit) {
|
||||
return None;
|
||||
}
|
||||
if max_stale.is_some() {
|
||||
return Some(false);
|
||||
}
|
||||
if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate {
|
||||
return Some(true);
|
||||
}
|
||||
match max_stale {
|
||||
Some(None) | Some(Some(_)) => Some(false),
|
||||
None => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
Vendored
+6
-4
@@ -574,10 +574,12 @@ mod v022_cache_tests {
|
||||
|
||||
#[test]
|
||||
fn stale_if_error_policy_is_configurable_by_error_class_and_status() {
|
||||
let mut settings = Settings::default();
|
||||
settings.stale_if_error_timeout = false;
|
||||
settings.stale_if_error_dns = false;
|
||||
settings.stale_if_error_statuses = vec![500, 503];
|
||||
let settings = Settings {
|
||||
stale_if_error_timeout: false,
|
||||
stale_if_error_dns: false,
|
||||
stale_if_error_statuses: vec![500, 503],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout));
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Connection));
|
||||
|
||||
+5
-3
@@ -414,8 +414,10 @@ mod varnish_compatible_tests {
|
||||
#[tokio::test]
|
||||
async fn positive_fallback_ttl_reuses_only_public_no_cache_entries() {
|
||||
let cache = Cache::new(1);
|
||||
let mut settings = Settings::default();
|
||||
settings.default_ttl_seconds = 75;
|
||||
let settings = Settings {
|
||||
default_ttl_seconds: 75,
|
||||
..Default::default()
|
||||
};
|
||||
let ticket = cache
|
||||
.ticket(
|
||||
1,
|
||||
@@ -503,7 +505,7 @@ mod varnish_compatible_tests {
|
||||
)
|
||||
.unwrap();
|
||||
let mut response = Response::new(Body::from("css"));
|
||||
*response.headers_mut() = headers("public, max-age=60");
|
||||
*response.headers_mut() = headers("public, max-age=60, stale-while-revalidate=0");
|
||||
let p = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
type PhpEndpointPolicy = (
|
||||
String,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
);
|
||||
|
||||
pub fn compile_with_active(
|
||||
mut config: Config,
|
||||
revision: u64,
|
||||
@@ -67,14 +73,7 @@ pub fn compile_with_active(
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
let mut web_services = HashMap::new();
|
||||
let mut php_endpoint_policies: HashMap<
|
||||
String,
|
||||
(
|
||||
String,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
),
|
||||
> = HashMap::new();
|
||||
let mut php_endpoint_policies: HashMap<String, PhpEndpointPolicy> = HashMap::new();
|
||||
for service in &mut config.web_services {
|
||||
service
|
||||
.validate()
|
||||
|
||||
+1
-11
@@ -151,7 +151,7 @@ pub struct HostPolicy {
|
||||
pub hosts: Vec<String>,
|
||||
pub acl: Acl,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AclProfile {
|
||||
pub id: String,
|
||||
@@ -159,16 +159,6 @@ pub struct AclProfile {
|
||||
pub acl: Option<Acl>,
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
}
|
||||
impl Default for AclProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
acl: None,
|
||||
country_access: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Backend {
|
||||
|
||||
@@ -101,8 +101,8 @@ fn compile_routing(
|
||||
for definition in host.backends.iter().filter(|_| needs_proxy_pool) {
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
&desired_policy,
|
||||
&guarded_bindings,
|
||||
desired_policy,
|
||||
guarded_bindings,
|
||||
&host.load_balancing.keepalive,
|
||||
host.proxy_timeouts
|
||||
.connect_timeout()
|
||||
@@ -224,7 +224,7 @@ fn compile_routing(
|
||||
authentik: &host.authentik,
|
||||
oidc: &host.oidc,
|
||||
}
|
||||
.compile(&desired_policy, &guarded_bindings)?,
|
||||
.compile(desired_policy, guarded_bindings)?,
|
||||
id: host.id.clone(),
|
||||
bindings: host_bindings,
|
||||
acl_profile_id: host.acl_profile_id.clone(),
|
||||
|
||||
+7
-4
@@ -116,20 +116,23 @@ mod tests {
|
||||
method: crate::auth_provider::IntegrationMethod::Oidc,
|
||||
});
|
||||
host.oidc.enabled = true;
|
||||
host.oidc.issuer_url = "https://auth.gruszczynski.cc".into();
|
||||
host.oidc.issuer_url = "https://auth.example.com".into();
|
||||
host.oidc.client_id = "app".into();
|
||||
host.oidc.client_secret = "client-secret".into();
|
||||
host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into();
|
||||
host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into();
|
||||
assert!(host.validate().is_ok());
|
||||
let encoded = serde_json::to_value(&host).unwrap();
|
||||
assert_eq!(encoded["authentication"], serde_json::json!({"provider":"authelia","method":"oidc"}));
|
||||
let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap();
|
||||
assert_eq!(
|
||||
encoded["authentication"],
|
||||
serde_json::json!({"provider":"authelia","method":"oidc"})
|
||||
);
|
||||
let mut restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap();
|
||||
assert_eq!(restored.authentication, host.authentication);
|
||||
assert!(restored.validate().is_ok());
|
||||
let mut legacy = encoded.as_object().unwrap().clone();
|
||||
legacy.remove("authentication");
|
||||
let old: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
|
||||
let mut old: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
|
||||
assert!(old.authentication.is_none());
|
||||
assert!(old.validate().is_ok());
|
||||
host.authentication = Some(crate::auth_provider::Choice {
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ impl Diagnostics {
|
||||
pub fn trim_allocator() -> bool {
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
unsafe {
|
||||
return libc::malloc_trim(0) != 0;
|
||||
libc::malloc_trim(0) != 0
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ impl Manager {
|
||||
.filter(|agent| agent.enabled)
|
||||
.filter(|agent| {
|
||||
force
|
||||
|| snapshot.get(&agent.id).map_or(true, |observation| {
|
||||
|| snapshot.get(&agent.id).is_none_or(|observation| {
|
||||
now.saturating_sub(observation.checked_at_unix_ms)
|
||||
>= agent.poll_interval_seconds.saturating_mul(1000)
|
||||
})
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ fn normalize_fingerprint(value: &str) -> Result<String> {
|
||||
fn fingerprint_bytes(value: &str) -> Result<[u8; 32]> {
|
||||
let value = normalize_fingerprint(value)?;
|
||||
let mut out = [0u8; 32];
|
||||
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
|
||||
for (index, chunk) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
|
||||
let hi = (chunk[0] as char)
|
||||
.to_digit(16)
|
||||
.context("invalid TLS fingerprint")? as u8;
|
||||
|
||||
@@ -73,14 +73,14 @@ pub fn effective_config(
|
||||
.iter()
|
||||
.filter(|assignment| assignment.enabled)
|
||||
{
|
||||
let hosts = assignment_hosts(&assignment, &agent.base_domain)?;
|
||||
let hosts = assignment_hosts(assignment, &agent.base_domain)?;
|
||||
let target = inventory
|
||||
.and_then(|inventory| {
|
||||
inventory
|
||||
.services
|
||||
.iter()
|
||||
.find(|service| service.key == assignment.service_key)
|
||||
.and_then(|service| route_target(agent, &assignment, inventory, service))
|
||||
.and_then(|service| route_target(agent, assignment, inventory, service))
|
||||
})
|
||||
.map(|(ip, port)| -> Result<_> {
|
||||
usable_target_ip(ip)?;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub trait Adapter: Send + Sync + std::fmt::Debug + 'static {
|
||||
fn portal(_settings: &Settings) -> Result<Option<Url>> {
|
||||
Ok(None)
|
||||
}
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn denied(
|
||||
status: StatusCode,
|
||||
response_headers: &HeaderMap,
|
||||
@@ -298,6 +299,7 @@ impl<A: Adapter> Compiled<A> {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
|
||||
+3
-4
@@ -399,10 +399,9 @@ impl Compiled {
|
||||
};
|
||||
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()?
|
||||
match (cached, trusted_peer && self.trust_proto) {
|
||||
(Some(cached), false) => cached.port_header.clone(),
|
||||
_ => port.to_string().parse()?,
|
||||
},
|
||||
);
|
||||
values.insert("x-forwarded-uri", original_target.parse()?);
|
||||
|
||||
+2
-7
@@ -31,19 +31,14 @@ struct V6Range {
|
||||
end: [u8; 16],
|
||||
country: [u8; 2],
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DatabaseFormat {
|
||||
#[default]
|
||||
CsvPair,
|
||||
Mmdb,
|
||||
}
|
||||
|
||||
impl Default for DatabaseFormat {
|
||||
fn default() -> Self {
|
||||
Self::CsvPair
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Backend {
|
||||
Csv {
|
||||
|
||||
+1
-1
@@ -336,7 +336,7 @@ fn read_uint(
|
||||
read_be(bytes, h.payload, h.size)
|
||||
}
|
||||
|
||||
fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> {
|
||||
fn inline_string(bytes: &[u8], offset: usize, base: usize) -> Result<(&str, usize)> {
|
||||
let h = header(bytes, offset, base)?;
|
||||
ensure!(
|
||||
h.pointer.is_none() && h.kind == 2,
|
||||
|
||||
@@ -18,14 +18,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_require_https_and_safe_bounds() {
|
||||
let mut settings = UpdateSettings::default();
|
||||
settings.ipv4_url = "http://example.invalid/db.csv".into();
|
||||
let mut settings = UpdateSettings {
|
||||
ipv4_url: "http://example.invalid/db.csv".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate().is_err());
|
||||
let mut settings = UpdateSettings::default();
|
||||
settings.interval_seconds = 59;
|
||||
let mut settings = UpdateSettings {
|
||||
interval_seconds: 59,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate().is_err());
|
||||
let mut settings = UpdateSettings::default();
|
||||
settings.max_bytes = DEFAULT_MAX_BYTES + 1;
|
||||
let mut settings = UpdateSettings {
|
||||
max_bytes: DEFAULT_MAX_BYTES + 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -1,3 +1,8 @@
|
||||
// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's
|
||||
// argument-count threshold is stylistic and does not improve these call sites.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
#![cfg_attr(test, allow(clippy::field_reassign_with_default))]
|
||||
|
||||
pub mod acl;
|
||||
pub mod acme;
|
||||
pub mod admin;
|
||||
@@ -34,12 +39,12 @@ pub mod transform;
|
||||
pub mod auth_provider;
|
||||
pub mod authelia;
|
||||
pub mod authentik;
|
||||
pub mod oidc;
|
||||
pub mod balance;
|
||||
pub mod forward_auth;
|
||||
pub mod forwarding;
|
||||
pub mod geoip;
|
||||
pub mod http3;
|
||||
pub mod oidc;
|
||||
pub mod secret_store;
|
||||
pub mod security;
|
||||
pub mod tinyauth;
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct SinkStats {
|
||||
pub errors: u64,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum HistoryEntry {
|
||||
Owned(LogEntry),
|
||||
Shared(Arc<LogEntry>),
|
||||
|
||||
@@ -264,18 +264,14 @@ fn source_value_name(name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ErrorLogFormat {
|
||||
#[default]
|
||||
#[serde(rename = "JSON")]
|
||||
Json,
|
||||
#[serde(rename = "RAW")]
|
||||
Raw,
|
||||
}
|
||||
impl Default for ErrorLogFormat {
|
||||
fn default() -> Self {
|
||||
Self::Json
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
|
||||
+16
-12
@@ -62,8 +62,10 @@ mod tests {
|
||||
"transform_effective_enabled".into(),
|
||||
);
|
||||
format.static_fields.insert("service".into(), "edge".into());
|
||||
let mut settings = Settings::default();
|
||||
settings.format = format.clone();
|
||||
let settings = Settings {
|
||||
format: format.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.validate().unwrap();
|
||||
let value = format_entry(&entry(), &format);
|
||||
assert_eq!(value["http_status"], 200);
|
||||
@@ -399,9 +401,6 @@ mod tests {
|
||||
let mut live = logs.subscribe();
|
||||
let mut sentinel = entry();
|
||||
sentinel.id = 6;
|
||||
let mut history = logs.entries.lock().unwrap();
|
||||
history.push_back(HistoryEntry::Owned(sentinel.clone()));
|
||||
|
||||
let mut item = entry();
|
||||
item.path = "/contention/€\"quoted\"".into();
|
||||
item.authority = Some("app.example:443".into());
|
||||
@@ -409,14 +408,19 @@ mod tests {
|
||||
let expected = serde_json::to_value(&item).unwrap();
|
||||
let producer = logs.clone();
|
||||
let (finished, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
producer.push(item);
|
||||
finished.send(()).unwrap();
|
||||
});
|
||||
let completed = completion.recv_timeout(Duration::from_secs(2));
|
||||
drop(history);
|
||||
let worker = {
|
||||
let mut history = logs.entries.lock().unwrap();
|
||||
history.push_back(HistoryEntry::Owned(sentinel.clone()));
|
||||
let worker = std::thread::spawn(move || {
|
||||
producer.push(item);
|
||||
finished.send(()).unwrap();
|
||||
});
|
||||
let completed = completion.recv_timeout(Duration::from_secs(2));
|
||||
drop(history);
|
||||
completed.expect("history contention must not block live/sink delivery");
|
||||
worker
|
||||
};
|
||||
worker.join().unwrap();
|
||||
completed.expect("history contention must not block live/sink delivery");
|
||||
|
||||
assert_eq!(logs.dropped.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's
|
||||
// argument-count threshold is stylistic and does not improve these call sites.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
#![cfg_attr(test, allow(clippy::field_reassign_with_default))]
|
||||
|
||||
include!("main/bootstrap.rs");
|
||||
include!("main/admin_runtime.rs");
|
||||
include!("main/proxy_runtime.rs");
|
||||
|
||||
+510
-156
@@ -5,13 +5,24 @@ use crate::{
|
||||
};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _};
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine as _,
|
||||
};
|
||||
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use ring::{aead, digest, hmac, rand::{SecureRandom, SystemRandom}};
|
||||
use ring::{
|
||||
aead, digest, hmac,
|
||||
rand::{SecureRandom, SystemRandom},
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{fmt, net::SocketAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fmt,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::{form_urlencoded, Url};
|
||||
@@ -42,87 +53,183 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false, issuer_url: String::new(), backend_url: String::new(), client_id: String::new(),
|
||||
client_secret: String::new(), scopes: "openid profile email".into(), session_secret: String::new(), redirect_url: String::new(),
|
||||
allow_private: false, address_allowlist: Vec::new(), tls_ca_pem: None,
|
||||
connect_timeout_seconds: 5, timeout_seconds: 10, session_ttl_seconds: 3600,
|
||||
enabled: false,
|
||||
issuer_url: String::new(),
|
||||
backend_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
scopes: "openid profile email".into(),
|
||||
session_secret: String::new(),
|
||||
redirect_url: String::new(),
|
||||
allow_private: false,
|
||||
address_allowlist: Vec::new(),
|
||||
tls_ca_pem: None,
|
||||
connect_timeout_seconds: 5,
|
||||
timeout_seconds: 10,
|
||||
session_ttl_seconds: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn https_url(raw: &str) -> Result<Url> {
|
||||
ensure!(!raw.is_empty() && raw.len() <= 2048, "OIDC URL must be 1..2048 bytes");
|
||||
ensure!(
|
||||
!raw.is_empty() && raw.len() <= 2048,
|
||||
"OIDC URL must be 1..2048 bytes"
|
||||
);
|
||||
let url = Url::parse(raw)?;
|
||||
ensure!(url.scheme() == "https" && url.host_str().is_some(), "OIDC requires HTTPS URLs");
|
||||
ensure!(url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none(), "OIDC URL cannot contain credentials, queries or fragments");
|
||||
ensure!(
|
||||
url.scheme() == "https" && url.host_str().is_some(),
|
||||
"OIDC requires HTTPS URLs"
|
||||
);
|
||||
ensure!(
|
||||
url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none(),
|
||||
"OIDC URL cannot contain credentials, queries or fragments"
|
||||
);
|
||||
Ok(url)
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!((1..=30).contains(&self.connect_timeout_seconds)
|
||||
&& (1..=60).contains(&self.timeout_seconds)
|
||||
&& self.connect_timeout_seconds <= self.timeout_seconds,
|
||||
"OIDC timeouts must be 1..30s connect <= 1..60s total");
|
||||
ensure!((60..=86400).contains(&self.session_ttl_seconds), "OIDC session lifetime must be 60..86400 seconds");
|
||||
ensure!(
|
||||
(1..=30).contains(&self.connect_timeout_seconds)
|
||||
&& (1..=60).contains(&self.timeout_seconds)
|
||||
&& self.connect_timeout_seconds <= self.timeout_seconds,
|
||||
"OIDC timeouts must be 1..30s connect <= 1..60s total"
|
||||
);
|
||||
ensure!(
|
||||
(60..=86400).contains(&self.session_ttl_seconds),
|
||||
"OIDC session lifetime must be 60..86400 seconds"
|
||||
);
|
||||
if self.enabled {
|
||||
let issuer = https_url(&self.issuer_url)?;
|
||||
ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid");
|
||||
if !self.backend_url.is_empty() {
|
||||
let internal = crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
|
||||
ensure!(internal.path() == "/" && internal.query().is_none(),
|
||||
"OIDC internal backend URL must be an origin without a path or query");
|
||||
let internal =
|
||||
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
|
||||
ensure!(
|
||||
internal.path() == "/" && internal.query().is_none(),
|
||||
"OIDC internal backend URL must be an origin without a path or query"
|
||||
);
|
||||
}
|
||||
ensure!(!self.client_id.is_empty() && self.client_id.len() <= 256
|
||||
&& self.client_id.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
|
||||
"OIDC client ID must contain 1..256 URL-safe characters");
|
||||
ensure!(!self.client_secret.is_empty() && self.client_secret.len() <= 2048, "OIDC client secret required");
|
||||
ensure!(self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, "OIDC session secret must be at least 32 bytes");
|
||||
ensure!(self.scopes.len() <= 1024 && self.scopes.bytes().all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
|
||||
"OIDC scopes cannot contain control characters");
|
||||
ensure!(
|
||||
!self.client_id.is_empty()
|
||||
&& self.client_id.len() <= 256
|
||||
&& self
|
||||
.client_id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
|
||||
"OIDC client ID must contain 1..256 URL-safe characters"
|
||||
);
|
||||
ensure!(
|
||||
!self.client_secret.is_empty() && self.client_secret.len() <= 2048,
|
||||
"OIDC client secret required"
|
||||
);
|
||||
ensure!(
|
||||
self.session_secret.len() >= 32 && self.session_secret.len() <= 4096,
|
||||
"OIDC session secret must be at least 32 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.scopes.len() <= 1024
|
||||
&& self
|
||||
.scopes
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
|
||||
"OIDC scopes cannot contain control characters"
|
||||
);
|
||||
let scopes: Vec<_> = self.scopes.split_whitespace().collect();
|
||||
ensure!(scopes.len() <= 16 && scopes.contains(&"openid")
|
||||
&& scopes.iter().all(|item| item.len() <= 64 && item.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
|
||||
"OIDC scopes must contain openid and valid space-separated names");
|
||||
ensure!(
|
||||
scopes.len() <= 16
|
||||
&& scopes.contains(&"openid")
|
||||
&& scopes.iter().all(|item| item.len() <= 64
|
||||
&& item
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
|
||||
"OIDC scopes must contain openid and valid space-separated names"
|
||||
);
|
||||
let redirect = https_url(&self.redirect_url)?;
|
||||
ensure!(redirect.path() == CALLBACK, "OIDC redirect URL must end with {CALLBACK}");
|
||||
ensure!(
|
||||
redirect.path() == CALLBACK,
|
||||
"OIDC redirect URL must end with {CALLBACK}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn compile(&self, policy: &ValidationPolicy, bindings: &[SocketAddr]) -> Result<Option<Compiled>> {
|
||||
pub fn compile(
|
||||
&self,
|
||||
policy: &ValidationPolicy,
|
||||
bindings: &[SocketAddr],
|
||||
) -> Result<Option<Compiled>> {
|
||||
self.validate()?;
|
||||
if !self.enabled { return Ok(None); }
|
||||
if !self.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let issuer = https_url(&self.issuer_url)?;
|
||||
let redirect = https_url(&self.redirect_url)?;
|
||||
let upstream_origin = if self.backend_url.is_empty() {
|
||||
issuer.origin().ascii_serialization()
|
||||
} else {
|
||||
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?.origin().ascii_serialization()
|
||||
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?
|
||||
.origin()
|
||||
.ascii_serialization()
|
||||
};
|
||||
let public_host: Uri = issuer.as_str().parse()?;
|
||||
let host_header = HeaderValue::from_str(public_host.authority().context("OIDC issuer authority missing")?.as_str())?;
|
||||
let host_header = HeaderValue::from_str(
|
||||
public_host
|
||||
.authority()
|
||||
.context("OIDC issuer authority missing")?
|
||||
.as_str(),
|
||||
)?;
|
||||
let definition = Backend {
|
||||
id: "oidc".into(), url: upstream_origin.clone(),
|
||||
allow_private: self.allow_private, accept_local_listener_risk: false,
|
||||
preserve_host: false, protocol: UpstreamProtocol::Http1,
|
||||
id: "oidc".into(),
|
||||
url: upstream_origin.clone(),
|
||||
allow_private: self.allow_private,
|
||||
accept_local_listener_risk: false,
|
||||
preserve_host: false,
|
||||
protocol: UpstreamProtocol::Http1,
|
||||
address_allowlist: self.address_allowlist.clone(),
|
||||
tls_ca_pem: self.tls_ca_pem.clone(), tls_server_name: None, tls_verify: true,
|
||||
tls_ca_pem: self.tls_ca_pem.clone(),
|
||||
tls_server_name: None,
|
||||
tls_verify: true,
|
||||
};
|
||||
let keepalive = crate::balance::Keepalive::default();
|
||||
let mut backend = crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
|
||||
let mut backend =
|
||||
crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
|
||||
backend.client = crate::upstream::client_with_timeout(
|
||||
&definition, backend.address_policy.clone(), false, &keepalive,
|
||||
&definition,
|
||||
backend.address_policy.clone(),
|
||||
false,
|
||||
&keepalive,
|
||||
Duration::from_secs(self.connect_timeout_seconds),
|
||||
)?;
|
||||
let fingerprint = digest::digest(&digest::SHA256,
|
||||
format!("{}:{}", self.client_id, self.redirect_url).as_bytes());
|
||||
let suffix = fingerprint.as_ref()[..8].iter().map(|c| format!("{c:02x}")).collect::<Vec<_>>().join("");
|
||||
let fingerprint = digest::digest(
|
||||
&digest::SHA256,
|
||||
format!("{}:{}", self.client_id, self.redirect_url).as_bytes(),
|
||||
);
|
||||
let suffix = fingerprint.as_ref()[..8]
|
||||
.iter()
|
||||
.map(|c| format!("{c:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
Ok(Some(Compiled {
|
||||
provider_id: "oidc", provider_label: "OIDC",
|
||||
issuer: self.issuer_url.clone(), client_id: self.client_id.clone(), scopes: self.scopes.clone(),
|
||||
provider_id: "oidc",
|
||||
provider_label: "OIDC",
|
||||
issuer: self.issuer_url.clone(),
|
||||
client_id: self.client_id.clone(),
|
||||
scopes: self.scopes.clone(),
|
||||
client_secret: self.client_secret.clone(),
|
||||
session_secret: scoped_secret(&self.session_secret, &self.issuer_url, &self.client_id, &self.redirect_url),
|
||||
redirect, backend: Arc::new(backend), cache: Mutex::new(None),
|
||||
upstream_origin: Url::parse(&upstream_origin)?, host_header,
|
||||
session_secret: scoped_secret(
|
||||
&self.session_secret,
|
||||
&self.issuer_url,
|
||||
&self.client_id,
|
||||
&self.redirect_url,
|
||||
),
|
||||
redirect,
|
||||
backend: Arc::new(backend),
|
||||
cache: Mutex::new(None),
|
||||
upstream_origin: Url::parse(&upstream_origin)?,
|
||||
host_header,
|
||||
internal_override: !self.backend_url.is_empty(),
|
||||
timeout: Duration::from_secs(self.timeout_seconds),
|
||||
session_ttl: self.session_ttl_seconds,
|
||||
@@ -172,8 +279,10 @@ impl Compiled {
|
||||
}
|
||||
impl fmt::Debug for Compiled {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OidcProvider").field("issuer", &self.issuer)
|
||||
.field("redirect", &self.redirect).finish_non_exhaustive()
|
||||
f.debug_struct("OidcProvider")
|
||||
.field("issuer", &self.issuer)
|
||||
.field("redirect", &self.redirect)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -199,7 +308,9 @@ fn now() -> Result<u64> {
|
||||
fn random_b64(bytes: usize) -> Result<String> {
|
||||
ensure!(bytes <= 128, "random input too long");
|
||||
let mut random = vec![0u8; bytes];
|
||||
SystemRandom::new().fill(&mut random).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
|
||||
SystemRandom::new()
|
||||
.fill(&mut random)
|
||||
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(random))
|
||||
}
|
||||
fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String {
|
||||
@@ -209,17 +320,25 @@ fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) ->
|
||||
}
|
||||
fn crypt_key(secret: &str) -> Result<aead::LessSafeKey> {
|
||||
let hash = digest::digest(&digest::SHA256, secret.as_bytes());
|
||||
Ok(aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()).map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?))
|
||||
Ok(aead::LessSafeKey::new(
|
||||
aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?,
|
||||
))
|
||||
}
|
||||
fn seal<T: Serialize>(secret: &str, value: &T) -> Result<String> {
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
SystemRandom::new().fill(&mut nonce_bytes).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
|
||||
SystemRandom::new()
|
||||
.fill(&mut nonce_bytes)
|
||||
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
|
||||
let mut bytes = serde_json::to_vec(value)?;
|
||||
ensure!(bytes.len() <= 3072, "OIDC cookie contents too large");
|
||||
crypt_key(secret)?.seal_in_place_append_tag(
|
||||
aead::Nonce::assume_unique_for_key(nonce_bytes),
|
||||
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes,
|
||||
).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
|
||||
crypt_key(secret)?
|
||||
.seal_in_place_append_tag(
|
||||
aead::Nonce::assume_unique_for_key(nonce_bytes),
|
||||
aead::Aad::from("rustproxy-oidc-v1"),
|
||||
&mut bytes,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
|
||||
let mut data = nonce_bytes.to_vec();
|
||||
data.extend_from_slice(&bytes);
|
||||
let encoded = URL_SAFE_NO_PAD.encode(data);
|
||||
@@ -232,10 +351,13 @@ fn open<T: DeserializeOwned>(secret: &str, encoded: &str) -> Result<T> {
|
||||
ensure!(bytes.len() > 28, "invalid OIDC cookie");
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&bytes[..12]);
|
||||
let plaintext = crypt_key(secret)?.open_in_place(
|
||||
aead::Nonce::assume_unique_for_key(nonce),
|
||||
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..],
|
||||
).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
|
||||
let plaintext = crypt_key(secret)?
|
||||
.open_in_place(
|
||||
aead::Nonce::assume_unique_for_key(nonce),
|
||||
aead::Aad::from("rustproxy-oidc-v1"),
|
||||
&mut bytes[12..],
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
|
||||
Ok(serde_json::from_slice(plaintext)?)
|
||||
}
|
||||
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
@@ -244,7 +366,9 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
for entry in line.to_str().ok()?.split(';') {
|
||||
if let Some((key, value)) = entry.trim().split_once('=') {
|
||||
if key == name {
|
||||
if selected.is_some() { return None; }
|
||||
if selected.is_some() {
|
||||
return None;
|
||||
}
|
||||
selected = Some(value);
|
||||
}
|
||||
}
|
||||
@@ -256,19 +380,27 @@ fn cookie(name: &str, value: &str, age: u64) -> Result<HeaderValue> {
|
||||
Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?)
|
||||
}
|
||||
fn clear_cookie(name: &str) -> HeaderValue {
|
||||
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax").parse().expect("static valid cookie name")
|
||||
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax")
|
||||
.parse()
|
||||
.expect("static valid cookie name")
|
||||
}
|
||||
fn redirect(location: &str, status: StatusCode) -> Response {
|
||||
let mut response = auth_provider::deny(status);
|
||||
match location.parse() {
|
||||
Ok(value) => { response.headers_mut().insert(header::LOCATION, value); }
|
||||
Err(_) => { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
|
||||
Ok(value) => {
|
||||
response.headers_mut().insert(header::LOCATION, value);
|
||||
}
|
||||
Err(_) => {
|
||||
return auth_provider::deny(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||
if value.len() <= 2048 {
|
||||
if let Ok(value) = value.parse() { headers.insert(name, value); }
|
||||
if let Ok(value) = value.parse() {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn identity(session: &Session, issuer: &str) -> Identity {
|
||||
@@ -276,19 +408,31 @@ fn identity(session: &Session, issuer: &str) -> Identity {
|
||||
sanitize_header(&mut headers, "remote-sub", &session.subject);
|
||||
sanitize_header(&mut headers, "remote-issuer", issuer);
|
||||
sanitize_header(&mut headers, "remote-user", &session.username);
|
||||
if let Some(email) = &session.email { sanitize_header(&mut headers, "remote-email", email); }
|
||||
if let Some(name) = &session.name { sanitize_header(&mut headers, "remote-name", name); }
|
||||
if !session.groups.is_empty() { sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); }
|
||||
if let Some(email) = &session.email {
|
||||
sanitize_header(&mut headers, "remote-email", email);
|
||||
}
|
||||
if let Some(name) = &session.name {
|
||||
sanitize_header(&mut headers, "remote-name", name);
|
||||
}
|
||||
if !session.groups.is_empty() {
|
||||
sanitize_header(&mut headers, "remote-groups", &session.groups.join(","));
|
||||
}
|
||||
Identity { headers }
|
||||
}
|
||||
impl Compiled {
|
||||
fn url_for_endpoint(&self, raw: &str) -> Result<Url> {
|
||||
let url = https_url(raw)?;
|
||||
ensure!(url.origin() == https_url(&self.issuer)?.origin(), "OIDC endpoints must use the configured issuer origin");
|
||||
ensure!(
|
||||
url.origin() == https_url(&self.issuer)?.origin(),
|
||||
"OIDC endpoints must use the configured issuer origin"
|
||||
);
|
||||
Ok(url)
|
||||
}
|
||||
fn wire_uri(&self, public: &Url) -> Result<Uri> {
|
||||
ensure!(public.origin() == https_url(&self.issuer)?.origin(), "OIDC request not on issuer origin");
|
||||
ensure!(
|
||||
public.origin() == https_url(&self.issuer)?.origin(),
|
||||
"OIDC request not on issuer origin"
|
||||
);
|
||||
let mut wire = self.upstream_origin.clone();
|
||||
wire.set_path(public.path());
|
||||
wire.set_query(public.query());
|
||||
@@ -301,64 +445,145 @@ impl Compiled {
|
||||
}
|
||||
}
|
||||
async fn get_json(&self, url: &Url) -> Result<Value> {
|
||||
let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(url)?)
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(self.wire_uri(url)?)
|
||||
.body(Body::empty())?;
|
||||
self.wire_headers(request.headers_mut());
|
||||
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
let response = self.backend.client.request(request).await?;
|
||||
ensure!(response.status() == StatusCode::OK, "OIDC endpoint returned an error");
|
||||
let body = Limited::new(response.into_body(), 65536).collect().await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?.to_bytes();
|
||||
ensure!(
|
||||
response.status() == StatusCode::OK,
|
||||
"OIDC endpoint returned an error"
|
||||
);
|
||||
let body = Limited::new(response.into_body(), 65536)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?
|
||||
.to_bytes();
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
async fn discovery(&self) -> Result<Discovery> {
|
||||
let mut guard = self.cache.lock().await;
|
||||
if let Some(saved) = guard.as_ref() {
|
||||
if saved.until > Instant::now() { return Ok(saved.discovery.clone()); }
|
||||
if saved.until > Instant::now() {
|
||||
return Ok(saved.discovery.clone());
|
||||
}
|
||||
}
|
||||
let discovery_url = self.url_for_endpoint(&format!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?;
|
||||
let discovery_url = self.url_for_endpoint(&format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
self.issuer.trim_end_matches('/')
|
||||
))?;
|
||||
let metadata = self.get_json(&discovery_url).await?;
|
||||
ensure!(metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), "OIDC discovery issuer mismatch");
|
||||
let authorization = self.url_for_endpoint(metadata.get("authorization_endpoint").and_then(Value::as_str).context("missing OIDC authorization endpoint")?)?;
|
||||
let token = self.url_for_endpoint(metadata.get("token_endpoint").and_then(Value::as_str).context("missing OIDC token endpoint")?)?;
|
||||
let userinfo = metadata.get("userinfo_endpoint").and_then(Value::as_str)
|
||||
.map(|url| self.url_for_endpoint(url)).transpose()?;
|
||||
let jwks_url = self.url_for_endpoint(metadata.get("jwks_uri").and_then(Value::as_str).context("missing OIDC JWKS URI")?)?;
|
||||
ensure!(
|
||||
metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()),
|
||||
"OIDC discovery issuer mismatch"
|
||||
);
|
||||
let authorization = self.url_for_endpoint(
|
||||
metadata
|
||||
.get("authorization_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing OIDC authorization endpoint")?,
|
||||
)?;
|
||||
let token = self.url_for_endpoint(
|
||||
metadata
|
||||
.get("token_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing OIDC token endpoint")?,
|
||||
)?;
|
||||
let userinfo = metadata
|
||||
.get("userinfo_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.map(|url| self.url_for_endpoint(url))
|
||||
.transpose()?;
|
||||
let jwks_url = self.url_for_endpoint(
|
||||
metadata
|
||||
.get("jwks_uri")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing OIDC JWKS URI")?,
|
||||
)?;
|
||||
let jwks = self.get_json(&jwks_url).await?;
|
||||
ensure!(jwks.get("keys").and_then(Value::as_array).is_some(), "invalid OIDC signing keys");
|
||||
let discovery = Discovery { authorization, token, userinfo, jwks };
|
||||
*guard = Some(Cached { until: Instant::now() + Duration::from_secs(300), discovery: discovery.clone() });
|
||||
ensure!(
|
||||
jwks.get("keys").and_then(Value::as_array).is_some(),
|
||||
"invalid OIDC signing keys"
|
||||
);
|
||||
let discovery = Discovery {
|
||||
authorization,
|
||||
token,
|
||||
userinfo,
|
||||
jwks,
|
||||
};
|
||||
*guard = Some(Cached {
|
||||
until: Instant::now() + Duration::from_secs(300),
|
||||
discovery: discovery.clone(),
|
||||
});
|
||||
Ok(discovery)
|
||||
}
|
||||
fn is_redirect_host(&self, context: &forwarding::Context) -> bool {
|
||||
context.scheme == "https" && Url::parse(&context.original_url)
|
||||
.is_ok_and(|url| url.origin() == self.redirect.origin())
|
||||
context.scheme == "https"
|
||||
&& Url::parse(&context.original_url)
|
||||
.is_ok_and(|url| url.origin() == self.redirect.origin())
|
||||
}
|
||||
async fn initiate(&self, context: &forwarding::Context, cookies: &CookieSink) -> Result<Response> {
|
||||
ensure!(self.is_redirect_host(context), "OIDC request origin does not match callback");
|
||||
async fn initiate(
|
||||
&self,
|
||||
context: &forwarding::Context,
|
||||
cookies: &CookieSink,
|
||||
) -> Result<Response> {
|
||||
ensure!(
|
||||
self.is_redirect_host(context),
|
||||
"OIDC request origin does not match callback"
|
||||
);
|
||||
let destination = Url::parse(&context.original_url)?;
|
||||
let mut return_to = destination.path().to_owned();
|
||||
if let Some(query) = destination.query() { return_to.push('?'); return_to.push_str(query); }
|
||||
ensure!(return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), "invalid OIDC return URL");
|
||||
if let Some(query) = destination.query() {
|
||||
return_to.push('?');
|
||||
return_to.push_str(query);
|
||||
}
|
||||
ensure!(
|
||||
return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"),
|
||||
"invalid OIDC return URL"
|
||||
);
|
||||
let transaction = Pending {
|
||||
state: random_b64(32)?, nonce: random_b64(32)?, verifier: random_b64(32)?,
|
||||
return_to, issued: now()?,
|
||||
state: random_b64(32)?,
|
||||
nonce: random_b64(32)?,
|
||||
verifier: random_b64(32)?,
|
||||
return_to,
|
||||
issued: now()?,
|
||||
};
|
||||
let mut authorization = self.discovery().await?.authorization;
|
||||
let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes());
|
||||
authorization.query_pairs_mut().append_pair("response_type", "code")
|
||||
authorization
|
||||
.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &self.client_id)
|
||||
.append_pair("redirect_uri", self.redirect.as_str())
|
||||
.append_pair("scope", &self.scopes)
|
||||
.append_pair("state", &transaction.state)
|
||||
.append_pair("nonce", &transaction.nonce)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &URL_SAFE_NO_PAD.encode(challenge.as_ref()));
|
||||
.append_pair(
|
||||
"code_challenge",
|
||||
&URL_SAFE_NO_PAD.encode(challenge.as_ref()),
|
||||
);
|
||||
let mut response = redirect(authorization.as_str(), StatusCode::FOUND);
|
||||
let mut set_cookie = HeaderMap::new();
|
||||
set_cookie.append(header::SET_COOKIE, cookie(&self.state_cookie, &seal(&self.session_secret, &transaction)?, 300)?);
|
||||
ensure!(cookies.collect(&set_cookie), "OIDC transaction cookie too large");
|
||||
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
set_cookie.append(
|
||||
header::SET_COOKIE,
|
||||
cookie(
|
||||
&self.state_cookie,
|
||||
&seal(&self.session_secret, &transaction)?,
|
||||
300,
|
||||
)?,
|
||||
);
|
||||
ensure!(
|
||||
cookies.collect(&set_cookie),
|
||||
"OIDC transaction cookie too large"
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
Ok(response)
|
||||
}
|
||||
async fn exchange(&self, code: &str, pending: &Pending) -> Result<token::Claims> {
|
||||
@@ -369,38 +594,89 @@ impl Compiled {
|
||||
.append_pair("redirect_uri", self.redirect.as_str())
|
||||
.append_pair("code_verifier", &pending.verifier)
|
||||
.finish();
|
||||
let id_encoded: String = form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
|
||||
let secret_encoded: String = form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
|
||||
let id_encoded: String =
|
||||
form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
|
||||
let secret_encoded: String =
|
||||
form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
|
||||
let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}"));
|
||||
let mut request = Request::builder().method(Method::POST).uri(self.wire_uri(&discovery.token)?)
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(self.wire_uri(&discovery.token)?)
|
||||
.body(Body::from(body))?;
|
||||
self.wire_headers(request.headers_mut());
|
||||
request.headers_mut().insert(header::AUTHORIZATION, format!("Basic {authorization}").parse()?);
|
||||
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
request.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded"));
|
||||
request.headers_mut().insert(
|
||||
header::AUTHORIZATION,
|
||||
format!("Basic {authorization}").parse()?,
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
request.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/x-www-form-urlencoded"),
|
||||
);
|
||||
let response = self.backend.client.request(request).await?;
|
||||
ensure!(response.status() == StatusCode::OK, "OIDC token endpoint returned an error");
|
||||
let body = Limited::new(response.into_body(), 65536).collect().await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?.to_bytes();
|
||||
ensure!(
|
||||
response.status() == StatusCode::OK,
|
||||
"OIDC token endpoint returned an error"
|
||||
);
|
||||
let body = Limited::new(response.into_body(), 65536)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?
|
||||
.to_bytes();
|
||||
let value: Value = serde_json::from_slice(&body)?;
|
||||
let id_token = value.get("id_token").and_then(Value::as_str).context("missing OIDC ID token")?;
|
||||
let mut claims = token::verify_id_token(id_token, &discovery.jwks, &self.issuer, &self.client_id, &pending.nonce)?;
|
||||
if let (Some(userinfo_url), Some(access_token)) = (discovery.userinfo.as_ref(), value.get("access_token").and_then(Value::as_str)) {
|
||||
ensure!(access_token.len() <= 8192 && !access_token.is_empty(), "invalid OIDC access token");
|
||||
let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(userinfo_url)?)
|
||||
let id_token = value
|
||||
.get("id_token")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing OIDC ID token")?;
|
||||
let mut claims = token::verify_id_token(
|
||||
id_token,
|
||||
&discovery.jwks,
|
||||
&self.issuer,
|
||||
&self.client_id,
|
||||
&pending.nonce,
|
||||
)?;
|
||||
if let (Some(userinfo_url), Some(access_token)) = (
|
||||
discovery.userinfo.as_ref(),
|
||||
value.get("access_token").and_then(Value::as_str),
|
||||
) {
|
||||
ensure!(
|
||||
access_token.len() <= 8192 && !access_token.is_empty(),
|
||||
"invalid OIDC access token"
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(self.wire_uri(userinfo_url)?)
|
||||
.body(Body::empty())?;
|
||||
self.wire_headers(request.headers_mut());
|
||||
request.headers_mut().insert(header::AUTHORIZATION, format!("Bearer {access_token}").parse()?);
|
||||
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
request.headers_mut().insert(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {access_token}").parse()?,
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
|
||||
let response = self.backend.client.request(request).await?;
|
||||
ensure!(response.status() == StatusCode::OK, "OIDC UserInfo endpoint returned an error");
|
||||
let data = Limited::new(response.into_body(), 65536).collect().await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?.to_bytes();
|
||||
ensure!(
|
||||
response.status() == StatusCode::OK,
|
||||
"OIDC UserInfo endpoint returned an error"
|
||||
);
|
||||
let data = Limited::new(response.into_body(), 65536)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?
|
||||
.to_bytes();
|
||||
token::merge_userinfo(&mut claims, &serde_json::from_slice::<Value>(&data)?)?;
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
async fn callback(&self, request: Request<Body>, context: &forwarding::Context, shutdown: &CancellationToken) -> Response {
|
||||
async fn callback(
|
||||
&self,
|
||||
request: Request<Body>,
|
||||
context: &forwarding::Context,
|
||||
shutdown: &CancellationToken,
|
||||
) -> Response {
|
||||
if request.method() != Method::GET || !self.is_redirect_host(context) {
|
||||
return auth_provider::deny(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -408,14 +684,20 @@ impl Compiled {
|
||||
let mut valid_state = None;
|
||||
let mut error = false;
|
||||
if let Some(query) = request.uri().query() {
|
||||
if query.len() > 8192 { return auth_provider::deny(StatusCode::BAD_REQUEST); }
|
||||
if query.len() > 8192 {
|
||||
return auth_provider::deny(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"code" if valid_code.is_none() && value.len() <= 4096 => valid_code = Some(value.into_owned()),
|
||||
"state" if valid_state.is_none() && value.len() <= 256 => valid_state = Some(value.into_owned()),
|
||||
"code" if valid_code.is_none() && value.len() <= 4096 => {
|
||||
valid_code = Some(value.into_owned())
|
||||
}
|
||||
"state" if valid_state.is_none() && value.len() <= 256 => {
|
||||
valid_state = Some(value.into_owned())
|
||||
}
|
||||
"error" => error = true,
|
||||
"code" | "state" => error = true,
|
||||
_ => {},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,11 +708,18 @@ impl Compiled {
|
||||
Ok(value) => value,
|
||||
Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
let current = match now() { Ok(now) => now, Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY) };
|
||||
if error || current < pending.issued || current.saturating_sub(pending.issued) > 300
|
||||
let current = match now() {
|
||||
Ok(now) => now,
|
||||
Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY),
|
||||
};
|
||||
if error
|
||||
|| current < pending.issued
|
||||
|| current.saturating_sub(pending.issued) > 300
|
||||
|| valid_state.as_deref() != Some(pending.state.as_str())
|
||||
|| valid_code.as_deref().is_none_or(str::is_empty)
|
||||
{ return auth_provider::deny(StatusCode::UNAUTHORIZED); }
|
||||
{
|
||||
return auth_provider::deny(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let code = valid_code.expect("validated authorization code");
|
||||
let result = tokio::select! {
|
||||
_ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE),
|
||||
@@ -440,44 +729,90 @@ impl Compiled {
|
||||
Ok(Ok(claims)) => claims,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed");
|
||||
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification);
|
||||
return auth_provider::gateway_failure(
|
||||
self.provider_label,
|
||||
GatewayFailure::Verification,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
|
||||
}
|
||||
Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
|
||||
};
|
||||
let session = Session {
|
||||
subject: claims.subject, username: claims.username, email: claims.email,
|
||||
name: claims.name, groups: claims.groups,
|
||||
subject: claims.subject,
|
||||
username: claims.username,
|
||||
email: claims.email,
|
||||
name: claims.name,
|
||||
groups: claims.groups,
|
||||
expires: current.saturating_add(self.session_ttl),
|
||||
};
|
||||
let value = match seal(&self.session_secret, &session) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification),
|
||||
Err(_) => {
|
||||
return auth_provider::gateway_failure(
|
||||
self.provider_label,
|
||||
GatewayFailure::Verification,
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER);
|
||||
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
|
||||
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) {
|
||||
response.headers_mut().append(header::SET_COOKIE, cookie);
|
||||
} else { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
|
||||
} else {
|
||||
return auth_provider::deny(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
impl auth_provider::Provider for Compiled {
|
||||
fn id(&self) -> &'static str { self.provider_id }
|
||||
fn method(&self) -> auth_provider::IntegrationMethod { auth_provider::IntegrationMethod::Oidc }
|
||||
fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
let names: Vec<_> = headers.keys().filter(|name| {
|
||||
let name = name.as_str();
|
||||
name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-")
|
||||
|| matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email")
|
||||
}).cloned().collect();
|
||||
for name in names { headers.remove(name); }
|
||||
fn id(&self) -> &'static str {
|
||||
self.provider_id
|
||||
}
|
||||
fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method,
|
||||
context: &'a forwarding::Context, shutdown: &'a CancellationToken,
|
||||
cookies: &'a CookieSink) -> auth_provider::Authorization<'a> {
|
||||
fn method(&self) -> auth_provider::IntegrationMethod {
|
||||
auth_provider::IntegrationMethod::Oidc
|
||||
}
|
||||
fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
let names: Vec<_> = headers
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
let name = name.as_str();
|
||||
name.starts_with("remote-")
|
||||
|| name.starts_with("x-authentik-")
|
||||
|| name.starts_with("x-auth-request-")
|
||||
|| matches!(
|
||||
name,
|
||||
"x-forwarded-user"
|
||||
| "x-forwarded-email"
|
||||
| "x-remote-user"
|
||||
| "x-remote-email"
|
||||
| "x-user"
|
||||
| "x-email"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for name in names {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
incoming: &'a HeaderMap,
|
||||
method: &'a Method,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
cookies: &'a CookieSink,
|
||||
) -> auth_provider::Authorization<'a> {
|
||||
Box::pin(async move {
|
||||
if !self.is_redirect_host(context) { return Err(auth_provider::deny(StatusCode::FORBIDDEN)); }
|
||||
if !self.is_redirect_host(context) {
|
||||
return Err(auth_provider::deny(StatusCode::FORBIDDEN));
|
||||
}
|
||||
if let Some(encoded) = cookie_value(incoming, &self.session_cookie) {
|
||||
if let Ok(session) = open::<Session>(&self.session_secret, encoded) {
|
||||
if now().is_ok_and(|current| session.expires > current) {
|
||||
@@ -496,25 +831,44 @@ impl auth_provider::Provider for Compiled {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed");
|
||||
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification)
|
||||
auth_provider::gateway_failure(
|
||||
self.provider_label,
|
||||
GatewayFailure::Verification,
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
|
||||
}
|
||||
Err(_) => auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
|
||||
})
|
||||
})
|
||||
}
|
||||
fn reserved_path(&self, path: &str) -> bool { matches!(path, CALLBACK | LOGOUT) }
|
||||
fn serve_reserved<'a>(&'a self, request: Request<Body>, context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken) -> Option<auth_provider::Route<'a>> {
|
||||
fn reserved_path(&self, path: &str) -> bool {
|
||||
matches!(path, CALLBACK | LOGOUT)
|
||||
}
|
||||
fn serve_reserved<'a>(
|
||||
&'a self,
|
||||
request: Request<Body>,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
) -> Option<auth_provider::Route<'a>> {
|
||||
match request.uri().path() {
|
||||
CALLBACK => Some(Box::pin(async move { self.callback(request, context, shutdown).await })),
|
||||
CALLBACK => Some(Box::pin(async move {
|
||||
self.callback(request, context, shutdown).await
|
||||
})),
|
||||
LOGOUT => Some(Box::pin(async move {
|
||||
if request.method() != Method::GET || !self.is_redirect_host(context) {
|
||||
return auth_provider::deny(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let mut response = redirect("/", StatusCode::SEE_OTHER);
|
||||
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
|
||||
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
|
||||
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
response
|
||||
})),
|
||||
_ => None,
|
||||
|
||||
+120
-37
@@ -6,11 +6,11 @@ mod tests {
|
||||
fn enabled() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
issuer_url: "https://auth.gruszczynski.cc".into(),
|
||||
issuer_url: "https://auth.example.com".into(),
|
||||
client_id: "rustproxy-app".into(),
|
||||
client_secret: "super-secret-client".into(),
|
||||
session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(),
|
||||
redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
|
||||
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,14 @@ mod tests {
|
||||
assert!(!Settings::default().enabled);
|
||||
let settings = enabled();
|
||||
assert!(settings.validate().is_ok());
|
||||
assert_eq!(https_url(&settings.issuer_url).unwrap().host_str(), Some("auth.gruszczynski.cc"));
|
||||
assert_eq!(https_url(&settings.redirect_url).unwrap().host_str(), Some("app.linuxiarz.pl"));
|
||||
assert_eq!(
|
||||
https_url(&settings.issuer_url).unwrap().host_str(),
|
||||
Some("auth.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
https_url(&settings.redirect_url).unwrap().host_str(),
|
||||
Some("app.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +36,7 @@ mod tests {
|
||||
settings.issuer_url = "http://auth.example.com".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings = enabled();
|
||||
settings.redirect_url = "https://app.linuxiarz.pl/other".into();
|
||||
settings.redirect_url = "https://app.example.com/other".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings = enabled();
|
||||
settings.client_secret.clear();
|
||||
@@ -50,9 +56,12 @@ mod tests {
|
||||
fn encrypted_cookies_are_authenticated_and_not_readable() {
|
||||
let secret = "session-secret-at-least-32-bytes-unique";
|
||||
let session = Session {
|
||||
subject: "a-stable-id".into(), username: "alice".into(),
|
||||
email: Some("alice@example.com".into()), name: None,
|
||||
groups: vec!["member".into()], expires: now().unwrap() + 3600,
|
||||
subject: "a-stable-id".into(),
|
||||
username: "alice".into(),
|
||||
email: Some("alice@example.com".into()),
|
||||
name: None,
|
||||
groups: vec!["member".into()],
|
||||
expires: now().unwrap() + 3600,
|
||||
};
|
||||
let encoded = seal(secret, &session).unwrap();
|
||||
assert!(!encoded.contains("alice"));
|
||||
@@ -73,8 +82,18 @@ mod tests {
|
||||
#[test]
|
||||
fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() {
|
||||
let shared = "same-admin-configured-secret-for-multiple-applications";
|
||||
let a = scoped_secret(shared, "https://auth.example.com", "app1", "https://app1.example.org/_rustproxy/oidc/callback");
|
||||
let b = scoped_secret(shared, "https://auth.example.com", "app2", "https://app2.example.org/_rustproxy/oidc/callback");
|
||||
let a = scoped_secret(
|
||||
shared,
|
||||
"https://auth.example.com",
|
||||
"app1",
|
||||
"https://app1.example.org/_rustproxy/oidc/callback",
|
||||
);
|
||||
let b = scoped_secret(
|
||||
shared,
|
||||
"https://auth.example.com",
|
||||
"app2",
|
||||
"https://app2.example.org/_rustproxy/oidc/callback",
|
||||
);
|
||||
assert_ne!(a, b);
|
||||
let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap();
|
||||
assert!(open::<Value>(&b, &encrypted).is_err());
|
||||
@@ -83,7 +102,10 @@ mod tests {
|
||||
#[test]
|
||||
fn duplicate_cookies_cannot_override_a_valid_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "a=1; login=ok; login=malicious".parse().unwrap());
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"a=1; login=ok; login=malicious".parse().unwrap(),
|
||||
);
|
||||
assert!(cookie_value(&headers, "login").is_none());
|
||||
headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap());
|
||||
assert_eq!(cookie_value(&headers, "login"), Some("ok"));
|
||||
@@ -92,17 +114,28 @@ mod tests {
|
||||
#[test]
|
||||
fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() {
|
||||
let mut claims = token::Claims {
|
||||
subject: "id-42".into(), username: "id-42".into(), email: None,
|
||||
name: None, groups: vec![],
|
||||
subject: "id-42".into(),
|
||||
username: "id-42".into(),
|
||||
email: None,
|
||||
name: None,
|
||||
groups: vec![],
|
||||
};
|
||||
assert!(token::merge_userinfo(&mut claims, &serde_json::json!({
|
||||
"sub":"other-user", "preferred_username":"admin"
|
||||
})).is_err());
|
||||
assert!(token::merge_userinfo(
|
||||
&mut claims,
|
||||
&serde_json::json!({
|
||||
"sub":"other-user", "preferred_username":"admin"
|
||||
})
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(claims.username, "id-42");
|
||||
token::merge_userinfo(&mut claims, &serde_json::json!({
|
||||
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
|
||||
"email_verified":false, "groups":["users"]
|
||||
})).unwrap();
|
||||
token::merge_userinfo(
|
||||
&mut claims,
|
||||
&serde_json::json!({
|
||||
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
|
||||
"email_verified":false, "groups":["users"]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(claims.username, "alice");
|
||||
assert_eq!(claims.email, None);
|
||||
assert_eq!(claims.groups, vec!["users".to_string()]);
|
||||
@@ -112,9 +145,16 @@ mod tests {
|
||||
fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() {
|
||||
let rng = SystemRandom::new();
|
||||
let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8(
|
||||
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
|
||||
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
|
||||
&rng,
|
||||
)
|
||||
.unwrap();
|
||||
let keypair = signature::EcdsaKeyPair::from_pkcs8(
|
||||
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
|
||||
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
|
||||
pkcs8.as_ref(),
|
||||
&rng,
|
||||
)
|
||||
.unwrap();
|
||||
let public = keypair.public_key().as_ref();
|
||||
let jwks = serde_json::json!({ "keys": [{
|
||||
"kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256",
|
||||
@@ -128,32 +168,75 @@ mod tests {
|
||||
});
|
||||
let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"});
|
||||
let token = |claims: &Value| {
|
||||
let data = format!("{}.{}",
|
||||
let data = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()));
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap())
|
||||
);
|
||||
let sig = keypair.sign(&rng, data.as_bytes()).unwrap();
|
||||
format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref()))
|
||||
};
|
||||
let jwt = token(&claims);
|
||||
let verified = token::verify_id_token(&jwt, &jwks,
|
||||
"https://auth.example.com", "rustproxy", "session-nonce").unwrap();
|
||||
let verified = token::verify_id_token(
|
||||
&jwt,
|
||||
&jwks,
|
||||
"https://auth.example.com",
|
||||
"rustproxy",
|
||||
"session-nonce",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(verified.subject, "id-42");
|
||||
assert_eq!(verified.username, "alice");
|
||||
assert!(token::verify_id_token(&jwt, &jwks,
|
||||
"https://other.example.com", "rustproxy", "session-nonce").is_err());
|
||||
assert!(token::verify_id_token(&jwt, &jwks,
|
||||
"https://auth.example.com", "other-client", "session-nonce").is_err());
|
||||
assert!(token::verify_id_token(&jwt, &jwks,
|
||||
"https://auth.example.com", "rustproxy", "wrong-nonce").is_err());
|
||||
assert!(token::verify_id_token(
|
||||
&jwt,
|
||||
&jwks,
|
||||
"https://other.example.com",
|
||||
"rustproxy",
|
||||
"session-nonce"
|
||||
)
|
||||
.is_err());
|
||||
assert!(token::verify_id_token(
|
||||
&jwt,
|
||||
&jwks,
|
||||
"https://auth.example.com",
|
||||
"other-client",
|
||||
"session-nonce"
|
||||
)
|
||||
.is_err());
|
||||
assert!(token::verify_id_token(
|
||||
&jwt,
|
||||
&jwks,
|
||||
"https://auth.example.com",
|
||||
"rustproxy",
|
||||
"wrong-nonce"
|
||||
)
|
||||
.is_err());
|
||||
let mut expired = claims.clone();
|
||||
expired["exp"] = serde_json::json!(now().unwrap() - 1);
|
||||
assert!(token::verify_id_token(&token(&expired), &jwks,
|
||||
"https://auth.example.com", "rustproxy", "session-nonce").is_err());
|
||||
assert!(token::verify_id_token(
|
||||
&token(&expired),
|
||||
&jwks,
|
||||
"https://auth.example.com",
|
||||
"rustproxy",
|
||||
"session-nonce"
|
||||
)
|
||||
.is_err());
|
||||
let mut wrong = claims.clone();
|
||||
wrong["sub"] = serde_json::json!("another-user");
|
||||
let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect();
|
||||
let modified = format!("{}.{}.{}", forged_parts[0], forged_parts[1], jwt.split('.').last().unwrap());
|
||||
assert!(token::verify_id_token(&modified, &jwks,
|
||||
"https://auth.example.com", "rustproxy", "session-nonce").is_err());
|
||||
let modified = format!(
|
||||
"{}.{}.{}",
|
||||
forged_parts[0],
|
||||
forged_parts[1],
|
||||
jwt.split('.').next_back().unwrap()
|
||||
);
|
||||
assert!(token::verify_id_token(
|
||||
&modified,
|
||||
&jwks,
|
||||
"https://auth.example.com",
|
||||
"rustproxy",
|
||||
"session-nonce"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
+157
-42
@@ -14,7 +14,10 @@ pub(super) struct Claims {
|
||||
}
|
||||
|
||||
fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
|
||||
value.get(name).and_then(Value::as_str).context("missing or invalid OIDC claim")
|
||||
value
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.context("missing or invalid OIDC claim")
|
||||
}
|
||||
|
||||
fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
|
||||
@@ -24,22 +27,42 @@ fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> {
|
||||
ensure!(key.get("use").and_then(Value::as_str).is_none_or(|usage| usage == "sig"), "OIDC JWK is not a signing key");
|
||||
ensure!(key.get("alg").and_then(Value::as_str).is_none_or(|declared| declared == alg), "OIDC JWK algorithm mismatch");
|
||||
ensure!(
|
||||
key.get("use")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|usage| usage == "sig"),
|
||||
"OIDC JWK is not a signing key"
|
||||
);
|
||||
ensure!(
|
||||
key.get("alg")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|declared| declared == alg),
|
||||
"OIDC JWK algorithm mismatch"
|
||||
);
|
||||
match alg {
|
||||
"RS256" => {
|
||||
ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch");
|
||||
let n = key_bytes(key, "n")?;
|
||||
let e = key_bytes(key, "e")?;
|
||||
signature::RsaPublicKeyComponents { n: &n, e: &e }
|
||||
.verify(&signature::RSA_PKCS1_2048_8192_SHA256, signed, signature_bytes)
|
||||
.verify(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
signed,
|
||||
signature_bytes,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?;
|
||||
}
|
||||
"ES256" => {
|
||||
ensure!(field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", "OIDC key type mismatch");
|
||||
ensure!(
|
||||
field(key, "kty")? == "EC" && field(key, "crv")? == "P-256",
|
||||
"OIDC key type mismatch"
|
||||
);
|
||||
let x = key_bytes(key, "x")?;
|
||||
let y = key_bytes(key, "y")?;
|
||||
ensure!(x.len() == 32 && y.len() == 32, "invalid OIDC curve coordinates");
|
||||
ensure!(
|
||||
x.len() == 32 && y.len() == 32,
|
||||
"invalid OIDC curve coordinates"
|
||||
);
|
||||
let mut public = Vec::with_capacity(65);
|
||||
public.push(4);
|
||||
public.extend_from_slice(&x);
|
||||
@@ -69,79 +92,171 @@ pub(super) fn verify_id_token(
|
||||
let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?;
|
||||
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
|
||||
let alg = field(&header, "alg")?;
|
||||
ensure!(matches!(alg, "RS256" | "ES256"), "OIDC JWT algorithm not allowed");
|
||||
ensure!(
|
||||
matches!(alg, "RS256" | "ES256"),
|
||||
"OIDC JWT algorithm not allowed"
|
||||
);
|
||||
let kid = header.get("kid").and_then(Value::as_str);
|
||||
let keys = jwks.get("keys").and_then(Value::as_array).context("invalid OIDC JWKS")?;
|
||||
let keys = jwks
|
||||
.get("keys")
|
||||
.and_then(Value::as_array)
|
||||
.context("invalid OIDC JWKS")?;
|
||||
ensure!(keys.len() <= 32, "too many OIDC signing keys");
|
||||
let candidates: Vec<_> = keys.iter().filter(|key| {
|
||||
kid.map_or(true, |id| key.get("kid").and_then(Value::as_str) == Some(id))
|
||||
&& key.get("alg").and_then(Value::as_str).is_none_or(|configured| configured == alg)
|
||||
&& key.get("use").and_then(Value::as_str).is_none_or(|configured| configured == "sig")
|
||||
}).collect();
|
||||
ensure!(candidates.len() == 1, "OIDC signing key missing or ambiguous");
|
||||
let candidates: Vec<_> = keys
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
kid.is_none_or(|id| key.get("kid").and_then(Value::as_str) == Some(id))
|
||||
&& key
|
||||
.get("alg")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|configured| configured == alg)
|
||||
&& key
|
||||
.get("use")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|configured| configured == "sig")
|
||||
})
|
||||
.collect();
|
||||
ensure!(
|
||||
candidates.len() == 1,
|
||||
"OIDC signing key missing or ambiguous"
|
||||
);
|
||||
let signed = format!("{head}.{payload}");
|
||||
validate_signature(candidates[0], alg, signed.as_bytes(), &URL_SAFE_NO_PAD.decode(signature)?)?;
|
||||
ensure!(field(&claims, "iss")? == expected_issuer, "OIDC issuer mismatch");
|
||||
validate_signature(
|
||||
candidates[0],
|
||||
alg,
|
||||
signed.as_bytes(),
|
||||
&URL_SAFE_NO_PAD.decode(signature)?,
|
||||
)?;
|
||||
ensure!(
|
||||
field(&claims, "iss")? == expected_issuer,
|
||||
"OIDC issuer mismatch"
|
||||
);
|
||||
let audience = claims.get("aud").context("missing OIDC audience")?;
|
||||
let audiences: Vec<&str> = match audience {
|
||||
Value::String(value) => vec![value],
|
||||
Value::Array(values) => values.iter().map(|value| value.as_str().context("invalid OIDC audience")).collect::<Result<_>>()?,
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(|value| value.as_str().context("invalid OIDC audience"))
|
||||
.collect::<Result<_>>()?,
|
||||
_ => bail!("invalid OIDC audience"),
|
||||
};
|
||||
ensure!(audiences.contains(&client_id), "OIDC audience mismatch");
|
||||
if audiences.len() > 1 {
|
||||
ensure!(field(&claims, "azp")? == client_id, "OIDC authorized party mismatch");
|
||||
ensure!(
|
||||
field(&claims, "azp")? == client_id,
|
||||
"OIDC authorized party mismatch"
|
||||
);
|
||||
} else if let Some(azp) = claims.get("azp").and_then(Value::as_str) {
|
||||
ensure!(azp == client_id, "OIDC authorized party mismatch");
|
||||
}
|
||||
ensure!(field(&claims, "nonce")? == expected_nonce, "OIDC nonce mismatch");
|
||||
ensure!(
|
||||
field(&claims, "nonce")? == expected_nonce,
|
||||
"OIDC nonce mismatch"
|
||||
);
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
let exp = claims.get("exp").and_then(Value::as_u64).context("missing OIDC expiration")?;
|
||||
let exp = claims
|
||||
.get("exp")
|
||||
.and_then(Value::as_u64)
|
||||
.context("missing OIDC expiration")?;
|
||||
ensure!(exp > now, "OIDC ID token expired");
|
||||
let issued = claims.get("iat").and_then(Value::as_u64).context("missing OIDC issuance time")?;
|
||||
ensure!(issued <= now.saturating_add(60), "OIDC token issued in the future");
|
||||
let issued = claims
|
||||
.get("iat")
|
||||
.and_then(Value::as_u64)
|
||||
.context("missing OIDC issuance time")?;
|
||||
ensure!(
|
||||
issued <= now.saturating_add(60),
|
||||
"OIDC token issued in the future"
|
||||
);
|
||||
if let Some(nbf) = claims.get("nbf") {
|
||||
ensure!(nbf.as_u64().is_some_and(|start| start <= now.saturating_add(60)), "OIDC token is not yet valid");
|
||||
ensure!(
|
||||
nbf.as_u64()
|
||||
.is_some_and(|start| start <= now.saturating_add(60)),
|
||||
"OIDC token is not yet valid"
|
||||
);
|
||||
}
|
||||
let subject = field(&claims, "sub")?.to_string();
|
||||
ensure!(!subject.is_empty() && subject.len() <= 256, "invalid OIDC subject");
|
||||
let username = claims.get("preferred_username").and_then(Value::as_str)
|
||||
ensure!(
|
||||
!subject.is_empty() && subject.len() <= 256,
|
||||
"invalid OIDC subject"
|
||||
);
|
||||
let username = claims
|
||||
.get("preferred_username")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256)
|
||||
.unwrap_or(&subject).to_string();
|
||||
let optional = |key| claims.get(key).and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 256).map(str::to_string);
|
||||
let groups = claims.get("groups").and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 128).take(32).map(str::to_string).collect())
|
||||
.unwrap_or(&subject)
|
||||
.to_string();
|
||||
let optional = |key| {
|
||||
claims
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty() && value.len() <= 256)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let groups = claims
|
||||
.get("groups")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 128)
|
||||
.take(32)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) {
|
||||
None
|
||||
} else {
|
||||
optional("email")
|
||||
};
|
||||
Ok(Claims { subject, username, email, name: optional("name"), groups })
|
||||
Ok(Claims {
|
||||
subject,
|
||||
username,
|
||||
email,
|
||||
name: optional("name"),
|
||||
groups,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> {
|
||||
ensure!(field(value, "sub")? == claims.subject, "OIDC UserInfo subject mismatch");
|
||||
ensure!(
|
||||
field(value, "sub")? == claims.subject,
|
||||
"OIDC UserInfo subject mismatch"
|
||||
);
|
||||
if claims.username == claims.subject {
|
||||
if let Some(username) = value.get("preferred_username").and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256) {
|
||||
if let Some(username) = value
|
||||
.get("preferred_username")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256)
|
||||
{
|
||||
claims.username = username.to_string();
|
||||
}
|
||||
}
|
||||
if claims.name.is_none() {
|
||||
claims.name = value.get("name").and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
|
||||
claims.name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256)
|
||||
.map(str::to_string);
|
||||
}
|
||||
if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) {
|
||||
claims.email = value.get("email").and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
|
||||
if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false)
|
||||
{
|
||||
claims.email = value
|
||||
.get("email")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 256)
|
||||
.map(str::to_string);
|
||||
}
|
||||
if claims.groups.is_empty() {
|
||||
if let Some(groups) = value.get("groups").and_then(Value::as_array) {
|
||||
claims.groups = groups.iter().filter_map(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 128).take(32).map(str::to_string).collect();
|
||||
claims.groups = groups
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 128)
|
||||
.take(32)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -100,7 +100,7 @@ mod tests {
|
||||
state
|
||||
.prometheus_token_hash
|
||||
.store(Some(Arc::new(auth::secret_hash(token))));
|
||||
let router = router(state);
|
||||
let router = router(state, None);
|
||||
let make = |path: &str, peer: Option<&str>, bearer: Option<&str>| {
|
||||
let mut builder = Request::builder().uri(path);
|
||||
if let Some(peer) = peer {
|
||||
|
||||
@@ -251,7 +251,7 @@ impl Drop for Exchange {
|
||||
sni: None,
|
||||
authority: None,
|
||||
matched_by: None,
|
||||
cache_reason: *self
|
||||
cache_reason: self
|
||||
.cache_reason
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
|
||||
@@ -86,6 +86,7 @@ enum ErrorFallbackBody {
|
||||
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
|
||||
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn prepare_error_fallback_body(
|
||||
request: &mut Request,
|
||||
enabled: bool,
|
||||
|
||||
@@ -912,7 +912,7 @@ mod cache_fast_path_tests {
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
|
||||
fixture.stop().await;
|
||||
|
||||
let origin = origin().await;
|
||||
let origin = self::origin().await;
|
||||
let mut config = config_for(origin.addr, true);
|
||||
config.cache.default_ttl_seconds = 45;
|
||||
let fixture = Fixture::new(config).await;
|
||||
|
||||
@@ -208,7 +208,7 @@ let upstream_result = loop {
|
||||
let selection = match pool
|
||||
.select_retry_wait(
|
||||
context.client_ip,
|
||||
&retry_headers,
|
||||
retry_headers,
|
||||
tls,
|
||||
&attempted_backends,
|
||||
)
|
||||
@@ -232,7 +232,7 @@ let upstream_result = loop {
|
||||
let Some(next_request) = retry_request(
|
||||
&retry_method,
|
||||
upstream_request_version(&next_backend, false),
|
||||
&retry_headers,
|
||||
retry_headers,
|
||||
&next_backend,
|
||||
&original_host,
|
||||
&origin_suffix,
|
||||
|
||||
@@ -45,7 +45,12 @@ mod authority_tests {
|
||||
.extensions_mut()
|
||||
.insert(LocalAddr("127.0.0.1:8080".parse().unwrap()));
|
||||
assert!(response_context(&snapshot, &invalid).validated.is_none());
|
||||
assert!(crate::acl::routing_path_view("/encoded%2fslash").is_err());
|
||||
assert_eq!(
|
||||
crate::acl::routing_path_view("/encoded%2fslash")
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
"/encoded/slash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -477,6 +482,39 @@ mod authority_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_slash_routes_on_normalized_path_but_preserves_upstream_target() {
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
for suffix in ["/a%2Fb?x=1", "/a%2fb?x=1"] {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(suffix)
|
||||
.header(HOST, "localhost")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(LocalAddr("127.0.0.1:8080".parse().unwrap()));
|
||||
|
||||
let context = response_context(&snapshot, &request);
|
||||
let route = context.route.as_ref().expect("route selected");
|
||||
let routing_path = context
|
||||
.validated
|
||||
.as_ref()
|
||||
.and_then(|validated| validated.decoded_path.as_deref())
|
||||
.expect("encoded slash creates a normalized routing path");
|
||||
assert_eq!(routing_path, "/a/b");
|
||||
assert!(route.rewritten_path(routing_path).unwrap().is_none());
|
||||
|
||||
let public_suffix = request.uri().path_and_query().unwrap().as_str();
|
||||
assert_eq!(public_suffix, suffix);
|
||||
let backend = route.backend.as_ref().expect("reverse-proxy backend");
|
||||
let upstream = upstream_uri(backend, public_suffix).unwrap();
|
||||
assert_eq!(upstream.path_and_query().unwrap().as_str(), suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
@@ -670,6 +708,16 @@ mod graceful_h2_tests {
|
||||
use super::*;
|
||||
use std::fmt;
|
||||
|
||||
fn validation_policy() -> crate::config::ValidationPolicy {
|
||||
crate::config::ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct H2DebugError(&'static str);
|
||||
impl fmt::Display for H2DebugError {
|
||||
@@ -697,7 +745,7 @@ mod graceful_h2_tests {
|
||||
let backend = &snapshot.pools[0].members[0].backend;
|
||||
assert_eq!(
|
||||
backend.request_origin_uri.to_string(),
|
||||
backend.request_origin
|
||||
format!("{}/", backend.request_origin)
|
||||
);
|
||||
for suffix in [
|
||||
"/",
|
||||
|
||||
+10
-13
@@ -10,19 +10,16 @@ struct SelectedRouteContext {
|
||||
route: Option<Arc<crate::config::CompiledRoute>>,
|
||||
validated: Option<ValidatedRoutingIdentity>,
|
||||
}
|
||||
fn take_routing_identity(
|
||||
request: &mut Request,
|
||||
tls: bool,
|
||||
) -> Result<
|
||||
(
|
||||
Option<SelectedRouteContext>,
|
||||
HeaderValue,
|
||||
String,
|
||||
Option<String>,
|
||||
bool,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
type RoutingIdentity = (
|
||||
Option<SelectedRouteContext>,
|
||||
HeaderValue,
|
||||
String,
|
||||
Option<String>,
|
||||
bool,
|
||||
);
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn take_routing_identity(request: &mut Request, tls: bool) -> Result<RoutingIdentity, Response> {
|
||||
let mut selected_context = request.extensions_mut().remove::<SelectedRouteContext>();
|
||||
let validated = selected_context
|
||||
.as_mut()
|
||||
|
||||
@@ -228,7 +228,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.map(|context| context.0.clone());
|
||||
let effective_route_context = final_route_context
|
||||
.as_deref()
|
||||
.or_else(|| route_context.as_deref());
|
||||
.or(route_context.as_deref());
|
||||
let minimal_welcome = response
|
||||
.extensions()
|
||||
.get::<crate::pages::MinimalWelcome>()
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn private_tempdir() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
dir.path(),
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
crate::auth::prepare_directory(dir.path()).unwrap();
|
||||
let database = dir.path().join("config.sqlite3");
|
||||
crate::auth::prepare_database(&database).unwrap();
|
||||
@@ -47,7 +61,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_ciphertext_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
crate::auth::prepare_directory(dir.path()).unwrap();
|
||||
let database = dir.path().join("config.sqlite3");
|
||||
crate::auth::prepare_database(&database).unwrap();
|
||||
|
||||
@@ -67,7 +67,7 @@ impl SendfileSlots {
|
||||
return None;
|
||||
}
|
||||
let mut data = vec![0u8; FRAME_LEN];
|
||||
for chunk in data.chunks_exact_mut(TAG_LEN) {
|
||||
for chunk in data.as_chunks_mut::<TAG_LEN>().0 {
|
||||
chunk.copy_from_slice(&tag);
|
||||
}
|
||||
registry.slots.push(Slot {
|
||||
@@ -357,7 +357,7 @@ mod tests {
|
||||
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)
|
||||
[0, 1, 2, 3].repeat(25_000)
|
||||
} else {
|
||||
vec![9u8; 100_003]
|
||||
};
|
||||
@@ -411,7 +411,7 @@ mod tests {
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
let mut wanted = Vec::new();
|
||||
for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] {
|
||||
for expected in [[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",
|
||||
|
||||
@@ -124,11 +124,13 @@ mod tests {
|
||||
https: Some("0.0.0.0:8443".parse().unwrap()),
|
||||
administration: None,
|
||||
};
|
||||
let mut config = crate::config::Config::default();
|
||||
config.listeners = Listeners::from_startup(
|
||||
"0.0.0.0:80".parse().unwrap(),
|
||||
Some("0.0.0.0:443".parse().unwrap()),
|
||||
);
|
||||
let mut config = crate::config::Config {
|
||||
listeners: Listeners::from_startup(
|
||||
"0.0.0.0:80".parse().unwrap(),
|
||||
Some("0.0.0.0:443".parse().unwrap()),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
config.listeners.endpoints[0]
|
||||
.additional_addresses
|
||||
.push("127.0.0.1:80".parse().unwrap());
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ pub fn validate(
|
||||
);
|
||||
ensure!(
|
||||
!host.listen.ip().is_multicast()
|
||||
&& (host.backends.len() > 0 || !host.upstream.ip().is_multicast()),
|
||||
&& (!host.backends.is_empty() || !host.upstream.ip().is_multicast()),
|
||||
"stream listener/upstream must not use multicast addresses"
|
||||
);
|
||||
ensure!(
|
||||
|
||||
+4
-2
@@ -81,12 +81,14 @@ impl HealthCheck {
|
||||
|
||||
fn decode_probe_hex(value: &str) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
value.len() <= 256 && value.len() % 2 == 0 && value.is_ascii(),
|
||||
value.len() <= 256 && value.len().is_multiple_of(2) && value.is_ascii(),
|
||||
"invalid probe hex length"
|
||||
);
|
||||
value
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let hex = |byte: u8| -> Option<u8> {
|
||||
match byte {
|
||||
|
||||
+11
-11
@@ -55,31 +55,31 @@ pub(crate) struct Counters {
|
||||
pub(crate) fn platform_name() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return "Linux";
|
||||
"Linux"
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
return "FreeBSD";
|
||||
"FreeBSD"
|
||||
}
|
||||
#[cfg(target_os = "openbsd")]
|
||||
{
|
||||
return "OpenBSD";
|
||||
"OpenBSD"
|
||||
}
|
||||
#[cfg(target_os = "netbsd")]
|
||||
{
|
||||
return "NetBSD";
|
||||
"NetBSD"
|
||||
}
|
||||
#[cfg(target_os = "dragonfly")]
|
||||
{
|
||||
return "DragonFly BSD";
|
||||
"DragonFly BSD"
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return "macOS";
|
||||
"macOS"
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return "Windows";
|
||||
"Windows"
|
||||
}
|
||||
#[cfg(all(
|
||||
target_family = "unix",
|
||||
@@ -93,7 +93,7 @@ pub(crate) fn platform_name() -> &'static str {
|
||||
))
|
||||
))]
|
||||
{
|
||||
return "Unix";
|
||||
"Unix"
|
||||
}
|
||||
#[cfg(all(not(target_family = "unix"), not(target_os = "windows")))]
|
||||
{
|
||||
@@ -104,7 +104,7 @@ pub(crate) fn platform_name() -> &'static str {
|
||||
fn source_name() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return "systemstat+sysinfo+metrics_process+linux_procfs";
|
||||
"systemstat+sysinfo+metrics_process+linux_procfs"
|
||||
}
|
||||
#[cfg(any(
|
||||
target_os = "freebsd",
|
||||
@@ -113,11 +113,11 @@ fn source_name() -> &'static str {
|
||||
target_os = "windows"
|
||||
))]
|
||||
{
|
||||
return "systemstat+sysinfo+metrics_process";
|
||||
"systemstat+sysinfo+metrics_process"
|
||||
}
|
||||
#[cfg(any(target_os = "netbsd", target_os = "dragonfly"))]
|
||||
{
|
||||
return "systemstat+sysinfo+metrics_process(dummy)";
|
||||
"systemstat+sysinfo+metrics_process(dummy)"
|
||||
}
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
|
||||
@@ -289,6 +289,7 @@ impl Compiled {
|
||||
})?;
|
||||
target.as_str().parse().ok()
|
||||
}
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
|
||||
@@ -286,7 +286,7 @@ pub fn for_all_frontends(
|
||||
}
|
||||
for cert in &mut result.certificates {
|
||||
cert.hosts.retain(|host| {
|
||||
!assignments.contains_key(&host.to_ascii_lowercase().trim_end_matches('.').to_string())
|
||||
!assignments.contains_key(host.to_ascii_lowercase().trim_end_matches('.'))
|
||||
});
|
||||
for (host, id) in &assignments {
|
||||
if id == &cert.id {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = private_tempdir();
|
||||
let cert_dir = root.path().join("rustproxy-certs");
|
||||
fs::create_dir(&cert_dir).unwrap();
|
||||
crate::auth::prepare_directory(&cert_dir).unwrap();
|
||||
initialize_default(&cert_dir).unwrap();
|
||||
|
||||
let archive = root.path().join("archive/example.test");
|
||||
@@ -100,7 +100,7 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
|
||||
fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_or_world_read() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = private_tempdir();
|
||||
let key = root.path().join("external.key");
|
||||
fs::write(&key, b"not-a-real-key").unwrap();
|
||||
|
||||
@@ -119,7 +119,7 @@ fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_
|
||||
fn external_paths_reject_writable_non_sticky_parent_directories() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = private_tempdir();
|
||||
let unsafe_parent = root.path().join("unsafe");
|
||||
fs::create_dir(&unsafe_parent).unwrap();
|
||||
fs::set_permissions(&unsafe_parent, fs::Permissions::from_mode(0o770)).unwrap();
|
||||
@@ -133,7 +133,7 @@ fn external_paths_reject_writable_non_sticky_parent_directories() {
|
||||
fn certificate_input_rejects_fifo_without_waiting_for_a_writer() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = private_tempdir();
|
||||
let fifo = root.path().join("certificate.pipe");
|
||||
let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
|
||||
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
|
||||
|
||||
@@ -85,10 +85,12 @@ pub struct CertificateMetadata {
|
||||
fn der_text(tag: u8, raw: &[u8]) -> Option<String> {
|
||||
let value = match tag {
|
||||
0x0c | 0x13 | 0x14 | 0x16 => std::str::from_utf8(raw).ok()?.to_owned(),
|
||||
0x1e if raw.len() % 2 == 0 => {
|
||||
0x1e if raw.len().is_multiple_of(2) => {
|
||||
let units: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| u16::from_be_bytes(*chunk))
|
||||
.collect();
|
||||
String::from_utf16(&units).ok()?
|
||||
}
|
||||
|
||||
+29
-15
@@ -1,5 +1,20 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn private_tempdir() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
dir.path(),
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_removal_requires_prior_unassignment_including_administration() {
|
||||
let certificate = Certificate {
|
||||
@@ -67,7 +82,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn external_pair_stages_generation_and_rolls_back_cleanly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
let generated = rcgen::generate_simple_self_signed(vec![
|
||||
"shared.example.test".into(),
|
||||
"www.shared.example.test".into(),
|
||||
@@ -96,7 +111,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn external_pair_reconcile_is_idempotent_and_repairs_missing_generation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
let generated =
|
||||
rcgen::generate_simple_self_signed(vec!["reconcile.example.test".into()]).unwrap();
|
||||
let certificate = generated.cert.pem();
|
||||
@@ -166,7 +181,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_is_persistent_and_tls_advertises_both_protocols() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
let before = fs::read(dir.path().join("default.pem")).unwrap();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
@@ -293,7 +308,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pqc_builds_both_tcp_tls_and_quic_configs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.policy.post_quantum = true;
|
||||
@@ -308,7 +323,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn administration_tls_supports_default_and_selected_certificate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
generate(dir.path(), "panel", vec!["panel.localhost".into()]).unwrap();
|
||||
let settings = Settings {
|
||||
@@ -347,7 +362,7 @@ mod tests {
|
||||
b.signing_key.serialize_pem().as_bytes()
|
||||
)
|
||||
.is_err());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
generate(dir.path(), "a", vec!["a.localhost".into()]).unwrap();
|
||||
let mut settings = Settings {
|
||||
@@ -370,7 +385,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn named_generation_never_overwrites_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
generate(dir.path(), "app", vec!["app.localhost".into()]).unwrap();
|
||||
let before = fs::read(dir.path().join("app.key")).unwrap();
|
||||
assert!(generate(dir.path(), "app", vec!["other.localhost".into()]).is_err());
|
||||
@@ -378,7 +393,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn certificate_hostname_bindings_are_not_artificially_capped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
let hosts = (0..40)
|
||||
.map(|index| format!("host-{index}.example.test"))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -408,7 +423,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn uploads_validate_key_and_names_and_store_only_private_immutable_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
let pair = rcgen::generate_simple_self_signed(vec![
|
||||
"app.localhost".into(),
|
||||
@@ -481,7 +496,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_certificate_coverage_can_be_read_from_existing_pem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
let pair = rcgen::generate_simple_self_signed(vec![
|
||||
"app.example.org".into(),
|
||||
"*.example.org".into(),
|
||||
@@ -496,7 +511,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn mismatched_named_certificate_requires_explicit_per_vhost_consent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = private_tempdir();
|
||||
initialize_default(dir.path()).unwrap();
|
||||
generate(dir.path(), "named", vec!["right.example.test".into()]).unwrap();
|
||||
let settings = Settings {
|
||||
@@ -511,13 +526,12 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let vhost = &mut config.vhosts[0];
|
||||
vhost.certificate_id = Some("named".into());
|
||||
vhost.frontend.hosts = vec!["other.example.test".into()];
|
||||
config.vhosts[0].certificate_id = Some("named".into());
|
||||
config.vhosts[0].frontend.hosts = vec!["other.example.test".into()];
|
||||
let blocked = for_vhosts(&settings, &config.vhosts).unwrap();
|
||||
assert!(compile(&blocked, Some(dir.path())).is_err());
|
||||
assert!(blocked.forced_mismatches.is_empty());
|
||||
vhost.allow_certificate_mismatch = true;
|
||||
config.vhosts[0].allow_certificate_mismatch = true;
|
||||
let forced = for_vhosts(&settings, &config.vhosts).unwrap();
|
||||
assert!(forced
|
||||
.forced_mismatches
|
||||
|
||||
+1
-11
@@ -17,7 +17,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
const MAX_PEM: u64 = 131_072;
|
||||
const MAX_CERTIFICATE_PATH: usize = 4096;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -28,16 +28,6 @@ pub struct Settings {
|
||||
#[serde(skip)]
|
||||
pub forced_mismatches: HashSet<(String, String)>,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_certificate: None,
|
||||
certificates: Vec::new(),
|
||||
policy: ProtocolPolicy::default(),
|
||||
forced_mismatches: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.default_certificate.is_none()
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
let mut previous = '\0';
|
||||
while let Some(next) = chars.next() {
|
||||
for next in chars.by_ref() {
|
||||
if previous == '*' && next == '/' {
|
||||
break;
|
||||
}
|
||||
|
||||
+1
-14
@@ -140,7 +140,7 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
pub enabled: bool,
|
||||
@@ -151,19 +151,6 @@ pub struct Overrides {
|
||||
pub json: Option<bool>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
}
|
||||
impl Default for Overrides {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
javascript: None,
|
||||
css: None,
|
||||
html: None,
|
||||
svg: None,
|
||||
json: None,
|
||||
extensions: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Overrides {
|
||||
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
||||
let mut resolved = global.clone();
|
||||
|
||||
@@ -61,11 +61,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resource_preset_metadata_round_trips() {
|
||||
let mut settings = Settings::default();
|
||||
settings.preset = Some(Preset::Uber);
|
||||
settings.memory_budget_bytes = 1024 * 1024 * 1024;
|
||||
settings.max_body_bytes = 32 * 1024 * 1024;
|
||||
settings.max_inflight = 128;
|
||||
let settings = Settings {
|
||||
preset: Some(Preset::Uber),
|
||||
memory_budget_bytes: 1024 * 1024 * 1024,
|
||||
max_body_bytes: 32 * 1024 * 1024,
|
||||
max_inflight: 128,
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(encoded["preset"], "uber");
|
||||
let decoded: Settings = serde_json::from_value(encoded).unwrap();
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@ impl Service<Uri> for TimedConnector {
|
||||
let timeout = self.timeout;
|
||||
Box::pin(async move {
|
||||
match tokio::time::timeout(timeout, future).await {
|
||||
Ok(result) => result.map_err(Into::into),
|
||||
Ok(result) => result,
|
||||
Err(error) => Err(Box::new(error) as BoxError),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,9 +29,11 @@ mod tests {
|
||||
#[test]
|
||||
fn generation_recycles_after_request_budget() {
|
||||
let backend = backend("rotation-test", 18081);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 2;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let keepalive = crate::balance::Keepalive {
|
||||
pool_recycle_requests: 2,
|
||||
pool_recycle_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
@@ -52,9 +54,11 @@ mod tests {
|
||||
#[test]
|
||||
fn generation_recycles_after_age_without_request() {
|
||||
let backend = backend("rotation-age-test", 18083);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 0;
|
||||
keepalive.pool_recycle_seconds = 1;
|
||||
let keepalive = crate::balance::Keepalive {
|
||||
pool_recycle_requests: 0,
|
||||
pool_recycle_seconds: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
@@ -78,9 +82,11 @@ mod tests {
|
||||
#[test]
|
||||
fn generation_recycling_can_be_disabled() {
|
||||
let backend = backend("rotation-off", 18082);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 0;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let keepalive = crate::balance::Keepalive {
|
||||
pool_recycle_requests: 0,
|
||||
pool_recycle_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
@@ -96,9 +102,11 @@ mod tests {
|
||||
#[test]
|
||||
fn request_recycling_still_has_exact_threshold_after_clock_sampling() {
|
||||
let backend = backend("requests-boundary", 18085);
|
||||
let mut keepalive = crate::balance::Keepalive::default();
|
||||
keepalive.pool_recycle_requests = 1_000;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let keepalive = crate::balance::Keepalive {
|
||||
pool_recycle_requests: 1_000,
|
||||
pool_recycle_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let client = client_with_keepalive(
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
|
||||
+2
-6
@@ -273,17 +273,13 @@ impl Default for LocationResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Source {
|
||||
#[default]
|
||||
ReverseProxy,
|
||||
WebServer { web_service_id: String },
|
||||
}
|
||||
impl Default for Source {
|
||||
fn default() -> Self {
|
||||
Self::ReverseProxy
|
||||
}
|
||||
}
|
||||
impl Source {
|
||||
pub fn is_reverse_proxy(&self) -> bool {
|
||||
matches!(self, Self::ReverseProxy)
|
||||
|
||||
+7
-5
@@ -21,11 +21,13 @@ mod default_listener_tests {
|
||||
}
|
||||
#[test]
|
||||
fn listener_plan_inherits_legacy_h2_limits_and_prefers_listener_override() {
|
||||
let mut config = Config::default();
|
||||
config.http2 = crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 111,
|
||||
max_connection_lifetime_seconds: 222,
|
||||
drain_timeout_seconds: 33,
|
||||
let mut config = Config {
|
||||
http2: crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 111,
|
||||
max_connection_lifetime_seconds: 222,
|
||||
drain_timeout_seconds: 33,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
config.listeners.endpoints = serde_json::from_value(json!([
|
||||
{
|
||||
|
||||
@@ -455,7 +455,7 @@ pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result<Vec<L
|
||||
.listeners
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|e| e.enabled && e.bindings().into_iter().any(|value| same(value)))
|
||||
.filter(|e| e.enabled && e.bindings().into_iter().any(&same))
|
||||
{
|
||||
owners.push(format!("listener '{}' ({})", item.id, item.name));
|
||||
}
|
||||
|
||||
@@ -300,7 +300,6 @@ pub(super) fn parse_cgi_headers(value: &[u8]) -> Result<(StatusCode, HeaderMap)>
|
||||
.context("PHP-FPM returned a malformed CGI header")?;
|
||||
if name.eq_ignore_ascii_case("status") {
|
||||
let code = value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.context("PHP-FPM returned an empty Status header")?
|
||||
|
||||
@@ -217,24 +217,24 @@ fn matching_scopes<'a>(compiled: &'a Compiled, path: &str) -> Vec<&'a Scope> {
|
||||
|
||||
pub fn denied(compiled: Option<&Compiled>, path: &str) -> bool {
|
||||
let Some(compiled) = compiled else { return false; };
|
||||
matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).last().unwrap_or(false)
|
||||
matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).next_back().unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn index_files<'a>(compiled: Option<&'a Compiled>, directory_components: &[String], fallback: &'a [String]) -> &'a [String] {
|
||||
let Some(compiled) = compiled else { return fallback; };
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).last().unwrap_or(fallback)
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).next_back().unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn directory_listing(compiled: Option<&Compiled>, directory_components: &[String], fallback: bool) -> bool {
|
||||
let Some(compiled) = compiled else { return fallback; };
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).last().unwrap_or(fallback)
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).next_back().unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn mime_type(compiled: Option<&Compiled>, path: &Path) -> Option<HeaderValue> {
|
||||
let compiled = compiled?;
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let path_text = path.to_string_lossy();
|
||||
matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).last()
|
||||
matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).next_back()
|
||||
}
|
||||
|
||||
pub fn php_extension(compiled: Option<&Compiled>, path: &Path) -> bool {
|
||||
@@ -345,8 +345,8 @@ async fn condition_matches(condition: &RewriteCondition, service: &CompiledServi
|
||||
ConditionMatcher::Regex { regex, negate } => {
|
||||
if let Some(captures) = regex.captures(&value) {
|
||||
let values = captures.iter().map(|value| value.map(|v| v.as_str().to_owned()).unwrap_or_default()).collect::<Vec<_>>();
|
||||
(if *negate { false } else { true }, if *negate { Vec::new() } else { values })
|
||||
} else { (if *negate { true } else { false }, Vec::new()) }
|
||||
(!*negate, if *negate { Vec::new() } else { values })
|
||||
} else { (*negate, Vec::new()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ mod security;
|
||||
mod serve;
|
||||
|
||||
pub use config::{
|
||||
CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, Service,
|
||||
SymlinkPolicy, SendfileMode,
|
||||
CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule,
|
||||
SendfileMode, Service, SymlinkPolicy,
|
||||
};
|
||||
pub use htaccess::Config as HtaccessConfig;
|
||||
pub use request::RequestContext;
|
||||
|
||||
@@ -217,13 +217,11 @@ async fn file_response_inner(
|
||||
));
|
||||
return response;
|
||||
}
|
||||
if start != 0 {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server seek failed\n",
|
||||
);
|
||||
}
|
||||
if start != 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server seek failed\n",
|
||||
);
|
||||
}
|
||||
let stream = ReaderStream::with_capacity(file.take(body_length), stream_capacity);
|
||||
*response.body_mut() = Body::new(file_body::FileBody::new(stream, body_length));
|
||||
|
||||
@@ -412,10 +412,7 @@ async fn too_many_byte_ranges_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let ranges = std::iter::repeat("0-0")
|
||||
.take(17)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let ranges = std::iter::repeat_n("0-0", 17).collect::<Vec<_>>().join(",");
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
|
||||
Reference in new issue
Block a user