Skip to content

Commit d52f0fa

Browse files
committed
Bind OIDC logout URLs to the requesting session
OIDC logout URLs signed only the redirect target and a timestamp, so any visitor who opened a still-valid logout link had their sqlpage_auth cookies cleared. A logout link issued for one user could log out another, or be replayed (forced-logout CSRF). It did not allow account takeover. The logout signature now also covers the current sqlpage_auth cookie value, and the logout handler verifies the signature against the auth cookie presented with the request. A logout link is therefore only honored for the browser it was generated for. The normal authenticated logout flow is unchanged.
1 parent fe13e4a commit d52f0fa

4 files changed

Lines changed: 202 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- **Download filenames can no longer inject extra `Content-Disposition` parameters.** The `csv` and `download` components now build the `Content-Disposition` header with a properly quoted and escaped filename instead of plain string concatenation. Before this fix, a filename containing characters such as `;`, `"`, or `=` could add a second header parameter (for example a `filename*=...` value), and some browsers prefer that injected value over the intended one. You are affected if your app puts untrusted data (such as a user-provided name or a value from the database) into the `filename` of a `csv` or `download` component. No SQL change is required: the supplied filename now always appears as a single, safely quoted `filename` value.
66
- **Security fix: reserved and private files could be served directly over HTTP after a trusted page loaded them.** Files that SQLPage normally refuses to serve to direct HTTP requests (anything under the reserved `sqlpage/` prefix such as migrations, configuration, and database connection details, as well as dotfiles, parent-directory traversal paths like `../secret.sql`, and absolute paths) could briefly become reachable. This happened only after a trusted page loaded that same file with `sqlpage.run_sql(...)`, which loads files with elevated privileges and caches the parsed result. While that cache entry was fresh, a direct request such as `GET /sqlpage/secret.sql` (or the extensionless alias `GET /sqlpage/secret`) returned `200 OK` and executed the private SQL instead of returning `403 Forbidden`. Worst case, an attacker who can reach your site could read or execute internal SQL that was meant to stay private, including migration and configuration logic. You are affected if your app calls `sqlpage.run_sql()` on files inside `sqlpage/` (or on dotfiles or paths outside the web root) and is reachable by untrusted users. The fix enforces the unprivileged path guard on every direct HTTP request regardless of the cache, so these paths now always return `403 Forbidden`. Upgrade to get the fix; no configuration change is required. Legitimate `sqlpage.run_sql()` includes of such files from your own pages keep working as before.
7+
- **Security: OIDC logout links are now bound to the session that requested them.** Previously, a logout URL produced by `sqlpage.oidc_logout_url` only signed the redirect target and a timestamp, so any visitor who opened a still-valid logout link had their SQLPage auth cookies cleared. This allowed a forced logout via CSRF (a logout link made for one user could log out another, or be replayed). It did NOT allow account takeover or access to anyone's data. Logout links are now tied to the current `sqlpage_auth` cookie, so following one only logs out the browser it was generated for. You are affected only if you use built-in OIDC authentication together with `sqlpage.oidc_logout_url`. No action is needed: the normal "click your own logout link" flow keeps working, and old links simply stop being honored for other sessions.
78

89
## v0.44.0
910

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,16 @@ async fn oidc_logout_url<'a>(
10611061
);
10621062
}
10631063

1064-
let logout_url = oidc_state.config.create_logout_url(redirect_uri);
1064+
// Bind the logout URL to the current session so that it can only log out
1065+
// the browser it was generated for, never a different user's session.
1066+
let session_token = request
1067+
.cookies
1068+
.get("sqlpage_auth")
1069+
.map(SingleOrVec::as_json_str);
1070+
1071+
let logout_url = oidc_state
1072+
.config
1073+
.create_logout_url(redirect_uri, session_token.as_deref());
10651074

10661075
Ok(Some(logout_url))
10671076
}

src/webserver/oidc.rs

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,22 @@ impl OidcConfig {
154154
.set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn())
155155
}
156156

157-
/// Creates a logout URL with the given redirect URI
157+
/// Creates a logout URL with the given redirect URI.
158+
///
159+
/// The signature is bound to the current session (the value of the
160+
/// [`SQLPAGE_AUTH_COOKIE_NAME`] cookie, if any) so that a logout URL
161+
/// issued for one browser cannot be used to forcibly log out a different
162+
/// browser. `session_token` is the current value of the auth cookie, or
163+
/// `None`/empty if the caller is not authenticated.
158164
#[must_use]
159-
pub fn create_logout_url(&self, redirect_uri: &str) -> String {
165+
pub fn create_logout_url(&self, redirect_uri: &str, session_token: Option<&str>) -> String {
160166
let timestamp = chrono::Utc::now().timestamp();
161-
let signature = compute_logout_signature(redirect_uri, timestamp, &self.client_secret);
167+
let signature = compute_logout_signature(
168+
redirect_uri,
169+
timestamp,
170+
session_token.unwrap_or_default(),
171+
&self.client_secret,
172+
);
162173
let query = form_urlencoded::Serializer::new(String::new())
163174
.append_pair("redirect_uri", redirect_uri)
164175
.append_pair("timestamp", &timestamp.to_string())
@@ -570,9 +581,16 @@ fn process_oidc_logout(
570581
) -> anyhow::Result<HttpResponse> {
571582
let params = parse_logout_params(request.query_string())?;
572583

573-
verify_logout_params(&params, &oidc_state.config.client_secret)?;
574-
575584
let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME);
585+
let session_token = id_token_cookie
586+
.as_ref()
587+
.map(Cookie::value)
588+
.unwrap_or_default();
589+
590+
// The signature is bound to the session it was issued for, so a logout URL
591+
// cannot be used to forcibly log out a different browser (CSRF).
592+
verify_logout_params(&params, session_token, &oidc_state.config.client_secret)?;
593+
576594
let id_token = id_token_cookie
577595
.as_ref()
578596
.map(|c| OidcToken::from_str(c.value()))
@@ -620,7 +638,12 @@ fn process_oidc_logout(
620638
Ok(response)
621639
}
622640

623-
fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &str) -> String {
641+
fn compute_logout_signature(
642+
redirect_uri: &str,
643+
timestamp: i64,
644+
session_token: &str,
645+
client_secret: &str,
646+
) -> String {
624647
use base64::Engine;
625648
use hmac::{Hmac, KeyInit, Mac};
626649
use sha2::Sha256;
@@ -629,15 +652,28 @@ fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &
629652
.expect("HMAC accepts any key size");
630653
mac.update(redirect_uri.as_bytes());
631654
mac.update(&timestamp.to_be_bytes());
655+
// Bind the signature to the session so a logout URL can only log out the
656+
// session it was issued for. The length prefix prevents ambiguity between
657+
// the redirect_uri and session_token fields.
658+
mac.update(&(session_token.len() as u64).to_be_bytes());
659+
mac.update(session_token.as_bytes());
632660
let signature = mac.finalize().into_bytes();
633661
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
634662
}
635663

636-
fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::Result<()> {
664+
fn verify_logout_params(
665+
params: &LogoutParams,
666+
session_token: &str,
667+
client_secret: &str,
668+
) -> anyhow::Result<()> {
637669
use base64::Engine;
638670

639-
let expected_signature =
640-
compute_logout_signature(&params.redirect_uri, params.timestamp, client_secret);
671+
let expected_signature = compute_logout_signature(
672+
&params.redirect_uri,
673+
params.timestamp,
674+
session_token,
675+
client_secret,
676+
);
641677

642678
let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
643679
.decode(&params.signature)
@@ -1240,14 +1276,55 @@ mod tests {
12401276
redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"),
12411277
logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"),
12421278
};
1243-
let generated = config.create_logout_url("/after");
1279+
let generated = config.create_logout_url("/after", Some("session-token"));
12441280

12451281
let parsed = Url::parse(&generated).expect("generated URL should be valid");
12461282
assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI);
12471283

12481284
let params = parse_logout_params(parsed.query().expect("query string is present"))
12491285
.expect("generated URL should parse");
1250-
verify_logout_params(&params, secret).expect("generated URL should validate");
1286+
verify_logout_params(&params, "session-token", secret)
1287+
.expect("generated URL should validate for the session it was issued for");
1288+
}
1289+
1290+
/// A logout URL is bound to the session it was issued for: presenting it
1291+
/// from a different browser (a different `sqlpage_auth` cookie), or with
1292+
/// no session at all, must NOT validate. This is what prevents a forced
1293+
/// logout CSRF where a logout URL generated for one user logs out another.
1294+
#[test]
1295+
fn logout_url_is_bound_to_the_issuing_session() {
1296+
let secret = "super_secret_key";
1297+
let config = OidcConfig {
1298+
issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(),
1299+
client_id: "test_client".to_string(),
1300+
client_secret: secret.to_string(),
1301+
protected_paths: vec![],
1302+
public_paths: vec![],
1303+
app_host: "example.com".to_string(),
1304+
scopes: vec![],
1305+
additional_audience_verifier: AudienceVerifier::new(None),
1306+
site_prefix: "https://example.com".to_string(),
1307+
redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"),
1308+
logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"),
1309+
};
1310+
1311+
// A logout URL issued for victim's session.
1312+
let victim_session = "victim-auth-token";
1313+
let generated = config.create_logout_url("/after", Some(victim_session));
1314+
let parsed = Url::parse(&generated).expect("generated URL should be valid");
1315+
let params = parse_logout_params(parsed.query().expect("query string is present"))
1316+
.expect("generated URL should parse");
1317+
1318+
// It validates for the session it was issued for.
1319+
verify_logout_params(&params, victim_session, secret)
1320+
.expect("must validate for the issuing session");
1321+
1322+
// It must NOT validate when presented by a different session...
1323+
verify_logout_params(&params, "attacker-auth-token", secret)
1324+
.expect_err("must reject a different session's auth cookie");
1325+
// ...nor when presented with no session at all (replay/CSRF).
1326+
verify_logout_params(&params, "", secret)
1327+
.expect_err("must reject a request with no auth cookie");
12511328
}
12521329

12531330
#[test]

tests/oidc/mod.rs

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ async fn test_oidc_logout_uses_correct_scheme() {
547547
.as_ref()
548548
.unwrap()
549549
.config
550-
.create_logout_url("/logged_out");
550+
.create_logout_url("/logged_out", None);
551551
let app = test::init_service(create_app(Data::new(app_state))).await;
552552
// make sure the logout path includes the configured domain
553553
assert!(logout_path.starts_with("/sqlpage/oidc_logout"));
@@ -653,3 +653,105 @@ async fn test_slow_token_endpoint_does_not_freeze_server() {
653653
.unwrap();
654654
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
655655
}
656+
657+
/// A logout URL is bound to the session it was issued for. A logout URL
658+
/// generated for one session must NOT clear a different browser's auth cookie
659+
/// (forced-logout CSRF), while the legitimate logout of the issuing session
660+
/// must keep working.
661+
#[actix_web::test]
662+
async fn test_oidc_logout_is_session_bound() {
663+
use sqlpage::{
664+
AppState,
665+
app_config::{AppConfig, test_database_url},
666+
};
667+
668+
crate::common::init_log();
669+
let provider = FakeOidcProvider::new().await;
670+
671+
let db_url = test_database_url();
672+
let config_json = format!(
673+
r#"{{
674+
"database_url": "{db_url}",
675+
"oidc_issuer_url": "{}",
676+
"oidc_client_id": "{}",
677+
"oidc_client_secret": "{}",
678+
"oidc_protected_paths": ["/"],
679+
"host": "localhost:1"
680+
}}"#,
681+
provider.issuer_url, provider.client_id, provider.client_secret
682+
);
683+
684+
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
685+
let app_state = AppState::init(&config).await.unwrap();
686+
let oidc_state = app_state.oidc_state.clone().unwrap();
687+
let app = test::init_service(create_app(Data::new(app_state))).await;
688+
689+
// Complete a full login as the victim.
690+
let mut cookies: Vec<Cookie<'static>> = Vec::new();
691+
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
692+
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
693+
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
694+
let state = get_query_param(&auth_url, "state");
695+
let nonce = get_query_param(&auth_url, "nonce");
696+
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
697+
provider.store_auth_code("test_auth_code".to_string(), nonce);
698+
let callback_uri = format!(
699+
"{}?code=test_auth_code&state={}",
700+
Url::parse(&redirect_uri).unwrap().path(),
701+
state
702+
);
703+
let callback_resp =
704+
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
705+
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
706+
707+
let victim_auth = cookies
708+
.iter()
709+
.find(|c| c.name() == "sqlpage_auth")
710+
.expect("victim should be authenticated")
711+
.value()
712+
.to_string();
713+
assert!(!victim_auth.is_empty());
714+
715+
// A logout URL bound to a DIFFERENT session must not clear the victim's
716+
// cookie when the victim's browser follows it.
717+
let foreign_logout_url = oidc_state
718+
.config
719+
.create_logout_url("/", Some("some-other-sessions-token"));
720+
let mut req = test::TestRequest::get().uri(&foreign_logout_url);
721+
for cookie in &cookies {
722+
req = req.cookie(cookie.clone());
723+
}
724+
let resp = test::call_service(&app, req.to_request()).await;
725+
assert_eq!(
726+
resp.status(),
727+
StatusCode::BAD_REQUEST,
728+
"a logout URL bound to another session must be rejected"
729+
);
730+
let cleared = extract_set_cookies(resp.headers())
731+
.iter()
732+
.any(|c| c.name() == "sqlpage_auth" && c.value().is_empty());
733+
assert!(
734+
!cleared,
735+
"the victim's auth cookie must not be cleared by a foreign logout URL"
736+
);
737+
738+
// The victim's own logout URL (bound to the victim's session) must work.
739+
let own_logout_url = oidc_state.config.create_logout_url("/", Some(&victim_auth));
740+
let mut req = test::TestRequest::get().uri(&own_logout_url);
741+
for cookie in &cookies {
742+
req = req.cookie(cookie.clone());
743+
}
744+
let resp = test::call_service(&app, req.to_request()).await;
745+
assert_eq!(
746+
resp.status(),
747+
StatusCode::SEE_OTHER,
748+
"the user's own logout must keep working"
749+
);
750+
let cleared = extract_set_cookies(resp.headers())
751+
.iter()
752+
.any(|c| c.name() == "sqlpage_auth" && c.value().is_empty());
753+
assert!(
754+
cleared,
755+
"the user's own logout must clear their auth cookie"
756+
);
757+
}

0 commit comments

Comments
 (0)