fix(js): time out scripted fetch/XHR so a hung request fires error, not nothing (#251) (#252)

build_request_client (the client behind every JS fetch(), XHR, and ES-module
load) set no request timeout. A cross-origin XHR with a non-simple header
triggers a CORS preflight OPTIONS; if the server accepts the connection but never
answers it, op_fetch_url's send().await hung forever, the fetch promise never
settled, and the XHR was stuck at readyState 1 with no completion event ever
firing. That stranded Angular HttpClient on live.deutsche-boerse.com.

Give the client a 30s request timeout (matching the other clients in the
workspace); OBSCURA_FETCH_TIMEOUT_MS overrides it. On timeout the request errors,
op_fetch_url propagates it, and the fetch shim turns it into an XHR error/loadend,
so readyState advances to 4 and Angular's error path runs.

Verified: a server that hangs on OPTIONS now makes the XHR fire error at the
timeout (was stuck forever); a CORS-allowed cross-origin XHR still succeeds with
load/200; obstacle course 33/33.

Closes #251
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-06 09:43:28 +02:00
1 parent 1a0644f7bc
commit ce67008072
1 file changed
+13
+13
View File
@@ -495,8 +495,21 @@ fn build_request_client(proxy_url: Option<&str>) -> Result<reqwest::Client, Stri
// against the same SSRF policy as the initial URL (GHSA-8v6v-g4rh-jmcm).
// With reqwest's default auto-follow, an attacker-controlled origin can
// 302 to http://127.0.0.1 and read the internal-service body.
// Per-request timeout so a scripted fetch()/XHR, or a CORS preflight OPTIONS
// (issue #251), to a server that accepts the connection but never responds
// cannot hang forever. Without it op_fetch_url never returns, the fetch
// promise never settles, and the JS XHR is stuck at readyState 1 with no
// completion event (which stranded Angular HttpClient). On timeout reqwest's
// send().await errors, which op_fetch_url propagates and the fetch shim turns
// into an XHR `error`/`loadend`. 30s matches the other clients in the
// workspace; OBSCURA_FETCH_TIMEOUT_MS overrides it for tighter cloud limits.
let timeout_ms: u64 = std::env::var("OBSCURA_FETCH_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30_000);
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_millis(timeout_ms))
// Be explicit about pool size: default is unbounded which is fine,
// but pool_idle_timeout default (90s) is short for SPA-heavy
// workloads where the same origin is hit dozens of times across