Skip to content

Commit 7f11bce

Browse files
authored
Sanitize next url (#323)
Signed-off-by: Sergio Castaño Arteaga <[email protected]>
1 parent 78aff4b commit 7f11bce

File tree

4 files changed

+73
-34
lines changed

4 files changed

+73
-34
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
@@ -40,6 +40,7 @@ openidconnect = { version = "4.0.1", features = ["accept-rfc3339-timestamps"] }
4040
openssl = { version = "0.10.73", features = ["vendored"] }
4141
palette = "0.7.6"
4242
password-auth = "1.0.0"
43+
percent-encoding = "2.3.2"
4344
postgres-openssl = "0.5.1"
4445
rand = "0.9.2"
4546
regex = "1.11.2"

gitjobs-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ openidconnect = { workspace = true }
3333
openssl = { workspace = true }
3434
palette = { workspace = true }
3535
password-auth = { workspace = true }
36+
percent-encoding = { workspace = true }
3637
postgres-openssl = { workspace = true }
3738
rand = { workspace = true }
3839
regex = { workspace = true }

gitjobs-server/src/handlers/auth.rs

Lines changed: 68 additions & 32 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;
@@ -69,13 +70,17 @@ pub(crate) async fn log_in_page(
6970
return Ok(Redirect::to("/").into_response());
7071
}
7172

73+
// Sanitize and encode the next url (if provided)
74+
let next_url =
75+
sanitize_next_url(query.get("next_url").map(String::as_str)).map(|value| encode_next_url(&value));
76+
7277
// Prepare template
7378
let template = templates::auth::LogInPage {
7479
auth_provider: None,
7580
login: cfg.login.clone(),
7681
cfg: cfg.into(),
7782
messages: messages.into_iter().collect(),
78-
next_url: query.get("next_url").cloned(),
83+
next_url,
7984
page_id: PageId::LogIn,
8085
user: User::default(),
8186
};
@@ -96,13 +101,17 @@ pub(crate) async fn sign_up_page(
96101
return Ok(Redirect::to("/").into_response());
97102
}
98103

104+
// Sanitize and encode the next url (if provided)
105+
let next_url =
106+
sanitize_next_url(query.get("next_url").map(String::as_str)).map(|value| encode_next_url(&value));
107+
99108
// Prepare template
100109
let template = templates::auth::SignUpPage {
101110
auth_provider: None,
102111
login: cfg.login.clone(),
103112
cfg: cfg.into(),
104113
messages: messages.into_iter().collect(),
105-
next_url: query.get("next_url").cloned(),
114+
next_url,
106115
page_id: PageId::SignUp,
107116
user: User::default(),
108117
};
@@ -122,14 +131,17 @@ pub(crate) async fn log_in(
122131
State(db): State<DynDB>,
123132
Form(creds): Form<PasswordCredentials>,
124133
) -> Result<impl IntoResponse, HandlerError> {
134+
// Sanitize next url
135+
let next_url = sanitize_next_url(query.get("next_url").map(String::as_str));
136+
125137
// Authenticate user
126138
let Some(user) = auth_session
127139
.authenticate(Credentials::Password(creds.clone()))
128140
.await
129141
.map_err(|e| HandlerError::Auth(e.to_string()))?
130142
else {
131143
messages.error("Invalid credentials. Please make sure you have verified your email address.");
132-
let log_in_url = get_log_in_url(query.get("next_url"));
144+
let log_in_url = get_log_in_url(next_url.as_deref());
133145
return Ok(Redirect::to(&log_in_url));
134146
};
135147

@@ -147,13 +159,7 @@ pub(crate) async fn log_in(
147159
.await?;
148160
}
149161

150-
// Prepare next url
151-
let next_url = if let Some(next_url) = query.get("next_url") {
152-
next_url
153-
} else {
154-
"/"
155-
};
156-
162+
let next_url = next_url.as_deref().unwrap_or("/");
157163
Ok(Redirect::to(next_url))
158164
}
159165

@@ -191,8 +197,12 @@ pub(crate) async fn oauth2_callback(
191197
}
192198

193199
// Get next url from session (if any)
194-
let next_url = session.remove::<Option<String>>(NEXT_URL_KEY).await?.flatten();
195-
let log_in_url = get_log_in_url(next_url.as_ref());
200+
let next_url = session
201+
.remove::<Option<String>>(NEXT_URL_KEY)
202+
.await?
203+
.flatten()
204+
.and_then(|value| sanitize_next_url(Some(value.as_str())));
205+
let log_in_url = get_log_in_url(next_url.as_deref());
196206

197207
// Authenticate user
198208
let creds = OAuth2Credentials { code, provider };
@@ -222,10 +232,8 @@ pub(crate) async fn oauth2_callback(
222232
.await?;
223233
}
224234

225-
// Prepare next url
226-
let next_url = next_url.unwrap_or("/".to_string());
227-
228-
Ok(Redirect::to(&next_url))
235+
let next_url = next_url.as_deref().unwrap_or("/");
236+
Ok(Redirect::to(next_url))
229237
}
230238

231239
/// Handler that redirects the user to the oauth2 provider.
@@ -242,6 +250,9 @@ pub(crate) async fn oauth2_redirect(
242250
}
243251
let (authorize_url, csrf_state) = builder.url();
244252

253+
// Sanitize the next url (if any)
254+
let next_url = sanitize_next_url(next_url.as_deref());
255+
245256
// Save the csrf state and next url in the session
246257
session.insert(OAUTH2_CSRF_STATE_KEY, csrf_state.secret()).await?;
247258
session.insert(NEXT_URL_KEY, next_url).await?;
@@ -279,8 +290,12 @@ pub(crate) async fn oidc_callback(
279290
};
280291

281292
// Get next url from session (if any)
282-
let next_url = session.remove::<Option<String>>(NEXT_URL_KEY).await?.flatten();
283-
let log_in_url = get_log_in_url(next_url.as_ref());
293+
let next_url = session
294+
.remove::<Option<String>>(NEXT_URL_KEY)
295+
.await?
296+
.flatten()
297+
.and_then(|value| sanitize_next_url(Some(value.as_str())));
298+
let log_in_url = get_log_in_url(next_url.as_deref());
284299

285300
// Authenticate user
286301
let creds = OidcCredentials {
@@ -317,10 +332,8 @@ pub(crate) async fn oidc_callback(
317332
.await?;
318333
}
319334

320-
// Prepare next url
321-
let next_url = next_url.unwrap_or("/".to_string());
322-
323-
Ok(Redirect::to(&next_url))
335+
let next_url = next_url.as_deref().unwrap_or("/");
336+
Ok(Redirect::to(next_url))
324337
}
325338

326339
/// Handler that redirects the user to the oidc provider.
@@ -341,6 +354,9 @@ pub(crate) async fn oidc_redirect(
341354
}
342355
let (authorize_url, csrf_state, nonce) = builder.url();
343356

357+
// Sanitize the next url (if any)
358+
let next_url = sanitize_next_url(next_url.as_deref());
359+
344360
// Save the csrf state, nonce and next url in the session
345361
session.insert(OAUTH2_CSRF_STATE_KEY, csrf_state.secret()).await?;
346362
session.insert(OIDC_NONCE_KEY, nonce.secret()).await?;
@@ -393,7 +409,8 @@ pub(crate) async fn sign_up(
393409
}
394410

395411
// Redirect to the log in page on success
396-
let log_in_url = get_log_in_url(query.get("next_url"));
412+
let next_url = sanitize_next_url(query.get("next_url").map(String::as_str));
413+
let log_in_url = get_log_in_url(next_url.as_deref());
397414
Ok(Redirect::to(&log_in_url).into_response())
398415
}
399416

@@ -466,15 +483,6 @@ pub(crate) async fn verify_email(
466483
Ok(Redirect::to(LOG_IN_URL).into_response())
467484
}
468485

469-
/// Get the log in url including the next url if provided.
470-
fn get_log_in_url(next_url: Option<&String>) -> String {
471-
let mut log_in_url = LOG_IN_URL.to_string();
472-
if let Some(next_url) = next_url {
473-
log_in_url = format!("{log_in_url}?next_url={next_url}");
474-
}
475-
log_in_url
476-
}
477-
478486
// Deserialization helpers.
479487

480488
/// `OAuth2` authorization response containing code and CSRF state.
@@ -632,3 +640,31 @@ pub(crate) async fn user_owns_job(
632640

633641
next.run(request).await.into_response()
634642
}
643+
644+
// Helpers.
645+
646+
/// Percent-encode a `next_url` so it can be safely embedded in a query string.
647+
fn encode_next_url(next_url: &str) -> String {
648+
utf8_percent_encode(next_url, NON_ALPHANUMERIC).to_string()
649+
}
650+
651+
/// Get the log in url including the next url if provided.
652+
fn get_log_in_url(next_url: Option<&str>) -> String {
653+
let mut log_in_url = LOG_IN_URL.to_string();
654+
if let Some(next_url) = sanitize_next_url(next_url) {
655+
log_in_url = format!("{log_in_url}?next_url={}", encode_next_url(&next_url));
656+
}
657+
log_in_url
658+
}
659+
660+
/// Sanitize a `next_url` value ensuring it points to an in-site path.
661+
fn sanitize_next_url(next_url: Option<&str>) -> Option<String> {
662+
let value = next_url?.trim();
663+
if value.is_empty() {
664+
return None;
665+
}
666+
if !value.starts_with('/') || value.starts_with("//") {
667+
return None;
668+
}
669+
Some(value.to_string())
670+
}

0 commit comments

Comments
 (0)