@@ -323,7 +323,9 @@ async fn process_oidc_callback(
323323 let token_response = exchange_code_for_token ( oidc_client, http_client, params) . await ?;
324324 log:: debug!( "Received token response: {token_response:?}" ) ;
325325
326- let mut response = build_redirect_response ( state. initial_url ) ;
326+ // Validate the redirect URL is safe before using it
327+ let redirect_url = validate_redirect_url ( & state. initial_url ) ;
328+ let mut response = build_redirect_response ( redirect_url) ;
327329 set_auth_cookie ( & mut response, & token_response, oidc_client) ?;
328330 Ok ( response)
329331}
@@ -642,12 +644,34 @@ fn nonce_matches(id_token_nonce: &Nonce, state_nonce: &Nonce) -> Result<(), Stri
642644
643645impl OidcLoginState {
644646 fn new ( request : & ServiceRequest , auth_url : AuthUrlParams ) -> Self {
647+ // Capture the full path with query string for proper redirect after auth
648+ let initial_url = Self :: build_safe_redirect_url ( request) ;
649+
645650 Self {
646- initial_url : request . path ( ) . to_string ( ) ,
651+ initial_url,
647652 csrf_token : auth_url. csrf_token ,
648653 nonce : auth_url. nonce ,
649654 }
650655 }
656+
657+ /// Build a safe redirect URL that preserves query parameters but ensures security
658+ fn build_safe_redirect_url ( request : & ServiceRequest ) -> String {
659+ let path = request. path ( ) ;
660+ let query = request. query_string ( ) ;
661+
662+ // Ensure the path starts with '/' for security (prevent open redirects)
663+ let safe_path = if path. starts_with ( '/' ) {
664+ path
665+ } else {
666+ "/"
667+ } ;
668+
669+ if query. is_empty ( ) {
670+ safe_path. to_string ( )
671+ } else {
672+ format ! ( "{}?{}" , safe_path, query)
673+ }
674+ }
651675}
652676
653677fn create_state_cookie ( request : & ServiceRequest , auth_url : AuthUrlParams ) -> Cookie {
@@ -668,3 +692,97 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result<OidcLoginSt
668692 serde_json:: from_str ( state_cookie. value ( ) )
669693 . with_context ( || format ! ( "Failed to parse OIDC state from cookie: {state_cookie}" ) )
670694}
695+
696+ /// Validate that a redirect URL is safe to use (prevents open redirect attacks)
697+ fn validate_redirect_url ( url : & str ) -> String {
698+ // Only allow relative URLs that start with '/' to prevent open redirects
699+ if url. starts_with ( '/' ) && !url. starts_with ( "//" ) {
700+ url. to_string ( )
701+ } else {
702+ log:: warn!( "Invalid redirect URL '{}', redirecting to root instead" , url) ;
703+ "/" . to_string ( )
704+ }
705+ }
706+
707+ #[ cfg( test) ]
708+ mod tests {
709+ use super :: * ;
710+ use actix_web:: { test, http:: Method } ;
711+
712+ #[ test]
713+ fn test_build_safe_redirect_url_with_query_params ( ) {
714+ let req = test:: TestRequest :: with_uri ( "/page.sql?param=1¶m2=value" )
715+ . method ( Method :: GET )
716+ . to_srv_request ( ) ;
717+
718+ let result = OidcLoginState :: build_safe_redirect_url ( & req) ;
719+ assert_eq ! ( result, "/page.sql?param=1¶m2=value" ) ;
720+ }
721+
722+ #[ test]
723+ fn test_build_safe_redirect_url_without_query_params ( ) {
724+ let req = test:: TestRequest :: with_uri ( "/page.sql" )
725+ . method ( Method :: GET )
726+ . to_srv_request ( ) ;
727+
728+ let result = OidcLoginState :: build_safe_redirect_url ( & req) ;
729+ assert_eq ! ( result, "/page.sql" ) ;
730+ }
731+
732+ #[ test]
733+ fn test_build_safe_redirect_url_with_special_characters ( ) {
734+ let req = test:: TestRequest :: with_uri ( "/page.sql?param=hello%20world&special=%26%3D" )
735+ . method ( Method :: GET )
736+ . to_srv_request ( ) ;
737+
738+ let result = OidcLoginState :: build_safe_redirect_url ( & req) ;
739+ assert_eq ! ( result, "/page.sql?param=hello%20world&special=%26%3D" ) ;
740+ }
741+
742+ #[ test]
743+ fn test_build_safe_redirect_url_prevents_absolute_urls ( ) {
744+ let req = test:: TestRequest :: with_uri ( "http://evil.com/page.sql" )
745+ . method ( Method :: GET )
746+ . to_srv_request ( ) ;
747+
748+ let result = OidcLoginState :: build_safe_redirect_url ( & req) ;
749+ // Should default to root path for security
750+ assert_eq ! ( result, "/" ) ;
751+ }
752+
753+ #[ test]
754+ fn test_validate_redirect_url_valid_paths ( ) {
755+ assert_eq ! ( validate_redirect_url( "/page.sql" ) , "/page.sql" ) ;
756+ assert_eq ! ( validate_redirect_url( "/page.sql?param=1" ) , "/page.sql?param=1" ) ;
757+ assert_eq ! ( validate_redirect_url( "/" ) , "/" ) ;
758+ assert_eq ! ( validate_redirect_url( "/some/deep/path" ) , "/some/deep/path" ) ;
759+ }
760+
761+ #[ test]
762+ fn test_validate_redirect_url_invalid_paths ( ) {
763+ // Protocol-relative URLs are dangerous
764+ assert_eq ! ( validate_redirect_url( "//evil.com/path" ) , "/" ) ;
765+
766+ // Absolute URLs are dangerous
767+ assert_eq ! ( validate_redirect_url( "http://evil.com" ) , "/" ) ;
768+ assert_eq ! ( validate_redirect_url( "https://evil.com" ) , "/" ) ;
769+
770+ // Relative URLs without leading slash
771+ assert_eq ! ( validate_redirect_url( "page.sql" ) , "/" ) ;
772+ }
773+
774+ #[ test]
775+ fn test_oidc_login_state_preserves_query_parameters ( ) {
776+ let req = test:: TestRequest :: with_uri ( "/dashboard.sql?user_id=123&filter=active" )
777+ . method ( Method :: GET )
778+ . to_srv_request ( ) ;
779+
780+ let auth_params = AuthUrlParams {
781+ csrf_token : CsrfToken :: new ( "test_token" . to_string ( ) ) ,
782+ nonce : Nonce :: new ( "test_nonce" . to_string ( ) ) ,
783+ } ;
784+
785+ let state = OidcLoginState :: new ( & req, auth_params) ;
786+ assert_eq ! ( state. initial_url, "/dashboard.sql?user_id=123&filter=active" ) ;
787+ }
788+ }
0 commit comments