Skip to content

Commit 57fb5cc

Browse files
cursoragentlovasoa
andcommitted
Fix OIDC redirect to preserve query parameters and prevent open redirects
Co-authored-by: contact <[email protected]>
1 parent 7b7e4cc commit 57fb5cc

File tree

2 files changed

+271
-2
lines changed

2 files changed

+271
-2
lines changed

OIDC_FIX_EXPLANATION.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# OIDC Query Parameter Preservation Fix
2+
3+
## Problem
4+
When using OIDC authentication in SQLPage, query parameters were lost during the authentication redirect flow. For example:
5+
- User visits: `/page.sql?param=1`
6+
- After OIDC authentication, user is redirected to: `/page.sql` (query parameters lost)
7+
8+
## Root Cause
9+
In `src/webserver/oidc.rs`, the `OidcLoginState::new()` function was only capturing `request.path()` which excludes query parameters, instead of the full URL.
10+
11+
## Fix Applied
12+
13+
### 1. Modified `OidcLoginState::new()` method
14+
**Before:**
15+
```rust
16+
fn new(request: &ServiceRequest, auth_url: AuthUrlParams) -> Self {
17+
Self {
18+
initial_url: request.path().to_string(), // BUG: loses query parameters
19+
csrf_token: auth_url.csrf_token,
20+
nonce: auth_url.nonce,
21+
}
22+
}
23+
```
24+
25+
**After:**
26+
```rust
27+
fn new(request: &ServiceRequest, auth_url: AuthUrlParams) -> Self {
28+
// Capture the full path with query string for proper redirect after auth
29+
let initial_url = Self::build_safe_redirect_url(request);
30+
31+
Self {
32+
initial_url,
33+
csrf_token: auth_url.csrf_token,
34+
nonce: auth_url.nonce,
35+
}
36+
}
37+
```
38+
39+
### 2. Added `build_safe_redirect_url()` method
40+
This method safely constructs the redirect URL by:
41+
- Using `request.path()` to get the path
42+
- Using `request.query_string()` to get query parameters
43+
- Combining them while ensuring security (path must start with '/')
44+
45+
```rust
46+
fn build_safe_redirect_url(request: &ServiceRequest) -> String {
47+
let path = request.path();
48+
let query = request.query_string();
49+
50+
// Ensure the path starts with '/' for security (prevent open redirects)
51+
let safe_path = if path.starts_with('/') {
52+
path
53+
} else {
54+
"/"
55+
};
56+
57+
if query.is_empty() {
58+
safe_path.to_string()
59+
} else {
60+
format!("{}?{}", safe_path, query)
61+
}
62+
}
63+
```
64+
65+
### 3. Added `validate_redirect_url()` function
66+
Added additional security validation when retrieving the URL from the cookie:
67+
68+
```rust
69+
fn validate_redirect_url(url: &str) -> String {
70+
// Only allow relative URLs that start with '/' to prevent open redirects
71+
if url.starts_with('/') && !url.starts_with("//") {
72+
url.to_string()
73+
} else {
74+
log::warn!("Invalid redirect URL '{}', redirecting to root instead", url);
75+
"/".to_string()
76+
}
77+
}
78+
```
79+
80+
### 4. Updated callback processing
81+
Modified the OIDC callback processing to use the validation function:
82+
83+
**Before:**
84+
```rust
85+
let mut response = build_redirect_response(state.initial_url);
86+
```
87+
88+
**After:**
89+
```rust
90+
// Validate the redirect URL is safe before using it
91+
let redirect_url = validate_redirect_url(&state.initial_url);
92+
let mut response = build_redirect_response(redirect_url);
93+
```
94+
95+
## Security Considerations
96+
97+
The fix includes several security measures:
98+
99+
1. **Open Redirect Prevention**: Only relative URLs starting with '/' are allowed
100+
2. **Protocol-relative URL Prevention**: URLs starting with '//' are rejected
101+
3. **Absolute URL Prevention**: URLs with protocols (http://, https://) are rejected
102+
4. **Fallback to Root**: Invalid URLs redirect to '/' instead of failing
103+
104+
## Testing
105+
106+
Unit tests were added to verify:
107+
- Query parameters are preserved correctly
108+
- Special characters in URLs are handled properly
109+
- Security validations work as expected
110+
- Invalid URLs are safely handled
111+
112+
Example test case:
113+
```rust
114+
#[test]
115+
fn test_oidc_login_state_preserves_query_parameters() {
116+
let req = test::TestRequest::with_uri("/dashboard.sql?user_id=123&filter=active")
117+
.method(Method::GET)
118+
.to_srv_request();
119+
120+
let auth_params = AuthUrlParams {
121+
csrf_token: CsrfToken::new("test_token".to_string()),
122+
nonce: Nonce::new("test_nonce".to_string()),
123+
};
124+
125+
let state = OidcLoginState::new(&req, auth_params);
126+
assert_eq!(state.initial_url, "/dashboard.sql?user_id=123&filter=active");
127+
}
128+
```
129+
130+
## Usage Example
131+
132+
After this fix, the following flow now works correctly:
133+
134+
1. User visits: `https://example.com/report.sql?date=2024-01-01&format=pdf`
135+
2. User is not authenticated, gets redirected to OIDC provider
136+
3. User authenticates successfully
137+
4. User is redirected back to: `https://example.com/report.sql?date=2024-01-01&format=pdf`
138+
139+
Previously, step 4 would redirect to: `https://example.com/report.sql` (losing query parameters) ❌
140+
141+
## Verification
142+
143+
To verify the fix works:
144+
145+
1. Set up OIDC authentication in SQLPage
146+
2. Clear browser cookies to force re-authentication
147+
3. Visit a SQLPage URL with query parameters (e.g., `/page.sql?param=value`)
148+
4. Complete the OIDC authentication flow
149+
5. Verify you're redirected back to the original URL with parameters intact
150+
151+
The fix preserves the user's original intent while maintaining security against open redirect attacks.

src/webserver/oidc.rs

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

643645
impl 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

653677
fn 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&param2=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&param2=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

Comments
 (0)