Skip to content

Commit 09b6c2b

Browse files
committed
Prevent caching of OIDC redirects
1 parent 8f7bff2 commit 09b6c2b

3 files changed

Lines changed: 87 additions & 0 deletions

File tree

CHANGELOG.md

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

55
- **Access logs now go to stdout.** SQLPage now writes the single per-request completion log line to stdout with the target `sqlpage::access`, matching common application-server and container logging conventions. Diagnostic logs, warnings, and internal errors still go to stderr. If your `LOG_LEVEL` or `RUST_LOG` filter is scoped to a specific old target such as `sqlpage::webserver::http=info`, add `sqlpage::access=info` so request-completion logs are still emitted. If your log pipeline only collects stderr, update it to collect stdout too.
6+
- **OIDC redirects are no longer cacheable.** Authorization redirects contain one-time state and post-login redirects set session cookies. SQLPage now sends `Cache-Control: no-store` for these responses, preventing a browser or intermediary from replaying an expired authorization redirect.
67

78
## v0.44.1
89

src/webserver/oidc.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,9 @@ fn build_auth_provider_redirect_response(
870870
.finish();
871871
let mut response = HttpResponse::SeeOther();
872872
response.append_header((header::LOCATION, url.to_string()));
873+
// The location contains a one-time CSRF state. A cached redirect would
874+
// replay it after its state cookie has been consumed.
875+
response.append_header((header::CACHE_CONTROL, "no-store"));
873876
if let Ok(cookies) = request.cookies() {
874877
for mut cookie in get_tmp_login_flow_state_cookies_to_evict(&cookies).cloned() {
875878
cookie.make_removal();
@@ -884,6 +887,7 @@ fn build_auth_provider_redirect_response(
884887
fn build_redirect_response(target_url: String) -> HttpResponse {
885888
HttpResponse::SeeOther()
886889
.append_header(("Location", target_url))
890+
.append_header((header::CACHE_CONTROL, "no-store"))
887891
.body("Redirecting...")
888892
}
889893

tests/oidc/mod.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,17 @@ fn get_query_param(url: &Url, name: &str) -> String {
264264
.to_string()
265265
}
266266

267+
fn permits_storage_after_proxy_adds_freshness(headers: &header::HeaderMap) -> bool {
268+
// The supplied HAR has `Cache-Control: max-age=86400` on the OIDC 303s,
269+
// despite SQLPage not setting it. This models a proxy adding that directive:
270+
// `no-store` still wins when both directives are present.
271+
!headers
272+
.get_all(header::CACHE_CONTROL)
273+
.filter_map(|value| value.to_str().ok())
274+
.flat_map(|value| value.split(','))
275+
.any(|directive| directive.trim().eq_ignore_ascii_case("no-store"))
276+
}
277+
267278
macro_rules! request_with_cookies {
268279
($app:expr, $req:expr, $cookies:expr) => {{
269280
let mut req = $req;
@@ -325,13 +336,79 @@ async fn setup_oidc_test(
325336
(app, provider)
326337
}
327338

339+
#[actix_web::test]
340+
async fn test_oidc_cached_authorization_redirect_cannot_replay_consumed_state() {
341+
let (app, provider) = setup_oidc_test(|_| {}).await;
342+
let mut cookies: Vec<Cookie<'static>> = Vec::new();
343+
344+
// Chrome's private cache can replay a cached 303 without reapplying its
345+
// Set-Cookie headers. This is the sequence captured in #1341's HAR.
346+
let initial_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
347+
let initial_auth_url = Url::parse(
348+
initial_response
349+
.headers()
350+
.get(header::LOCATION)
351+
.unwrap()
352+
.to_str()
353+
.unwrap(),
354+
)
355+
.unwrap();
356+
let cached_authorization_url =
357+
permits_storage_after_proxy_adds_freshness(initial_response.headers())
358+
.then_some(initial_auth_url.clone());
359+
360+
let state = get_query_param(&initial_auth_url, "state");
361+
let nonce = get_query_param(&initial_auth_url, "nonce");
362+
let redirect_uri = get_query_param(&initial_auth_url, "redirect_uri");
363+
let callback_path = Url::parse(&redirect_uri).unwrap().path().to_owned();
364+
provider.store_auth_code("first-code".to_string(), nonce.clone());
365+
let callback_uri = format!("{callback_path}?code=first-code&state={state}");
366+
let callback_response =
367+
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
368+
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
369+
370+
if let Some(stale_authorization_url) = cached_authorization_url {
371+
// A fresh Entra code is returned for the authorization URL cached by
372+
// Chrome, but its state is the state that the successful callback just
373+
// consumed. Before this fix, SQLPage restarted OIDC here, creating the
374+
// observed redirect loop.
375+
let stale_state = get_query_param(&stale_authorization_url, "state");
376+
provider.store_auth_code("stale-code".to_string(), nonce);
377+
let stale_callback_uri = format!("{callback_path}?code=stale-code&state={stale_state}");
378+
let stale_callback_response = request_with_cookies!(
379+
app,
380+
test::TestRequest::get().uri(&stale_callback_uri),
381+
cookies
382+
);
383+
panic!(
384+
"a cacheable authorization redirect replays a consumed state; stale callback redirected to {}",
385+
stale_callback_response
386+
.headers()
387+
.get(header::LOCATION)
388+
.unwrap()
389+
.to_str()
390+
.unwrap()
391+
);
392+
}
393+
394+
// With `no-store`, Chrome re-requests `/` after login and sends its new
395+
// auth cookie instead of following the original authorization redirect.
396+
let final_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
397+
assert_eq!(final_response.status(), StatusCode::OK);
398+
}
399+
328400
#[actix_web::test]
329401
async fn test_oidc_happy_path() {
330402
let (app, provider) = setup_oidc_test(|_| {}).await;
331403
let mut cookies: Vec<Cookie<'static>> = Vec::new();
332404

333405
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
334406
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
407+
assert_eq!(
408+
resp.headers().get(header::CACHE_CONTROL).unwrap(),
409+
"no-store",
410+
"the authorization redirect contains a one-time state and must not be cached"
411+
);
335412
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
336413

337414
let state = get_query_param(&auth_url, "state");
@@ -347,6 +424,11 @@ async fn test_oidc_happy_path() {
347424
let callback_resp =
348425
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
349426
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
427+
assert_eq!(
428+
callback_resp.headers().get(header::CACHE_CONTROL).unwrap(),
429+
"no-store",
430+
"the post-login redirect must not re-enter a cached authorization redirect"
431+
);
350432

351433
let final_resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
352434
assert_eq!(final_resp.status(), StatusCode::OK);

0 commit comments

Comments
 (0)