fn diagnostic_admin_authority(value: &str) -> &str { if value.len() <= 255 && !value.contains('@') && value.parse::().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::() 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" ); 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("RustProxy - Access blocked

RustProxy - Could not open the panel

The request origin was rejected. Check the proxy/URL and try again.

Reload panel

"), ).into_response()); } secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")) } async fn guard(State(state): State>, mut request: Request, next: Next) -> Response { let transport = request .extensions() .get::() .copied() .unwrap_or_default(); let peer = request.extensions().get::().copied(); let raw_host = match admin_request_authority(&request, transport.tls) { Ok(value) => value, 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 automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION); let pinned_authority_result = if automation_credentials { Ok(None) } else { configured_admin_public_authority(&state, headers, peer, &raw_host, transport) }; 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", 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() .ok() .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) }) { 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 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); let unsafe_method = request.method() != Method::GET && request.method() != Method::HEAD && request.method() != Method::OPTIONS; let secret = auth::cookie_secret(headers); let certificate_api_operation = certificate_api_operation(request.method(), &path); let mut certificate_api_access = None; let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) { let Some(operation) = certificate_api_operation.as_ref() else { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.invalid_route", )); }; match certificate_api_credentials(headers) { Ok(None) => None, Ok(Some((key_id, token))) => { if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.https_required", )); } let token_hash = auth::secret_hash(&token); let grant = match state .store .verify_certificate_api_credential(&key_id, &token_hash) .await { Ok(Some(grant)) => grant, Ok(None) => { return secure(reject( StatusCode::UNAUTHORIZED, "certificate_api.invalid_token", )) } Err(error) => return secure(auth::internal(error).into_response()), }; match operation { CertificateApiOperation::ReloadAll => { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.target_required", )); } CertificateApiOperation::ReloadOne(certificate_id) => { if !grant.allows_certificate(certificate_id) { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.certificate_forbidden", )); } } CertificateApiOperation::Deploy => { if !grant.allow_deploy { return secure(reject( StatusCode::FORBIDDEN, "certificate_api.deploy_forbidden", )); } } } certificate_api_access = Some(CertificateApiAccess { grant }); Some(Session { token_hash, csrf: String::new(), username: format!("certificate-api:{key_id}"), expires_at: auth::now().saturating_add(60), generation: 0, }) } Err(()) => { return secure(reject( StatusCode::UNAUTHORIZED, "certificate_api.invalid_token", )) } } } else { None }; let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) { let bearer = single_header(headers, AUTHORIZATION.as_str()) .and_then(|header| header.strip_prefix("Bearer ")); let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else { return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token")); }; let Some(peer) = peer else { return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required")); }; if !peer.0.ip().is_loopback() && scheme != "https" { return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required")); } let hash = auth::secret_hash(token); let peer_ip = crate::vhost::canonical_ip(peer.0.ip()); let grant = match state.store.verify_api_token(&hash, peer_ip).await { Ok(Some(grant)) => grant, Ok(None) => { return secure(reject( StatusCode::UNAUTHORIZED, "api_token.invalid_token_or_ip", )) } Err(error) => return secure(auth::internal(error).into_response()), }; if !general_api_token_allowed(request.method(), &path, &grant.permission) { return secure(reject( StatusCode::FORBIDDEN, "api_token.insufficient_permission", )); } Some(Session { token_hash: hash, csrf: String::new(), username: format!("api-token:{}", grant.id), expires_at: auth::now().saturating_add(60), generation: 0, }) } else { None }; let external_credential = certificate_api_session.is_some() || general_api_session.is_some(); let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); let session = if certificate_api_session.is_some() { certificate_api_session } else if general_api_session.is_some() { general_api_session } else if !public || panel_document_path(&path) { match secret.as_deref() { Some(secret) => match state .store .session_at_with_browser_lifetime(secret, auth::now(), session_seconds) .await { Ok(value) => value, Err(error) => return secure(auth::internal(error).into_response()), }, None => None, } } else { None }; if !public && session.is_none() { return secure(unauthorized(&request)); } if unsafe_method && !external_credential { if single_header(headers, "origin") .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) || single_header(headers, "x-rustproxy-request") != Some("1") { return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); } if !public && !session.as_ref().is_some_and(|session| { single_header(headers, "x-csrf-token") .is_some_and(|provided| auth::equal_secret(provided, &session.csrf)) }) { return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); } } if let Some(session) = session.clone() { request.extensions_mut().insert(session); } if let Some(access) = certificate_api_access { request.extensions_mut().insert(access); } let admin_origin = AdminOrigin { secure: scheme == "https", }; request.extensions_mut().insert(admin_origin); request.extensions_mut().insert(transport); let mut response = secure(next.run(request).await); let refresh_session_cookie = !matches!( path.as_str(), "/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password" ); if refresh_session_cookie && !external_credential { if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) { let remaining = session.expires_at.saturating_sub(auth::now()); if remaining > 0 { if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie( secret, admin_origin.secure, remaining, )) { response.headers_mut().append(SET_COOKIE, cookie); } } } } if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() { response.headers_mut().insert("content-security-policy", HeaderValue::from_static( "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); } response } async fn auth_status(State(state): State>) -> Result, ApiError> { Ok(Json( json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}), )) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Setup { token: String, username: String, password: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Login { username: String, password: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PasswordChange { current_password: String, new_password: String, } async fn setup( State(state): State>, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(input) = input.map_err(public::invalid_json)?; state .auth .setup(&input.token, &input.username, input.password) .await?; Ok(Json(json!({"initialized":true}))) } fn session_payload(session: &Session, session_seconds: i64) -> Value { json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at, "idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds}) } async fn login( State(state): State>, Extension(origin): Extension, headers: HeaderMap, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result { let Json(input) = input.map_err(public::invalid_json)?; let previous = auth::cookie_secret(&headers); let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); let issued = state .auth .login( &input.username, input.password, previous.as_deref(), session_seconds, ) .await?; let mut response = Json(session_payload(&issued.session, session_seconds)).into_response(); response.headers_mut().insert( SET_COOKIE, auth::session_cookie(&issued.secret, origin.secure, session_seconds) .parse() .map_err(auth::internal)?, ); Ok(response) } async fn session( State(state): State>, Extension(session): Extension, ) -> Json { let session_seconds = state .config .load() .envelope .config .administration .session_seconds(); Json(session_payload(&session, session_seconds)) } fn logged_out(secure_cookie: bool) -> Response { let mut response = Json(json!({"logged_out":true})).into_response(); response.headers_mut().insert( SET_COOKIE, HeaderValue::from_str(&auth::expired_cookie(secure_cookie)) .expect("static cookie attributes"), ); response } async fn logout( State(state): State>, Extension(origin): Extension, Extension(session): Extension, ) -> Result { state .store .end_session(&session) .await .map_err(auth::internal)?; Ok(logged_out(origin.secure)) } async fn logout_all( State(state): State>, Extension(origin): Extension, Extension(session): Extension, ) -> Result { state .store .revoke_all_sessions(&session.username) .await .map_err(auth::internal)?; Ok(logged_out(origin.secure)) } async fn password( State(state): State>, Extension(origin): Extension, Extension(session): Extension, Json(input): Json, ) -> Result { state .auth .change_password(&session, input.current_password, input.new_password) .await?; Ok(logged_out(origin.secure)) }