From 4aea0357b61d048896f9e5876bc47ff2e7e2df23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 18:34:07 +0000 Subject: [PATCH] fix: app login redirect broken after forward_auth challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. verify() built the login URL with `//login` (double slash) — now `/login` 2. safe_path() rejected absolute https:// next-URLs, so after login the user was silently dropped at `/` instead of their original app URL. Replaced safe_path with safe_redirect(next, domain) which allows relative paths OR absolute URLs whose host is the configured domain (or a subdomain). safe_path is kept as a thin wrapper (domain="") for the admin-UI middleware where next is always a relative path. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- server/src/auth.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/server/src/auth.rs b/server/src/auth.rs index 9937892..25743bc 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -60,13 +60,26 @@ pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Op state.sessions.lock().await.get(&token).cloned() } -/// Reject open-redirect: only allow relative paths. -fn safe_path(next: &str) -> String { +/// Reject open-redirect: allow relative paths or absolute URLs on the same domain. +fn safe_redirect(next: &str, domain: &str) -> String { if next.starts_with('/') && !next.starts_with("//") { - next.to_string() - } else { - "/".to_string() + return next.to_string(); } + // Allow https:// URLs whose host is the domain or a subdomain of it. + for prefix in &["https://", "http://"] { + if let Some(rest) = next.strip_prefix(prefix) { + let host = rest.split('/').next().unwrap_or(""); + if host == domain || host.ends_with(&format!(".{}", domain)) { + return next.to_string(); + } + } + } + "/".to_string() +} + +/// Reject open-redirect: only allow relative paths (used for admin-UI redirects). +fn safe_path(next: &str) -> String { + safe_redirect(next, "") } // ── Control-plane middleware (admin users only) ─────────────────────────────── @@ -138,7 +151,7 @@ pub async fn verify( format!("{}://{}{}", proto, host, uri) }; let login_url = format!( - "https://{}//login?next={}", + "https://{}/login?next={}", state.domain_suffix, urlencoding_simple(&next_url) ); @@ -232,10 +245,10 @@ pub async fn login_page( ) -> Response { // Already logged in → redirect. if current_user_id(&state, &headers).await.is_some() { - let next = params.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); + let next = params.next.as_deref().map(|n| safe_redirect(n, &state.domain_suffix)).unwrap_or_else(|| "/".into()); return Redirect::to(&next).into_response(); } - let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into()); + let next = params.next.map(|s| safe_redirect(&s, &state.domain_suffix)).unwrap_or_else(|| "/".into()); Html(login_html(&next, None)).into_response() } @@ -243,7 +256,7 @@ pub async fn handle_login( State(state): State, Form(form): Form, ) -> Response { - let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); + let next = form.next.as_deref().map(|n| safe_redirect(n, &state.domain_suffix)).unwrap_or_else(|| "/".into()); // --- env-var bootstrap path (used before any DB user exists) --- let using_bootstrap = matches!(