Skip to content

Commit 431ab87

Browse files
authored
Add timeout to OIDC HTTP response body read (#1232)
* add timeout to OIDC HTTP response body read A stalled OIDC provider that sends HTTP headers but never completes the body would cause response.body().await to hang forever, freezing the entire SQLPage process. Add a 5-second timeout on the body stream read using awc's ClientResponse::timeout(). partially Fixes #1231 * simplify test comment * simplify test: use token_endpoint_delay instead of Notify gate * simplify test: use tokio time pause + auto-advance instead of select * use spawn_local + advance instead of select to detect hang * use Notify sync point instead of yield loop for deterministic test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * simplify test: replace Notify+yields with sleep+time-advance Remove the Notify synchronization and yield_now() calls. Instead, use a small real-time sleep for TCP to complete, then pause+advance tokio time. Assert the actual response status instead of is_finished(). * fix clippy: remove unnecessary mut on response
1 parent 74ff8c8 commit 431ab87

3 files changed

Lines changed: 67 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ lambda-web = ["dep:lambda-web", "odbc-static"]
8989

9090
[dev-dependencies]
9191
actix-http = "3"
92+
tokio = { version = "1", features = ["rt", "time", "test-util"] }
9293

9394
[build-dependencies]
9495
awc = { version = "3", features = ["rustls-0_23-webpki-roots"] }

src/webserver/oidc.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce";
4848
const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_";
4949
const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
5050
const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
51+
const OIDC_HTTP_BODY_TIMEOUT: Duration = OIDC_CLIENT_MIN_REFRESH_INTERVAL;
5152
const SQLPAGE_OIDC_REDIRECT_COUNT_COOKIE: &str = "sqlpage_oidc_redirect_count";
5253
const MAX_OIDC_REDIRECTS: u8 = 3;
5354
const AUTH_COOKIE_EXPIRATION: awc::cookie::time::Duration =
@@ -837,7 +838,7 @@ async fn execute_oidc_request_with_awc(
837838
req = req.insert_header((name.as_str(), value.to_str()?));
838839
}
839840
let (req_head, body) = request.into_parts();
840-
let mut response = req.send_body(body).await.map_err(|e| {
841+
let response = req.send_body(body).await.map_err(|e| {
841842
anyhow!(e.to_string()).context(format!(
842843
"Failed to send request: {} {}",
843844
&req_head.method, &req_head.uri
@@ -849,6 +850,7 @@ async fn execute_oidc_request_with_awc(
849850
for (name, value) in head {
850851
resp_builder = resp_builder.header(name.as_str(), value.to_str()?);
851852
}
853+
let mut response = response.timeout(OIDC_HTTP_BODY_TIMEOUT);
852854
let body = response
853855
.body()
854856
.await

tests/oidc/mod.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use serde_json::json;
1212
use sqlpage::webserver::http::create_app;
1313
use std::collections::HashMap;
1414
use std::sync::{Arc, Mutex};
15+
use std::time::Duration;
1516
use tokio_util::sync::{CancellationToken, DropGuard};
1617

1718
fn base64url_encode(data: &[u8]) -> String {
@@ -50,6 +51,7 @@ struct ProviderState<'a> {
5051
client_id: String,
5152
auth_codes: HashMap<String, String>, // code -> nonce
5253
jwt_customizer: Option<Box<JwtCustomizer<'a>>>,
54+
token_endpoint_delay: Duration,
5355
}
5456

5557
type ProviderStateWithLifetime<'a> = ProviderState<'a>;
@@ -142,16 +144,24 @@ async fn token_endpoint(
142144
.map(|customizer| customizer(claims.clone(), &state.secret))
143145
.unwrap_or_else(|| make_jwt(&claims, &state.secret));
144146

147+
let delay = state.token_endpoint_delay;
148+
drop(state);
149+
145150
let response = TokenResponse {
146151
access_token: "test_access_token".to_string(),
147152
token_type: "Bearer".to_string(),
148153
id_token,
149154
expires_in: 3600,
150155
};
151156

157+
let json_bytes = serde_json::to_vec(&response).unwrap();
158+
let body = futures_util::stream::once(async move {
159+
tokio::time::sleep(delay).await;
160+
Ok::<web::Bytes, actix_web::Error>(web::Bytes::from(json_bytes))
161+
});
152162
HttpResponse::Ok()
153163
.insert_header((header::CONTENT_TYPE, "application/json"))
154-
.json(response)
164+
.streaming(body)
155165
}
156166

157167
pub struct FakeOidcProvider {
@@ -185,6 +195,7 @@ impl FakeOidcProvider {
185195
client_id: client_id.clone(),
186196
auth_codes: HashMap::new(),
187197
jwt_customizer: None,
198+
token_endpoint_delay: Duration::ZERO,
188199
}));
189200

190201
let state_for_server = Arc::clone(&state);
@@ -226,6 +237,10 @@ impl FakeOidcProvider {
226237
f(&mut state)
227238
}
228239

240+
pub fn set_token_endpoint_delay(&self, delay: Duration) {
241+
self.with_state_mut(|s| s.token_endpoint_delay = delay);
242+
}
243+
229244
pub fn store_auth_code(&self, code: String, nonce: String) {
230245
self.with_state_mut(|s| {
231246
s.auth_codes.insert(code, nonce);
@@ -540,3 +555,50 @@ async fn test_oidc_logout_uses_correct_scheme() {
540555
let post_logout = params.get("post_logout_redirect_uri").unwrap();
541556
assert_eq!(post_logout, "https://example.com/logged_out");
542557
}
558+
559+
/// A slow OIDC provider must not freeze the server.
560+
/// See https://github.com/sqlpage/SQLPage/issues/1231
561+
#[actix_web::test]
562+
async fn test_slow_token_endpoint_does_not_freeze_server() {
563+
let (app, provider) = setup_oidc_test(|_| {}).await;
564+
let mut cookies: Vec<Cookie<'static>> = Vec::new();
565+
566+
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
567+
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
568+
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
569+
let state_param = get_query_param(&auth_url, "state");
570+
let nonce = get_query_param(&auth_url, "nonce");
571+
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
572+
provider.store_auth_code("test_auth_code".to_string(), nonce);
573+
574+
provider.set_token_endpoint_delay(Duration::from_secs(999));
575+
576+
let callback_uri = format!(
577+
"{}?code=test_auth_code&state={}",
578+
Url::parse(&redirect_uri).unwrap().path(),
579+
state_param
580+
);
581+
582+
let handle = tokio::task::spawn_local(async move {
583+
let mut req = test::TestRequest::get().uri(&callback_uri);
584+
for cookie in cookies.iter() {
585+
req = req.cookie(cookie.clone());
586+
}
587+
test::call_service(&app, req.to_request()).await
588+
});
589+
590+
// Let the localhost TCP round-trip complete so awc reads response headers.
591+
tokio::time::sleep(Duration::from_millis(50)).await;
592+
593+
// Freeze time and advance past the body-read timeout.
594+
tokio::time::pause();
595+
tokio::time::advance(Duration::from_secs(60)).await;
596+
597+
// The body timeout should have fired, completing the request with an error
598+
// that SQLPage handles by redirecting to the OIDC provider.
599+
let resp = tokio::time::timeout(Duration::from_secs(1), handle)
600+
.await
601+
.expect("OIDC callback hung on a slow token endpoint")
602+
.unwrap();
603+
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
604+
}

0 commit comments

Comments
 (0)