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"));
|
||||
|
||||
Reference in new issue
Block a user