Skip to content

Commit 6b1b18f

Browse files
authored
Sanitize next url (#178)
Signed-off-by: Sergio Castaño Arteaga <[email protected]>
1 parent d951b3f commit 6b1b18f

File tree

4 files changed

+95
-31
lines changed

4 files changed

+95
-31
lines changed

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ openidconnect = { version = "4.0.0", features = ["accept-rfc3339-timestamps"] }
3636
openssl = { version = "0.10.73", features = ["vendored"] }
3737
palette = "0.7.6"
3838
password-auth = "1.0.0"
39+
percent-encoding = "2.3.2"
3940
postgres-openssl = "0.5.1"
4041
reqwest = { version = "0.12.20", features = ["json"] }
4142
rust-embed = "8.7.2"

ocg-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ openidconnect = { workspace = true }
3131
openssl = { workspace = true }
3232
palette = { workspace = true }
3333
password-auth = { workspace = true }
34+
percent-encoding = { workspace = true }
3435
postgres-openssl = { workspace = true }
3536
reqwest = { workspace = true }
3637
rust-embed = { workspace = true }

ocg-server/src/handlers/auth.rs

Lines changed: 90 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use axum_extra::extract::Form;
1313
use axum_messages::Messages;
1414
use openidconnect as oidc;
1515
use password_auth::verify_password;
16+
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
1617
use serde::Deserialize;
1718
use tower_sessions::Session;
1819
use tracing::instrument;
@@ -78,6 +79,10 @@ pub(crate) async fn log_in_page(
7879
// Get community information
7980
let community = db.get_community(community_id).await?;
8081

82+
// Sanitize and encode the next url (if any)
83+
let next_url =
84+
sanitize_next_url(query.get("next_url").map(String::as_str)).map(|value| encode_next_url(&value));
85+
8186
// Prepare template
8287
let template = templates::auth::LogInPage {
8388
community,
@@ -87,7 +92,7 @@ pub(crate) async fn log_in_page(
8792
path: LOG_IN_URL.to_string(),
8893
user: User::default(),
8994

90-
next_url: query.get("next_url").cloned(),
95+
next_url,
9196
};
9297

9398
Ok(Html(template.render()?).into_response())
@@ -111,6 +116,10 @@ pub(crate) async fn sign_up_page(
111116
// Get community information
112117
let community = db.get_community(community_id).await?;
113118

119+
// Sanitize and encode the next url (if any)
120+
let next_url =
121+
sanitize_next_url(query.get("next_url").map(String::as_str)).map(|value| encode_next_url(&value));
122+
114123
// Prepare template
115124
let template = templates::auth::SignUpPage {
116125
community,
@@ -120,7 +129,7 @@ pub(crate) async fn sign_up_page(
120129
path: SIGN_UP_URL.to_string(),
121130
user: User::default(),
122131

123-
next_url: query.get("next_url").cloned(),
132+
next_url,
124133
};
125134

126135
Ok(Html(template.render()?).into_response())
@@ -150,6 +159,9 @@ pub(crate) async fn log_in(
150159
Query(query): Query<HashMap<String, String>>,
151160
Form(login_form): Form<LoginForm>,
152161
) -> Result<impl IntoResponse, HandlerError> {
162+
// Sanitize next url
163+
let next_url = sanitize_next_url(query.get("next_url").map(String::as_str));
164+
153165
// Authenticate user
154166
let creds = PasswordCredentials {
155167
community_id,
@@ -162,7 +174,7 @@ pub(crate) async fn log_in(
162174
.map_err(|e| HandlerError::Auth(e.to_string()))?
163175
else {
164176
messages.error("Invalid credentials. Please make sure you have verified your email address.");
165-
let log_in_url = get_log_in_url(query.get("next_url"));
177+
let log_in_url = get_log_in_url(next_url.as_deref());
166178
return Ok(Redirect::to(&log_in_url));
167179
};
168180

@@ -178,13 +190,7 @@ pub(crate) async fn log_in(
178190
session.insert(SELECTED_GROUP_ID_KEY, groups[0].group_id).await?;
179191
}
180192

181-
// Prepare next url
182-
let next_url = if let Some(next_url) = query.get("next_url") {
183-
next_url
184-
} else {
185-
"/"
186-
};
187-
193+
let next_url = next_url.as_deref().unwrap_or("/");
188194
Ok(Redirect::to(next_url))
189195
}
190196

@@ -223,8 +229,12 @@ pub(crate) async fn oauth2_callback(
223229
}
224230

225231
// Get next url from session (if any)
226-
let next_url = session.remove::<Option<String>>(NEXT_URL_KEY).await?.flatten();
227-
let log_in_url = get_log_in_url(next_url.as_ref());
232+
let next_url = session
233+
.remove::<Option<String>>(NEXT_URL_KEY)
234+
.await?
235+
.flatten()
236+
.and_then(|value| sanitize_next_url(Some(value.as_str())));
237+
let log_in_url = get_log_in_url(next_url.as_deref());
228238

229239
// Authenticate user
230240
let creds = OAuth2Credentials {
@@ -256,10 +266,8 @@ pub(crate) async fn oauth2_callback(
256266
session.insert(SELECTED_GROUP_ID_KEY, groups[0].group_id).await?;
257267
}
258268

259-
// Prepare next url
260-
let next_url = next_url.unwrap_or("/".to_string());
261-
262-
Ok(Redirect::to(&next_url))
269+
let next_url = next_url.as_deref().unwrap_or("/");
270+
Ok(Redirect::to(next_url))
263271
}
264272

265273
/// Handler that redirects the user to the oauth2 provider.
@@ -276,6 +284,9 @@ pub(crate) async fn oauth2_redirect(
276284
}
277285
let (authorize_url, csrf_state) = builder.url();
278286

287+
// Sanitize the next url (if provided)
288+
let next_url = sanitize_next_url(next_url.as_deref());
289+
279290
// Save the csrf state and next url in the session
280291
session.insert(OAUTH2_CSRF_STATE_KEY, csrf_state.secret()).await?;
281292
session.insert(NEXT_URL_KEY, next_url).await?;
@@ -314,8 +325,12 @@ pub(crate) async fn oidc_callback(
314325
};
315326

316327
// Get next url from session (if any)
317-
let next_url = session.remove::<Option<String>>(NEXT_URL_KEY).await?.flatten();
318-
let log_in_url = get_log_in_url(next_url.as_ref());
328+
let next_url = session
329+
.remove::<Option<String>>(NEXT_URL_KEY)
330+
.await?
331+
.flatten()
332+
.and_then(|value| sanitize_next_url(Some(value.as_str())));
333+
let log_in_url = get_log_in_url(next_url.as_deref());
319334

320335
// Authenticate user
321336
let creds = OidcCredentials {
@@ -351,10 +366,8 @@ pub(crate) async fn oidc_callback(
351366
// Track auth provider in the session
352367
session.insert(AUTH_PROVIDER_KEY, provider).await?;
353368

354-
// Prepare next url
355-
let next_url = next_url.unwrap_or("/".to_string());
356-
357-
Ok(Redirect::to(&next_url))
369+
let next_url = next_url.as_deref().unwrap_or("/");
370+
Ok(Redirect::to(next_url))
358371
}
359372

360373
/// Handler that redirects the user to the oidc provider.
@@ -375,6 +388,9 @@ pub(crate) async fn oidc_redirect(
375388
}
376389
let (authorize_url, csrf_state, nonce) = builder.url();
377390

391+
// Sanitize the next url (if provided)
392+
let next_url = sanitize_next_url(next_url.as_deref());
393+
378394
// Save the csrf state, nonce and next url in the session
379395
session.insert(OAUTH2_CSRF_STATE_KEY, csrf_state.secret()).await?;
380396
session.insert(OIDC_NONCE_KEY, nonce.secret()).await?;
@@ -429,7 +445,8 @@ pub(crate) async fn sign_up(
429445
}
430446

431447
// Redirect to the log in page on success
432-
let log_in_url = get_log_in_url(query.get("next_url"));
448+
let next_url = sanitize_next_url(query.get("next_url").map(String::as_str));
449+
let log_in_url = get_log_in_url(next_url.as_deref());
433450
Ok(Redirect::to(&log_in_url).into_response())
434451
}
435452

@@ -504,15 +521,34 @@ pub(crate) async fn verify_email(
504521
Ok(Redirect::to(LOG_IN_URL))
505522
}
506523

524+
// Helpers.
525+
526+
/// Percent-encode a `next_url` so it can be safely embedded in a query string.
527+
fn encode_next_url(next_url: &str) -> String {
528+
utf8_percent_encode(next_url, NON_ALPHANUMERIC).to_string()
529+
}
530+
507531
/// Get the log in url including the next url if provided.
508-
fn get_log_in_url(next_url: Option<&String>) -> String {
532+
fn get_log_in_url(next_url: Option<&str>) -> String {
509533
let mut log_in_url = LOG_IN_URL.to_string();
510-
if let Some(next_url) = next_url {
511-
log_in_url = format!("{log_in_url}?next_url={next_url}");
534+
if let Some(next_url) = sanitize_next_url(next_url) {
535+
log_in_url = format!("{log_in_url}?next_url={}", encode_next_url(&next_url));
512536
}
513537
log_in_url
514538
}
515539

540+
/// Sanitize a `next_url` value ensuring it points to an in-site path.
541+
fn sanitize_next_url(next_url: Option<&str>) -> Option<String> {
542+
let value = next_url?.trim();
543+
if value.is_empty() {
544+
return None;
545+
}
546+
if !value.starts_with('/') || value.starts_with("//") {
547+
return None;
548+
}
549+
Some(value.to_string())
550+
}
551+
516552
// Types.
517553

518554
/// Login form data from the user.
@@ -1606,7 +1642,7 @@ mod tests {
16061642
assert_eq!(parts.status, StatusCode::SEE_OTHER);
16071643
assert_eq!(
16081644
parts.headers.get(LOCATION).unwrap(),
1609-
&HeaderValue::from_static("/log-in?next_url=/welcome"),
1645+
&HeaderValue::from_static("/log-in?next_url=%2Fwelcome"),
16101646
);
16111647
assert!(bytes.is_empty());
16121648
}
@@ -2033,8 +2069,33 @@ mod tests {
20332069

20342070
#[test]
20352071
fn test_get_log_in_url_with_next() {
2036-
let url = get_log_in_url(Some(&"/dashboard".to_string()));
2037-
assert_eq!(url, "/log-in?next_url=/dashboard");
2072+
let url = get_log_in_url(Some("/dashboard"));
2073+
assert_eq!(url, "/log-in?next_url=%2Fdashboard");
2074+
}
2075+
2076+
#[test]
2077+
fn test_sanitize_next_url_accepts_internal_paths() {
2078+
assert_eq!(
2079+
sanitize_next_url(Some("/dashboard")),
2080+
Some("/dashboard".to_string())
2081+
);
2082+
assert_eq!(
2083+
sanitize_next_url(Some("/groups?page=2#section")),
2084+
Some("/groups?page=2#section".to_string())
2085+
);
2086+
assert_eq!(
2087+
sanitize_next_url(Some(" /profile ")),
2088+
Some("/profile".to_string())
2089+
);
2090+
}
2091+
2092+
#[test]
2093+
fn test_sanitize_next_url_rejects_external_paths() {
2094+
assert_eq!(sanitize_next_url(Some("")), None);
2095+
assert_eq!(sanitize_next_url(Some("https://evil.example")), None);
2096+
assert_eq!(sanitize_next_url(Some("//evil.example")), None);
2097+
assert_eq!(sanitize_next_url(Some("javascript:alert(1)")), None);
2098+
assert_eq!(sanitize_next_url(Some("relative/path")), None);
20382099
}
20392100

20402101
#[tokio::test]

0 commit comments

Comments
 (0)