fix: app login redirect broken after forward_auth challenge

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
This commit is contained in:
Claude 2026-03-22 18:34:07 +00:00
parent c261f9d133
commit 4aea0357b6
No known key found for this signature in database

View file

@ -60,13 +60,26 @@ pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Op
state.sessions.lock().await.get(&token).cloned() state.sessions.lock().await.get(&token).cloned()
} }
/// Reject open-redirect: only allow relative paths. /// Reject open-redirect: allow relative paths or absolute URLs on the same domain.
fn safe_path(next: &str) -> String { fn safe_redirect(next: &str, domain: &str) -> String {
if next.starts_with('/') && !next.starts_with("//") { if next.starts_with('/') && !next.starts_with("//") {
next.to_string() return next.to_string();
} else { }
// 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() "/".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) ─────────────────────────────── // ── Control-plane middleware (admin users only) ───────────────────────────────
@ -138,7 +151,7 @@ pub async fn verify(
format!("{}://{}{}", proto, host, uri) format!("{}://{}{}", proto, host, uri)
}; };
let login_url = format!( let login_url = format!(
"https://{}//login?next={}", "https://{}/login?next={}",
state.domain_suffix, state.domain_suffix,
urlencoding_simple(&next_url) urlencoding_simple(&next_url)
); );
@ -232,10 +245,10 @@ pub async fn login_page(
) -> Response { ) -> Response {
// Already logged in → redirect. // Already logged in → redirect.
if current_user_id(&state, &headers).await.is_some() { 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(); 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() Html(login_html(&next, None)).into_response()
} }
@ -243,7 +256,7 @@ pub async fn handle_login(
State(state): State<crate::AppState>, State(state): State<crate::AppState>,
Form(form): Form<LoginForm>, Form(form): Form<LoginForm>,
) -> Response { ) -> 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) --- // --- env-var bootstrap path (used before any DB user exists) ---
let using_bootstrap = matches!( let using_bootstrap = matches!(