Skip to content

Commit 20f78f8

Browse files
committed
fix(oidc): bind logout URLs to the current session (forced-logout CSRF)
Logout URLs were signed only over the redirect target and a timestamp, so any valid, unexpired logout URL would clear whoever's cookies followed it. The logout signature now also covers the caller's sqlpage_auth cookie, so a logout URL only logs out the session it was issued for. Generation and verification select the same cookie (the last of any duplicates, matching how RequestInfo merges them) so the check stays consistent.
1 parent 5548539 commit 20f78f8

5 files changed

Lines changed: 272 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: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,19 @@ 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. Use
1066+
// the first cookie value, matching how verification reads the auth cookie
1067+
// (HttpRequest::cookie returns the first cookie of that name); signing a
1068+
// JSON array of duplicate cookies here would never match verification.
1069+
let session_token = request
1070+
.cookies
1071+
.get("sqlpage_auth")
1072+
.map(SingleOrVec::first_str);
1073+
1074+
let logout_url = oidc_state
1075+
.config
1076+
.create_logout_url(redirect_uri, session_token);
10651077

10661078
Ok(Some(logout_url))
10671079
}

src/webserver/oidc.rs

Lines changed: 131 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())
@@ -564,15 +575,38 @@ fn parse_logout_params(query: &str) -> anyhow::Result<LogoutParams> {
564575
.map(Query::into_inner)
565576
}
566577

578+
/// Selects the `sqlpage_auth` cookie that a logout URL is bound to. When a
579+
/// request carries duplicate `sqlpage_auth` cookies, this returns the last one,
580+
/// matching how `RequestInfo` merges duplicate cookies (last value wins) and
581+
/// therefore what `sqlpage.oidc_logout_url` signs. `ServiceRequest::cookie`
582+
/// would return the first instead, which made legitimate logout URLs fail with
583+
/// a 400 whenever duplicate cookies were present.
584+
fn logout_session_cookie(request: &ServiceRequest) -> Option<Cookie<'static>> {
585+
request.cookies().ok().and_then(|cookies| {
586+
cookies
587+
.iter()
588+
.rev()
589+
.find(|c| c.name() == SQLPAGE_AUTH_COOKIE_NAME)
590+
.cloned()
591+
})
592+
}
593+
567594
fn process_oidc_logout(
568595
oidc_state: &OidcState,
569596
request: &ServiceRequest,
570597
) -> anyhow::Result<HttpResponse> {
571598
let params = parse_logout_params(request.query_string())?;
572599

573-
verify_logout_params(&params, &oidc_state.config.client_secret)?;
600+
let id_token_cookie = logout_session_cookie(request);
601+
let session_token = id_token_cookie
602+
.as_ref()
603+
.map(Cookie::value)
604+
.unwrap_or_default();
605+
606+
// The signature is bound to the session it was issued for, so a logout URL
607+
// cannot be used to forcibly log out a different browser (CSRF).
608+
verify_logout_params(&params, session_token, &oidc_state.config.client_secret)?;
574609

575-
let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME);
576610
let id_token = id_token_cookie
577611
.as_ref()
578612
.map(|c| OidcToken::from_str(c.value()))
@@ -620,7 +654,12 @@ fn process_oidc_logout(
620654
Ok(response)
621655
}
622656

623-
fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &str) -> String {
657+
fn compute_logout_signature(
658+
redirect_uri: &str,
659+
timestamp: i64,
660+
session_token: &str,
661+
client_secret: &str,
662+
) -> String {
624663
use base64::Engine;
625664
use hmac::{Hmac, KeyInit, Mac};
626665
use sha2::Sha256;
@@ -629,15 +668,28 @@ fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &
629668
.expect("HMAC accepts any key size");
630669
mac.update(redirect_uri.as_bytes());
631670
mac.update(&timestamp.to_be_bytes());
671+
// Bind the signature to the session so a logout URL can only log out the
672+
// session it was issued for. The length prefix prevents ambiguity between
673+
// the redirect_uri and session_token fields.
674+
mac.update(&(session_token.len() as u64).to_be_bytes());
675+
mac.update(session_token.as_bytes());
632676
let signature = mac.finalize().into_bytes();
633677
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
634678
}
635679

636-
fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::Result<()> {
680+
fn verify_logout_params(
681+
params: &LogoutParams,
682+
session_token: &str,
683+
client_secret: &str,
684+
) -> anyhow::Result<()> {
637685
use base64::Engine;
638686

639-
let expected_signature =
640-
compute_logout_signature(&params.redirect_uri, params.timestamp, client_secret);
687+
let expected_signature = compute_logout_signature(
688+
&params.redirect_uri,
689+
params.timestamp,
690+
session_token,
691+
client_secret,
692+
);
641693

642694
let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
643695
.decode(&params.signature)
@@ -1240,14 +1292,82 @@ mod tests {
12401292
redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"),
12411293
logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"),
12421294
};
1243-
let generated = config.create_logout_url("/after");
1295+
let generated = config.create_logout_url("/after", Some("session-token"));
12441296

12451297
let parsed = Url::parse(&generated).expect("generated URL should be valid");
12461298
assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI);
12471299

12481300
let params = parse_logout_params(parsed.query().expect("query string is present"))
12491301
.expect("generated URL should parse");
1250-
verify_logout_params(&params, secret).expect("generated URL should validate");
1302+
verify_logout_params(&params, "session-token", secret)
1303+
.expect("generated URL should validate for the session it was issued for");
1304+
}
1305+
1306+
/// A logout URL is bound to the session it was issued for: presenting it
1307+
/// from a different browser (a different `sqlpage_auth` cookie), or with
1308+
/// no session at all, must NOT validate. This is what prevents a forced
1309+
/// logout CSRF where a logout URL generated for one user logs out another.
1310+
#[test]
1311+
fn logout_url_is_bound_to_the_issuing_session() {
1312+
let secret = "super_secret_key";
1313+
let config = OidcConfig {
1314+
issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(),
1315+
client_id: "test_client".to_string(),
1316+
client_secret: secret.to_string(),
1317+
protected_paths: vec![],
1318+
public_paths: vec![],
1319+
app_host: "example.com".to_string(),
1320+
scopes: vec![],
1321+
additional_audience_verifier: AudienceVerifier::new(None),
1322+
site_prefix: "https://example.com".to_string(),
1323+
redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"),
1324+
logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"),
1325+
};
1326+
1327+
// A logout URL issued for victim's session.
1328+
let victim_session = "victim-auth-token";
1329+
let generated = config.create_logout_url("/after", Some(victim_session));
1330+
let parsed = Url::parse(&generated).expect("generated URL should be valid");
1331+
let params = parse_logout_params(parsed.query().expect("query string is present"))
1332+
.expect("generated URL should parse");
1333+
1334+
// It validates for the session it was issued for.
1335+
verify_logout_params(&params, victim_session, secret)
1336+
.expect("must validate for the issuing session");
1337+
1338+
// It must NOT validate when presented by a different session...
1339+
verify_logout_params(&params, "attacker-auth-token", secret)
1340+
.expect_err("must reject a different session's auth cookie");
1341+
// ...nor when presented with no session at all (replay/CSRF).
1342+
verify_logout_params(&params, "", secret)
1343+
.expect_err("must reject a request with no auth cookie");
1344+
}
1345+
1346+
#[test]
1347+
fn logout_session_cookie_uses_last_duplicate_like_request_info() {
1348+
// Two sqlpage_auth cookies (e.g. set with different Path attributes).
1349+
// actix's ServiceRequest::cookie returns the first, but RequestInfo (and
1350+
// thus sqlpage.oidc_logout_url, which signs the logout URL) keeps the
1351+
// last after merging duplicates. Verification must use the same value
1352+
// the URL was signed with, otherwise a legitimate logout URL is 400ed.
1353+
let request = TestRequest::default()
1354+
.insert_header(("cookie", "sqlpage_auth=first; sqlpage_auth=second"))
1355+
.to_srv_request();
1356+
assert_eq!(
1357+
request
1358+
.cookie(SQLPAGE_AUTH_COOKIE_NAME)
1359+
.as_ref()
1360+
.map(Cookie::value),
1361+
Some("first"),
1362+
"actix selects the first duplicate cookie"
1363+
);
1364+
assert_eq!(
1365+
logout_session_cookie(&request)
1366+
.as_ref()
1367+
.map(Cookie::value),
1368+
Some("second"),
1369+
"logout binding must use the last duplicate, matching RequestInfo and the signed URL"
1370+
);
12511371
}
12521372

12531373
#[test]

src/webserver/single_or_vec.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ impl SingleOrVec {
8383
SingleOrVec::Vec(v) => Cow::Owned(serde_json::to_string(v).unwrap()),
8484
}
8585
}
86+
87+
/// Returns the first value. This mirrors how `actix_web::HttpRequest::cookie`
88+
/// selects the first cookie of a given name, so callers that need to agree
89+
/// with that selection (such as OIDC logout session binding) stay consistent
90+
/// instead of accidentally using a JSON array of merged duplicate values.
91+
#[must_use]
92+
pub fn first_str(&self) -> &str {
93+
match self {
94+
SingleOrVec::Single(x) => x,
95+
SingleOrVec::Vec(v) => v.first().map_or("", String::as_str),
96+
}
97+
}
8698
}
8799

88100
#[cfg(test)]
@@ -110,6 +122,18 @@ mod single_or_vec_tests {
110122
);
111123
}
112124

125+
#[test]
126+
fn first_str_returns_first_value() {
127+
assert_eq!(SingleOrVec::Single("a".to_string()).first_str(), "a");
128+
// Duplicate values (e.g. two cookies of the same name) yield the first,
129+
// matching HttpRequest::cookie, not a JSON array.
130+
assert_eq!(
131+
SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]).first_str(),
132+
"a"
133+
);
134+
assert_eq!(SingleOrVec::Vec(vec![]).first_str(), "");
135+
}
136+
113137
#[test]
114138
fn displays_single_value() {
115139
let single = SingleOrVec::Single("hello".to_string());

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)