This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 23:42:58 +01:00
1 parent 2c03fcf248
commit d3b492874b
95 files changed
+1628 -743

No files matched your search

+13 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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),
);
}
+2 -2
View File
@@ -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,
+6 -4
View 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;
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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": [
+23 -17
View File
@@ -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
View File
@@ -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!(
+27 -6
View File
@@ -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
View File
@@ -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)]
+7 -6
View File
@@ -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 -5
View File
@@ -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()
+2 -6
View File
@@ -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 {
+5 -5
View File
@@ -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))]
{
+8 -7
View File
@@ -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
}
+2 -1
View File
@@ -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,
+22 -14
View File
@@ -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());
+4 -2
View File
@@ -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());
+1 -3
View File
@@ -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)
+1 -3
View File
@@ -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,
+1 -3
View File
@@ -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);
+1
View File
@@ -296,6 +296,7 @@ struct ColdDraft {
_memory_lease: TransientLease,
}
#[allow(clippy::large_enum_variant)]
enum ColdCommand {
Store(ColdDraft),
Delete(Vec<PathBuf>),
+2 -2
View File
@@ -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"
+2 -6
View File
@@ -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")]
+5 -5
View File
@@ -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)]
+6 -4
View File
@@ -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
View File
@@ -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(),
+7 -8
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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")))]
{
+1 -1
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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)?;
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+12 -6
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -49,6 +49,7 @@ pub struct SinkStats {
pub errors: u64,
}
#[allow(clippy::large_enum_variant)]
enum HistoryEntry {
Owned(LogEntry),
Shared(Arc<LogEntry>),
+2 -6
View File
@@ -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
View File
@@ -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!(
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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()),
+1
View File
@@ -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,
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
+50 -2
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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>()
+16 -2
View File
@@ -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();
+3 -3
View File
@@ -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",
+7 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -289,6 +289,7 @@ impl Compiled {
})?;
target.as_str().parse().ok()
}
#[allow(clippy::result_large_err)]
pub async fn authorize(
&self,
incoming: &HeaderMap,
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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);
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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();
+7 -5
View File
@@ -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
View File
@@ -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),
}
})
+20 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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!([
{
+1 -1
View File
@@ -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));
}
-1
View File
@@ -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")?
+6 -6
View File
@@ -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()) }
}
}
}
+2 -2
View File
@@ -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;
+5 -7
View File
@@ -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));
+1 -4
View File
@@ -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()