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

+66
View File
@@ -229,6 +229,72 @@ fn external_admin_authority(
}
}
// In pinned-origin mode accept only bounded, consistent proxy-hop metadata:
// the first hop must report browser HTTPS, later internal hops may use HTTP.
// We still do NOT pick a trusted scheme from these values; the explicit pin
// defines the panel origin. A clear public HTTP or unknown value fails closed.
fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
let mut hops = 0;
for value in headers.get_all("x-forwarded-proto").iter() {
let Ok(value) = value.to_str() else { return false; };
for part in value.split(',') {
hops += 1;
if hops > 8 { return false; }
let scheme = part.trim();
if (hops == 1 && scheme != "https")
|| !matches!(scheme, "http" | "https")
{
return false;
}
}
}
true
}
/// A configured panel URL is authoritative only for an explicitly trusted
/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately
/// ignored: conflicting hop values such as "https, http" are not evidence of
/// the browser scheme. The public URL comes from administrator configuration,
/// not from a user-controlled header.
fn configured_admin_public_authority(
state: &App,
headers: &HeaderMap,
peer: Option<PeerAddr>,
raw_host: &str,
transport: Transport,
) -> Result<Option<String>, ()> {
let Some(origin) = state.active_administration.public_origin.as_deref() else {
return Ok(None);
};
if !trusted_admin_proxy(state, peer) {
// Never apply the pin to arbitrary direct/untrusted connections.
return Ok(None);
}
if !admin_host_allowed(
raw_host,
state.policy.admin_addr,
transport.tls,
&state.active_administration.allowed_hosts,
) {
return Err(());
}
let authority = origin.strip_prefix("https://").ok_or(())?;
if !pinned_admin_proto_compatible(headers) {
return Err(());
}
if headers.contains_key("x-forwarded-host") {
let forwarded = single_header(headers, "x-forwarded-host").ok_or(())?;
if forwarded.contains('@') {
return Err(());
}
let candidate = forwarded.parse::<http::uri::Authority>().map_err(|_| ())?;
if !authority_matches_origin(&candidate, authority, "https") {
return Err(());
}
}
Ok(Some(authority.to_owned()))
}
fn external_admin_scheme(
state: &App,
headers: &HeaderMap,
+1
View File
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
),
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
+8 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.3",
"version": "0.31.4",
"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."
},
"servers": [
@@ -764,7 +764,13 @@
"type": "array",
"items": {
"type": "string"
}
},
"description": "Optional additional panel DNS hostnames. The hostname of public_origin is implicitly allowed and must not be duplicated."
},
"public_origin": {
"type": "string",
"nullable": true,
"description": "Optional administrator-pinned browser HTTPS origin behind a trusted reverse proxy. Its DNS hostname is automatically allowed without adding it to allowed_hosts. Restart required. Does not affect public proxy traffic."
},
"session_days": {
"type": "integer",
+29 -7
View File
@@ -117,17 +117,39 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
}
// Pinned browser origin is panel-only. Certificate automation using API
// credentials keeps the original forwarding/TLS authorization semantics.
let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER)
|| (headers.contains_key(AUTHORIZATION)
&& certificate_api_operation(request.method(), request.uri().path()).is_some());
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
configured_admin_public_authority(&state, headers, peer, &raw_host, transport)
};
let external_authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
let pinned_authority = match pinned_authority_result {
Ok(value) => value,
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"),
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")),
trusted_host, trusted_proto),
};
let (scheme, external_authority) = if let Some(authority) = pinned_authority {
// HTTPS is a trusted, administrator-pinned identity here. Do not select
// any list element of an ambiguous forwarded-proto chain.
("https", authority)
} else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
}
};
let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
(scheme, authority)
};
if headers.get_all(ORIGIN).iter().count() > 1
|| headers.get(ORIGIN).is_some_and(|h| {
h.to_str()
+3 -2
View File
@@ -123,10 +123,11 @@ fn status_payload(state: &App) -> Value {
desired_admin
.allowed_hosts
.extend(certificate.hosts.clone());
desired_admin.allowed_hosts.sort();
desired_admin.allowed_hosts.dedup();
}
}
desired_admin.include_public_origin_host();
desired_admin.allowed_hosts.sort();
desired_admin.allowed_hosts.dedup();
let desired_admin_certificate_files = desired_admin
.certificate_id
.as_deref()
+1
View File
@@ -9,4 +9,5 @@ mod tests {
include!("tests/part_08.rs");
include!("tests/part_09.rs");
include!("tests/part_10.rs");
include!("tests/part_11.rs");
}
+142
View File
@@ -0,0 +1,142 @@
#[test]
fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() {
let mut headers = HeaderMap::new();
assert!(pinned_admin_proto_compatible(&headers));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(pinned_admin_proto_compatible(&headers));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
assert!(pinned_admin_proto_compatible(&headers));
headers.append("x-forwarded-proto", HeaderValue::from_static("http"));
assert!(pinned_admin_proto_compatible(&headers));
for bad in ["http", "http, https", "https, ftp", "https,", ",https", "HTTPS", "https, http, http, http, http, http, http, http, http"] {
headers.remove("x-forwarded-proto");
headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap());
assert!(!pinned_admin_proto_compatible(&headers), "must reject {bad}");
}
}
#[tokio::test]
async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() {
let mut config = Config::default();
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
// Without an explicit origin pin, 0.31.3 rejected this session GET as
// external_scheme although the browser address remained correct.
let result = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.header("sec-fetch-site", "same-origin")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::OK);
let session_cookie = result.headers().get_all(SET_COOKIE).iter()
.filter_map(|v| v.to_str().ok()).find(|v| v.starts_with(auth::COOKIE_NAME)).unwrap();
assert!(session_cookie.contains("; Secure"));
// The incoming admin Host may itself be the pinned public DNS name.
// There is no separate allowed_hosts entry in the saved configuration.
let pinned_host = Request::builder().uri("/api/auth/session")
.header("host", "panel.example.test")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45003".parse().unwrap()))
.body(Body::empty()).unwrap();
assert_eq!(router.clone().oneshot(pinned_host).await.unwrap().status(), StatusCode::OK);
// Explicit Origin, CSRF token and app request marker remain mandatory
// for a state-changing request even when XFP contains multiple hops.
let unauthorized = router.clone().oneshot(request("/api/auth/logout")
.method("POST").header("cookie", &cookie)
.header("origin", "https://panel.example.test")
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45001".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN);
let authorized = router.oneshot(request("/api/auth/logout")
.method("POST").header("cookie", &cookie)
.header("origin", "https://panel.example.test")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", csrf)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45002".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
store.close().await;
}
#[tokio::test]
async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() {
let mut config = Config::default();
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for (xfh, xfp, origin, fetch_site) in [
("evil.example.test", "https, http", "https://panel.example.test", "same-origin"),
("other@panel.example.test", "https, http", "https://panel.example.test", "same-origin"),
("panel.example.test", "http", "https://panel.example.test", "same-origin"),
("panel.example.test", "http, https", "https://panel.example.test", "same-origin"),
("panel.example.test", "https, http", "https://evil.example.test", "same-origin"),
("panel.example.test", "https, http", "null", "same-origin"),
("panel.example.test", "https, http", "https://panel.example.test", "cross-site"),
] {
let result = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", xfh)
.header("x-forwarded-proto", xfp)
.header("origin", origin)
.header("sec-fetch-site", fetch_site)
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::FORBIDDEN,
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
}
// Credential-bearing certificate automation does not inherit the
// browser-origin override, even through the same trusted admin socket.
let automation = router.clone().oneshot(request("/api/certificates/deploy")
.method("POST")
.header(CERTIFICATE_API_KEY_HEADER, "svc-key")
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(automation.status(), StatusCode::FORBIDDEN);
let untrusted = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.header("origin", "https://panel.example.test")
.extension(PeerAddr("192.0.2.42:44000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(untrusted.status(), StatusCode::FORBIDDEN);
let mut duplicated = request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap();
duplicated.headers_mut().append("x-forwarded-host", "panel.example.test".parse().unwrap());
assert_eq!(router.clone().oneshot(duplicated).await.unwrap().status(), StatusCode::FORBIDDEN);
store.close().await;
}
#[tokio::test]
async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() {
let mut config = Config::default();
config.administration.allowed_hosts = vec!["panel.example.test".into()];
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
let result = router.oneshot(request("/api/auth/session")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::FORBIDDEN);
store.close().await;
}
+11 -3
View File
@@ -2734,9 +2734,17 @@
data-i18n="administrationHttps">Enable HTTPS for administration</span></label>
<label><span data-i18n="administrationCertificate">Certificate</span><select
id="admin-certificate"></select></label>
<label><span data-i18n="administrationHosts">Allowed DNS hostnames</span><textarea
id="admin-hosts" rows="4" spellcheck="false"
placeholder="admin.example.com"></textarea></label>
<label><span data-i18n="administrationPublicOrigin">Public panel HTTPS origin (optional)</span><input
id="admin-public-origin" maxlength="255" placeholder="https://panel.example.com"
type="url" spellcheck="false" autocomplete="off" /></label>
<p class="fine" data-i18n="administrationPublicOriginHelp">When behind a trusted reverse proxy, enter the panel's external HTTPS address once. Its DNS name is automatically allowed. A restart is required.</p>
<details id="admin-extra-hosts-details">
<summary data-i18n="administrationHostsAdvanced">Advanced: additional allowed DNS hostnames</summary>
<label><span data-i18n="administrationHosts">Additional allowed DNS hostnames</span><textarea
id="admin-hosts" rows="4" spellcheck="false"
placeholder="other-admin.example.com"></textarea></label>
<p class="fine" data-i18n="administrationHostsHelp">Only extra hostnames belong here. The hostname from the public panel URL is automatically included; without a public URL this remains an independent strict allowlist.</p>
</details>
<label><span data-i18n="administrationSessionDays">Administrator session lifetime
(days)</span><input id="admin-session-days" min="7" max="30" step="1"
type="number" required="" /></label>
+18 -2
View File
@@ -8,6 +8,19 @@
? "logging"
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
const tabAliases = { http2: "connections" };
// Keep the public panel hostname implicit. Legacy configurations may have
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
function pinnedAdminHostname(value) {
try {
const url = new URL(value);
return url.protocol === "https:" ? url.hostname.toLowerCase().replace(/\.$/, "") : "";
} catch { return ""; }
}
function additionalAdminHosts(hosts, origin) {
const pinned = pinnedAdminHostname(origin);
return hosts.map(host => host.trim()).filter(host =>
host && (!pinned || host.replace(/\.$/, "").toLowerCase() !== pinned));
}
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
function applySettingsSearch() {
@@ -212,7 +225,10 @@ curl --fail --show-error --request POST \
$("admin-tls").checked = !!admin.tls;
const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate");
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
const extraHosts = additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin);
$("admin-hosts").value = extraHosts.join("\n");
$("admin-extra-hosts-details").open = extraHosts.length > 0;
$("admin-public-origin").value = admin.public_origin || "";
$("admin-session-days").value = Number(admin.session_days || 30);
for (const codec of ["zstd", "br", "gzip"]) $(`admin-compression-${codec}`).checked = admin.panel_compression?.[codec] !== false;
}
@@ -300,7 +316,7 @@ curl --fail --show-error --request POST \
}, "prometheus");
bindSettingsForm("administration-form", next => {
const locked = !!latestStatus?.administration?.listen_locked;
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $(`admin-compression-${codec}`).checked])) };
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: additionalAdminHosts($("admin-hosts").value.split(/[\n,]+/), $("admin-public-origin").value.trim()), public_origin: $("admin-public-origin").value.trim() || null, session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $(`admin-compression-${codec}`).checked])) };
}, "administration");
bindSettingsForm("tls-policy-form", next => {
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
+4 -5
View File
@@ -181,8 +181,9 @@ fn response_context(
}
/// Reserved administration-session cookies may cross the proxy boundary only
/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public
/// administration hostname, a backend that directly targets the configured
/// for the explicit self-admin deployment: HTTPS, a specifically allowed
/// administration hostname (extra DNS list or pinned public HTTPS origin),
/// a backend that directly targets the configured
/// administration listener, and the existing local-listener risk opt-in.
fn admin_session_passthrough(
snapshot: &crate::config::Snapshot,
@@ -196,9 +197,7 @@ fn admin_session_passthrough(
.envelope
.config
.administration
.allowed_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
.explicitly_allowed_panel_host(host)
}
fn apply_response_rules(
+3 -2
View File
@@ -145,10 +145,11 @@ impl App {
active_administration
.allowed_hosts
.extend(certificate.hosts.clone());
active_administration.allowed_hosts.sort();
active_administration.allowed_hosts.dedup();
}
}
active_administration.include_public_origin_host();
active_administration.allowed_hosts.sort();
active_administration.allowed_hosts.dedup();
let logs = Logs::start(log_settings, shutdown.clone());
let traffic = crate::traffic::Analytics::start(shutdown.clone());
let app = Arc::new(Self {
+18 -2
View File
@@ -1,5 +1,5 @@
//! Persisted listener intent and response controls. Listener changes require restart.
use anyhow::{ensure, Result};
use anyhow::{ensure, Context as _, Result};
use http::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use std::{net::SocketAddr, time::Duration};
@@ -195,8 +195,13 @@ pub struct Administration {
pub tls: bool,
/// None uses the application-managed default certificate pair.
pub certificate_id: Option<String>,
/// Optional strict DNS Host/Origin allowlist. Empty HTTPS lists accept valid DNS names.
/// Optional additional DNS Host allowlist. The hostname of public_origin is
/// included automatically at runtime, never duplicated in persisted config.
/// With no public_origin, an empty HTTPS list retains the legacy DNS policy.
pub allowed_hosts: Vec<String>,
/// Optional fixed browser-facing HTTPS origin for the panel behind a trusted
/// reverse proxy. It is NEVER inferred from forwarded headers.
pub public_origin: Option<String>,
/// Absolute browser-session lifetime. Existing sessions are bounded by the current value.
pub session_days: u16,
pub panel_compression: PanelCompression,
@@ -208,6 +213,7 @@ impl Default for Administration {
tls: false,
certificate_id: None,
allowed_hosts: Vec::new(),
public_origin: None,
session_days: 30,
panel_compression: PanelCompression::default(),
}
@@ -225,6 +231,7 @@ impl Administration {
|| self.tls != active.tls
|| self.certificate_id != active.certificate_id
|| self.allowed_hosts != active.allowed_hosts
|| self.public_origin != active.public_origin
}
pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> {
if let Some(addr) = self.listen {
@@ -263,6 +270,14 @@ impl Administration {
"duplicate administration hostname"
);
}
if let Some(public_origin) = self.public_origin.as_mut() {
*public_origin = normalize_public_admin_origin(public_origin)?;
}
// Accept old 0.31.4 saved configs, but never retain a second explicit
// copy of the pinned hostname in a validated or newly saved config.
if let Some(host) = self.public_origin_host().map(str::to_owned) {
self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host));
}
Ok(())
}
}
@@ -467,5 +482,6 @@ impl ServerHeader {
}
}
}
include!("settings/public_origin.rs");
#[cfg(test)]
include!("settings/tests.rs");
+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 {