diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 73a4d00..73af2cc 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -291,7 +291,11 @@ fn op_console_msg(state: &OpState, #[string] level: &str, #[string] msg: &str) { // network round-trip; the simplification is worth not having to invalidate // a cache when the proxy is reconfigured between fetches. fn build_request_client(proxy_url: Option<&str>) -> Result { - let mut builder = reqwest::Client::builder(); + // Redirects are followed manually below so each hop can be re-validated + // 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. + let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()); if let Some(proxy) = proxy_url { let p = reqwest::Proxy::all(proxy) .map_err(|e| format!("Invalid op_fetch_url proxy '{}': {}", proxy, e))?; @@ -302,6 +306,10 @@ fn build_request_client(proxy_url: Option<&str>) -> Result b, + Err(_) => break resp, + }; + let next_url = match base.join(&location) { + Ok(u) => u, + Err(_) => break resp, + }; + + // Re-validate every redirect target against the SSRF policy. + if let Err(reason) = validate_fetch_url(&next_url) { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": next_url.to_string(), + "headers": {}, + "blocked": true, + "error": format!("Redirect to forbidden URL blocked: {}", reason), + }) + .to_string()); + } + + redirects_followed += 1; + if redirects_followed > FETCH_REDIRECT_LIMIT { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": next_url.to_string(), + "headers": {}, + "blocked": true, + "error": format!("Too many redirects (>{})", FETCH_REDIRECT_LIMIT), + }) + .to_string()); + } + + // Browser semantics: 301/302/303 downgrade to GET with no body. + // 307/308 preserve method and body. + let status_code = resp.status().as_u16(); + if status_code == 301 || status_code == 302 || status_code == 303 { + current_method = reqwest::Method::GET; + current_body.clear(); + } + + current_url = next_url.to_string(); + }; + + let status = response.status().as_u16(); let resp_headers: std::collections::HashMap = response .headers()