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"));