This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 12:21:26 +02:00
1 parent 0facfe6f5c
commit 95c55da2ee
39 files changed
+715 -151

No files matched your search

+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.3.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
(
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.2",
"version": "0.31.3",
"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": [
+1
View File
@@ -35,6 +35,7 @@ fn admin_app_javascript() -> &'static str {
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
[
include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"),
include_str!("web/app/preferences.js"),
include_str!("web/app/config-history.js"),
+112 -3
View File
@@ -1,3 +1,108 @@
// Only log browser origin metadata: never cookies, CSRF tokens, Authorization,
// query strings, or untrusted forwarding data. Malformed origins are redacted.
fn diagnostic_admin_authority(value: &str) -> &str {
if value.len() <= 255 && !value.contains('@')
&& value.parse::<http::uri::Authority>().is_ok()
{
value
} else {
"(invalid)"
}
}
fn diagnostic_admin_origin(headers: &HeaderMap) -> &str {
if !headers.contains_key(ORIGIN) {
return "(absent)";
}
let Some(value) = single_header(headers, ORIGIN.as_str()) else {
return "(invalid)";
};
if value == "null" {
return "null";
}
if value.len() > 255 || value.contains('@') || value.contains('#') {
return "(invalid)";
}
let Ok(uri) = value.parse::<http::Uri>() else {
return "(invalid)";
};
if !matches!(uri.scheme_str(), Some("http" | "https"))
|| uri.query().is_some()
|| !matches!(uri.path(), "" | "/")
|| uri.authority().is_none()
{
return "(invalid)";
}
value
}
fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted: bool) -> &'a str {
if !headers.contains_key(name) {
return "(absent)";
}
if !trusted {
return "(untrusted)";
}
let Some(value) = single_header(headers, name) else {
return "(invalid)";
};
if name == "x-forwarded-proto" {
if matches!(value, "http" | "https") { value } else { "(invalid)" }
} else {
diagnostic_admin_authority(value)
}
}
fn invalid_admin_origin(
request: &Request,
reason: &'static str,
scheme: Option<&str>,
authority: Option<&str>,
trusted_host: bool,
trusted_proto: bool,
) -> Response {
let headers = request.headers();
let path = request.uri().path();
let path = if path.len() <= 160 { path } else { "(long_path)" };
let browser_host = single_header(headers, HOST.as_str())
.map(diagnostic_admin_authority)
.unwrap_or("(absent_or_duplicate)");
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
None => "(absent)",
Some("same-origin" | "same-site" | "cross-site" | "none") =>
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"),
_ => "(invalid)",
};
tracing::warn!(
reason,
method = %request.method(),
path,
browser_origin = diagnostic_admin_origin(headers),
browser_host,
expected_scheme = scheme.unwrap_or("(unknown)"),
expected_authority = authority.map(diagnostic_admin_authority).unwrap_or("(unknown)"),
browser_fetch_site,
trusted_forwarded_host = trusted_host,
trusted_forwarded_proto = trusted_proto,
forwarded_host = diagnostic_forwarded_header(headers, "x-forwarded-host", trusted_host),
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
"administration origin rejected"
);
// When a browser navigates to the dashboard document, JSON here would
// replace the entire page with a raw API error. Keep the 403 (no shell or
// protected data) but present a standalone recovery message instead.
let wants_html = request.headers().get(http::header::ACCEPT)
.and_then(|header| header.to_str().ok())
.is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html")));
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
return secure((
StatusCode::FORBIDDEN,
axum::response::Html("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>RustProxy - Access blocked</title><h1>RustProxy - Could not open the panel</h1><p>The request origin was rejected. Check the proxy/URL and try again.</p><p><a href=\"\">Reload panel</a></p></html>"),
).into_response());
}
secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"))
}
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
let transport = request
.extensions()
@@ -10,9 +115,13 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
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 secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")),
Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
}
};
let external_authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
@@ -26,13 +135,13 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
})
{
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
}
if headers
.get("sec-fetch-site")
.is_some_and(|h| h == "cross-site")
{
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
}
let path = request.uri().path().to_owned();
let public = anonymous_allowed(request.method(), &path);
+63
View File
@@ -253,6 +253,69 @@
));
}
#[test]
fn admin_origin_diagnostics_redact_non_origin_values() {
let mut headers = HeaderMap::new();
assert_eq!(diagnostic_admin_origin(&headers), "(absent)");
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test"));
assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test");
headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test"));
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret"));
assert_eq!(diagnostic_admin_origin(&headers), "(invalid)");
headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test"));
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)");
assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test");
}
#[tokio::test]
async fn unauthorized_browser_origins_remain_rejected_for_session_checks() {
let (router, store, _dir, secret, _) = fixture().await;
for (origin, fetch_site) in [
(Some("https://attacker.example"), None),
(Some("null"), None),
(None, Some("cross-site")),
] {
let mut request = Request::builder()
.uri("/api/auth/session")
.header("host", "127.0.0.1:9090")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); }
let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Origin on an ordinary same-origin GET may be absent, or may match.
for origin in [None, Some("http://127.0.0.1:9090")] {
let mut request = Request::builder()
.uri("/api/auth/session")
.header("host", "127.0.0.1:9090")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME));
if let Some(origin) = origin { request = request.header(ORIGIN, origin); }
assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK);
}
store.close().await;
}
#[tokio::test]
async fn invalid_origin_on_document_is_visible_but_still_forbidden() {
let (router, store, _dir, _secret, _) = fixture().await;
let response = router.oneshot(
Request::builder()
.uri("/overview")
.header("host", "127.0.0.1:9090")
.header("origin", "null")
.header("accept", "text/html,application/xhtml+xml")
.body(Body::empty())
.unwrap(),
).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html"));
let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("Could not open the panel"));
store.close().await;
}
#[tokio::test]
async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() {
let mut config = Config::default();
+6 -28
View File
@@ -25,7 +25,7 @@ function panelFromLocation() { if (typeof location === "undefined") return "over
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
let lastApiLatencyMs = null;
let lastApiLatencyMs = null, sessionCheckPromise = null;
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
// Language content is discovered by build.rs from langs/*.json, never compiled
// into a two-language switch here. Locale metadata also drives Intl and RTL.
@@ -388,7 +388,7 @@ async function activate(info) {
document.documentElement.classList.remove("app-startup-failed");
rustProxyRefresh.start();
const preferences = await loadAdminPreferences();
if (!authenticated || epoch !== authEpoch) return;
if (!authenticated || epoch !== authEpoch) return false;
window.rustProxyLogs?.applyPreferences(preferences);
window.rustProxyFileLogs?.applyPreferences(preferences);
window.rustProxyVhosts?.applyPreferences(preferences);
@@ -399,17 +399,18 @@ async function activate(info) {
for (let attempt = 0; attempt < 3 && authenticated && epoch === authEpoch; attempt++) {
try { await loadConfig(); break; }
catch (error) {
if (!authenticated || epoch !== authEpoch || error?.status === 401) return;
if (attempt === 2) { showStartupFailure(error); return; }
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
if (attempt === 2) { showStartupFailure(error); return false; }
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 400));
}
}
if (!authenticated || epoch !== authEpoch) return;
if (!authenticated || epoch !== authEpoch) return false;
// A single full snapshot seeds non-live diagnostics; subsequent updates
// continue over SSE rather than polling full status on a timer.
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
return true;
}
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
@@ -475,26 +476,3 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
async function revalidateSession() {
const epoch = authEpoch;
try {
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
if (epoch !== authEpoch) return;
if (!authenticated) { await activate(info); return; }
sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync();
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
}
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
function showStartupFailure(error) {
// Never expose a blank page or an incomplete workspace after startup fails.
// The authenticated shell keeps its own retry link; 401 still redirects to login.
$("login").hidden = true;
$("workspace").hidden = true;
$("logout").hidden = !authenticated;
document.documentElement.classList.add("app-loading", "app-startup-failed");
$("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy";
const detail = $("app-startup-detail");
detail.textContent = error?.message || String(error);
detail.hidden = false;
}
+49 -13
View File
@@ -21,9 +21,10 @@ const rustProxyRefresh = (() => {
const tasks = new Map();
let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel;
let settingsTab = "listeners", watchingVisibility = false;
let sessionReady = true, visibleResumeHandled = false;
const visible = () => !document.hidden;
function allowed(task) {
return ready && authenticated && visible() && options.live_enabled &&
return ready && authenticated && sessionReady && visible() && options.live_enabled &&
task.interval() > 0 && (!task.active || task.active());
}
function clear(task, abort = false) {
@@ -39,7 +40,7 @@ const rustProxyRefresh = (() => {
task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay);
}
function execute(task, manual) {
if (!authenticated || (!manual && !allowed(task))) return Promise.resolve();
if (!authenticated || !sessionReady || (!manual && !allowed(task))) return Promise.resolve();
if (task.inFlight) {
if (manual) task.queued = true;
return task.inFlight;
@@ -92,22 +93,56 @@ const rustProxyRefresh = (() => {
}
task.seconds = seconds;
}
if (ready && authenticated && visible() && options.live_enabled) startLive();
if (ready && authenticated && sessionReady && visible() && options.live_enabled) startLive();
else stopLive();
}
function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); }
function start() {
ready = true; panel = currentPanel;
settingsTab = window.rustProxySettings?.current?.() || "listeners";
if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; }
sync();
}
function stop() {
ready = false;
function suspendForSessionValidation() {
sessionReady = false;
for (const task of tasks.values()) {
clear(task, true); task.queued = false; task.enabled = false;
}
if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; }
sync();
}
function completeSessionValidation(success) {
sessionReady = !!success;
sync();
}
function requestSessionResume() {
if (!ready || !authenticated || !visible() || visibleResumeHandled) return;
visibleResumeHandled = true;
void revalidateSession();
}
function onVisibility() {
if (visible()) requestSessionResume();
else { visibleResumeHandled = false; sync(); }
}
function onPageHide() {
visibleResumeHandled = false;
suspendForSessionValidation();
}
function onPageShow(event) { if (event.persisted) requestSessionResume(); }
function start() {
ready = true; sessionReady = true; visibleResumeHandled = false; panel = currentPanel;
settingsTab = window.rustProxySettings?.current?.() || "listeners";
if (!watchingVisibility) {
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pagehide", onPageHide);
window.addEventListener("pageshow", onPageShow);
watchingVisibility = true;
}
sync();
}
function stop() {
ready = false; sessionReady = false; visibleResumeHandled = false;
for (const task of tasks.values()) {
clear(task, true); task.queued = false; task.enabled = false;
}
if (watchingVisibility) {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("pageshow", onPageShow);
watchingVisibility = false;
}
stopLive();
}
function update(value) { options = normalizeRefreshPreferences(value); sync(); }
@@ -151,6 +186,7 @@ const rustProxyRefresh = (() => {
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
run: async context => { await loadTunnels(context); } });
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
suspendForSessionValidation, completeSessionValidation,
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
})();
window.rustProxyRefresh = rustProxyRefresh;
+1 -2
View File
@@ -21,12 +21,11 @@ async function startAdminPanel() {
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
if (await activate(info)) clearInvalidOriginRecovery();
} catch (e) {
if (recoverInvalidOrigin(e)) return;
if (e.status === 401) {
+74
View File
@@ -0,0 +1,74 @@
// One in-flight validation per authenticated document. Neither BFCache restore nor
// visibility resume may trigger a reload: a 403 is a recoverable, visible error.
function revalidateSession() {
if (!authenticated) return Promise.resolve(false);
if (sessionCheckPromise) return sessionCheckPromise;
const epoch = authEpoch;
window.rustProxyRefresh?.suspendForSessionValidation();
const check = (async () => {
try {
const info = await api("/api/auth/session");
if (!authenticated || epoch !== authEpoch) return false;
sessionInfo = info;
csrf = info.csrf_token;
renderSession();
hideSessionValidationFailure();
window.rustProxyRefresh?.completeSessionValidation(true);
return true;
} catch (error) {
// api() locks and navigates to the login document on a 401.
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
window.rustProxyRefresh?.completeSessionValidation(false);
showSessionValidationFailure(error);
return false;
}
})();
sessionCheckPromise = check;
void check.then(
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; },
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; }
);
return check;
}
function displayStartupError(error, sessionResume = false) {
$("login").hidden = true;
$("workspace").hidden = true;
$("logout").hidden = !authenticated;
document.documentElement.classList.add("app-loading", "app-startup-failed");
document.documentElement.classList.toggle("app-session-failed", sessionResume);
$("app-startup-title").textContent = sessionResume
? t("sessionValidationFailed")
: (languagePacks.size ? t("startupFailed") : "Could not load RustProxy");
const detail = $("app-startup-detail");
detail.textContent = error?.message || String(error);
detail.hidden = false;
$("app-startup-reload").hidden = sessionResume;
$("app-session-retry").hidden = !sessionResume;
}
function showSessionValidationFailure(error) {
// Keep the workspace inaccessible while its session cannot be verified.
displayStartupError(error, true);
}
function hideSessionValidationFailure() {
if (!document.documentElement.classList.contains("app-session-failed")) return;
document.documentElement.classList.remove("app-session-failed", "app-loading", "app-startup-failed");
$("app-startup-detail").hidden = true;
$("app-startup-detail").textContent = "";
$("app-startup-reload").hidden = false;
$("app-session-retry").hidden = true;
$("workspace").hidden = false;
$("logout").hidden = false;
}
$("app-session-retry").addEventListener("click", () => {
const button = $("app-session-retry");
button.disabled = true;
void revalidateSession().then(
() => { button.disabled = false; },
() => { button.disabled = false; }
);
});
function showStartupFailure(error) {
// All startup failure paths stop background tasks and show visible recovery.
window.rustProxyRefresh?.stop();
displayStartupError(error);
}
+2 -1
View File
@@ -23,7 +23,8 @@
<div class="app-startup-recovery">
<p data-i18n="startupSlow">The panel is taking longer than expected. Check your connection and try again.</p>
<p id="app-startup-detail" class="app-startup-detail" hidden></p>
<a class="outline" href="" data-i18n="startupRetry">Reload panel</a>
<a class="outline" id="app-startup-reload" href="" data-i18n="startupRetry">Reload panel</a>
<button class="outline" type="button" id="app-session-retry" data-i18n="sessionRetry" hidden>Retry connection</button>
</div>
<noscript>JavaScript is required to use the administration panel.</noscript>
</div>
+1 -1
View File
@@ -232,7 +232,7 @@ html:not(.app-loading) .app-startup {
animation: app-startup-reveal 0s linear 12s forwards
}
.app-startup-recovery .outline {
.app-startup-recovery .outline:not([hidden]) {
margin-top: 10px;
display: inline-flex
}