v0.31.4
This commit is contained in:
1 parent
95c55da2ee
commit
d942c1a35d
42 files changed
+1265
-109
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user