Skip to content

Commit 67b9c00

Browse files
committed
fix(oidc): reject backslash authority forms in relative redirects
The relative-redirect validation accepted /\evil.test because it only rejected values starting with //. URL parsers normalize \ to /, turning such a value into an external http://evil.test/ target (open redirect). Add a shared is_safe_relative_redirect helper that also rejects any backslash, and use it in the three validation sites: logout verify_logout_params, sqlpage.oidc_logout_url, and validate_redirect_url.
1 parent ada23bc commit 67b9c00

3 files changed

Lines changed: 65 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG.md
22

3+
## Unreleased
4+
5+
- **Security (OIDC open redirect): logout and login redirect targets could point at an external site.** SQLPage accepted a relative redirect target if it started with `/` but not `//`. A value like `/\evil.test` passed that check, but the [WHATWG URL Standard](https://url.spec.whatwg.org/#url-parsing) treats `\` as `/` for http/https URLs, so SQLPage's own URL parser (the `url` crate) turns `/\evil.test` into `http://evil.test/` when it builds the absolute `post_logout_redirect_uri`. That makes it a server-side open redirect, independent of the client. The same WHATWG rule is implemented by current browsers (Chromium, Firefox, Safari), so a `Location: /\evil.test` is also followed to the external host. (Parsers that follow RFC 3986 instead, as many proxies do, leave the backslash alone, but SQLPage cannot rely on that.) You are affected only if you use OIDC and build a redirect target from user-controlled input, for example a `redirect_uri` passed to `sqlpage.oidc_logout_url`. SQLPage now rejects any redirect target containing a backslash, on top of the existing `//` check. Normal paths such as `/foo/bar?x=1` keep working; just upgrade.
6+
37
## v0.44.0
48

59
This release focuses on making production SQLPage apps easier to understand, debug, and operate. Most apps should keep working without SQL changes, but maintainers should review the notes about logging and uploaded-file permissions.

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,9 +1050,9 @@ async fn oidc_logout_url<'a>(
10501050

10511051
let redirect_uri = redirect_uri.as_deref().unwrap_or("/");
10521052

1053-
if !redirect_uri.starts_with('/') || redirect_uri.starts_with("//") {
1053+
if !crate::webserver::oidc::is_safe_relative_redirect(redirect_uri) {
10541054
anyhow::bail!(
1055-
"oidc_logout_url: redirect_uri must be a relative path starting with '/'. Got: {redirect_uri}"
1055+
"oidc_logout_url: redirect_uri must be a relative path starting with a single '/'. Got: {redirect_uri}"
10561056
);
10571057
}
10581058

src/webserver/oidc.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::R
659659
anyhow::bail!("Logout token timestamp is in the future");
660660
}
661661

662-
if !params.redirect_uri.starts_with('/') || params.redirect_uri.starts_with("//") {
662+
if !is_safe_relative_redirect(&params.redirect_uri) {
663663
anyhow::bail!("Invalid redirect URI");
664664
}
665665

@@ -1180,9 +1180,36 @@ impl AudienceVerifier {
11801180
}
11811181
}
11821182

1183+
/// Returns true if the given value is a safe relative redirect target.
1184+
///
1185+
/// Only paths starting with a single `/` (not `//`) are accepted, and any value
1186+
/// containing a backslash is rejected. The WHATWG URL Standard treats `\` as
1187+
/// equivalent to `/` for special schemes (http/https), so `/\evil.test` parses
1188+
/// to the authority `evil.test`, i.e. `http://evil.test/`. SQLPage itself uses a
1189+
/// WHATWG parser (the `url` crate) when it builds the absolute
1190+
/// `post_logout_redirect_uri`, so without this check a value classified as
1191+
/// "relative" becomes an external open-redirect target on the server side,
1192+
/// independent of the client. Browsers implementing the same standard (Chromium,
1193+
/// Firefox, Safari) resolve a `Location: /\evil.test` the same way.
1194+
pub(crate) fn is_safe_relative_redirect(uri: &str) -> bool {
1195+
// Reject any embedded backslash: the WHATWG URL parser used by SQLPage's
1196+
// `url` crate (and by browsers) treats `\` as `/`, so it can smuggle in an
1197+
// authority component.
1198+
if uri.contains('\\') {
1199+
return false;
1200+
}
1201+
let mut chars = uri.chars();
1202+
// Must start with `/`...
1203+
if chars.next() != Some('/') {
1204+
return false;
1205+
}
1206+
// ...but the second character must not be `/` (protocol-relative authority).
1207+
!matches!(chars.next(), Some('/'))
1208+
}
1209+
11831210
/// Validate that a redirect URL is safe to use (prevents open redirect attacks)
11841211
fn validate_redirect_url(url: String, redirect_uri: &str) -> String {
1185-
if url.starts_with('/') && !url.starts_with("//") && !url.starts_with(redirect_uri) {
1212+
if is_safe_relative_redirect(&url) && !url.starts_with(redirect_uri) {
11861213
return url;
11871214
}
11881215
log::warn!("Refusing to redirect to {url}");
@@ -1196,6 +1223,36 @@ mod tests {
11961223
use actix_web::{cookie::Cookie, test::TestRequest};
11971224
use openidconnect::url::Url;
11981225

1226+
#[test]
1227+
fn relative_redirect_rejects_authority_and_backslash_forms() {
1228+
// Legitimate relative paths must be accepted.
1229+
for ok in ["/", "/foo", "/foo/bar?x=1", "/a/b/c#frag"] {
1230+
assert!(
1231+
is_safe_relative_redirect(ok),
1232+
"expected {ok:?} to be accepted"
1233+
);
1234+
}
1235+
// Authority and backslash forms must all be rejected: SQLPage's `url`
1236+
// crate (a WHATWG URL parser) normalizes these into an external
1237+
// `http://evil.test/` target.
1238+
for bad in [
1239+
"//evil.test",
1240+
"/\\evil.test",
1241+
"/\\/evil.test",
1242+
"\\evil.test",
1243+
"\\\\evil.test",
1244+
"/foo\\bar",
1245+
"https://evil.test",
1246+
"evil.test",
1247+
"",
1248+
] {
1249+
assert!(
1250+
!is_safe_relative_redirect(bad),
1251+
"expected {bad:?} to be rejected"
1252+
);
1253+
}
1254+
}
1255+
11991256
#[test]
12001257
fn login_redirects_use_see_other() {
12011258
let response = build_redirect_response("/foo".to_string());

0 commit comments

Comments
 (0)