This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 13:56:30 +02:00
1 parent 95c55da2ee
commit d942c1a35d
42 files changed
+1265 -109

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
// Normalize the browser-facing HTTPS origin independently of the optional
// additional DNS allowlist. It is never inferred from forwarding headers.
fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
ensure!(
public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')),
"administration public_origin must be a single HTTPS origin without userinfo, query or fragment"
);
let uri = public_origin.parse::<http::Uri>()
.context("invalid administration public_origin")?;
ensure!(
uri.scheme_str() == Some("https")
&& uri.query().is_none()
&& matches!(uri.path(), "" | "/"),
"administration public_origin must be HTTPS without a path or query"
);
let authority = uri.authority()
.context("administration public_origin requires an authority")?;
// This setting is for a named HTTPS origin. Use the existing direct
// listener/Host policy for IP addresses and localhost.
let host = crate::config::normalize_host(authority.host())?;
ensure!(
host.parse::<std::net::IpAddr>().is_err() && host != "localhost",
"administration public_origin must name a DNS hostname"
);
let port = if authority.as_str().contains(':') {
let port = authority.port_u16()
.context("invalid administration public_origin port")?;
ensure!(port > 0, "administration public_origin port must be nonzero");
format!(":{port}")
} else {
String::new()
};
Ok(format!("https://{host}{port}"))
}
impl Administration {
/// The origin is validated and normalized before it is stored, so this
/// returns only its DNS hostname (never a forwarded or client-supplied host).
pub fn public_origin_host(&self) -> Option<&str> {
self.public_origin.as_deref()
.and_then(|url| url.strip_prefix("https://"))
.and_then(|authority| authority.split(':').next())
}
/// Used only by the explicitly opted-in panel session-cookie passthrough.
pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool {
self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host))
|| self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host))
}
/// Build the access-control allowlist for the active/desired panel state.
/// Never mutate the saved configuration or accumulate stale hosts on edits.
pub fn include_public_origin_host(&mut self) {
if let Some(host) = self.public_origin_host().map(str::to_owned) {
if !self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(&host)) {
self.allowed_hosts.push(host);
}
}
}
}
+61
View File
@@ -256,6 +256,67 @@ mod tests {
admin.allowed_hosts = vec!["panel.example.com".into(), "PANEL.EXAMPLE.COM.".into()];
assert!(admin.validate(&crate::tls::Settings::default()).is_err());
}
#[test]
fn administration_public_origin_is_https_explicit_and_restart_bound() {
let tls = crate::tls::Settings::default();
let mut admin = Administration {
public_origin: Some("https://Panel.Example.TEST:8443/".into()),
..Administration::default()
};
admin.validate(&tls).unwrap();
assert_eq!(admin.public_origin.as_deref(), Some("https://panel.example.test:8443"));
assert_eq!(admin.public_origin_host(), Some("panel.example.test"));
assert!(admin.allowed_hosts.is_empty(), "pin must not be stored twice");
assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST"));
assert!(!admin.explicitly_allowed_panel_host("other.example.test"));
let mut runtime = admin.clone();
runtime.include_public_origin_host();
runtime.include_public_origin_host();
assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]);
assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged");
let active = Administration { public_origin: None, ..admin.clone() };
assert!(admin.restart_differs(&active));
let mut legacy: Administration = serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap();
legacy.validate(&tls).unwrap();
assert_eq!(legacy.public_origin, None);
assert!(legacy.explicitly_allowed_panel_host("panel.example.test"));
let mut old_pin: Administration = serde_json::from_str(
r#"{"public_origin":"https://panel.example.test","allowed_hosts":["PANEL.Example.TEST.","other.example.test"]}"#
).unwrap();
old_pin.validate(&tls).unwrap();
assert_eq!(old_pin.allowed_hosts, vec!["other.example.test".to_owned()]);
assert!(old_pin.explicitly_allowed_panel_host("panel.example.test"));
assert!(old_pin.explicitly_allowed_panel_host("other.example.test"));
// Additional hosts are independent: editing/removing the pinned origin
// must not leave its former hostname in the saved strict list.
let mut changed = admin.clone();
changed.public_origin = Some("https://other.example.test".into());
changed.validate(&tls).unwrap();
assert_eq!(changed.public_origin_host(), Some("other.example.test"));
assert!(!changed.explicitly_allowed_panel_host("panel.example.test"));
let mut opted_out = changed;
opted_out.public_origin = None;
opted_out.validate(&tls).unwrap();
assert!(opted_out.allowed_hosts.is_empty());
for invalid in [
"http://panel.example.test",
"https://*.example.test",
"https://localhost",
"https://127.0.0.1",
"https://panel.example.test/a",
"https://panel.example.test/?x=1",
"https://admin@panel.example.test",
"https://panel.example.test#frag",
"https://panel.example.test:0",
"https://panel.example.test:99999",
"https://panel.example.test,https://evil.example.test",
] {
let mut candidate = admin.clone();
candidate.public_origin = Some(invalid.into());
assert!(candidate.validate(&tls).is_err(), "must reject {invalid}");
}
}
#[test]
fn administration_hostname_count_is_not_artificially_capped() {
let mut admin = Administration {