diff --git a/backend/windmill-ai/src/utils.rs b/backend/windmill-ai/src/utils.rs index b401282df1..fab1644255 100644 --- a/backend/windmill-ai/src/utils.rs +++ b/backend/windmill-ai/src/utils.rs @@ -2,8 +2,40 @@ use crate::{ ai_providers::AIProvider, ai_types::{ContentPart, OpenAIContent}, }; +use windmill_common::utils::configure_client; lazy_static::lazy_static! { + /// Debug escape hatch: when set, `AI_HTTP_CLIENT` follows redirects again. Off by + /// default because SSRF validation on `base_url` is single-shot, so a redirect can + /// bounce a validated public host into a private/internal one (GHSA-5q4v-c4v3-v7wr). + /// Only enable to debug a non-standard/self-hosted gateway you control. + pub static ref ALLOW_AI_BASE_URL_REDIRECTS: bool = std::env::var("ALLOW_AI_BASE_URL_REDIRECTS") + .ok() + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + /// HTTP client for anything targeting a user-configured AI provider `base_url`; + /// use it instead of the shared `HTTP_CLIENT`. Redirects are governed by + /// `ALLOW_AI_BASE_URL_REDIRECTS` (disabled by default). Mirrors the API proxy + /// client (windmill-api/src/ai.rs). + pub static ref AI_HTTP_CLIENT: reqwest::Client = { + let redirect = if *ALLOW_AI_BASE_URL_REDIRECTS { + tracing::warn!( + "ALLOW_AI_BASE_URL_REDIRECTS is enabled - the AI HTTP client will follow \ + redirects, weakening SSRF protection on provider base URLs" + ); + reqwest::redirect::Policy::default() + } else { + reqwest::redirect::Policy::none() + }; + configure_client(reqwest::ClientBuilder::new() + .user_agent("windmill/beta") + .connect_timeout(std::time::Duration::from_secs(10)) + .redirect(redirect)) + .build() + .expect("Failed to build AI HTTP client - check system TLS configuration") + }; + /// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples /// Format: "header1: value1, header2: value2" pub static ref AI_HTTP_HEADERS: Vec<(String, String)> = { @@ -54,3 +86,43 @@ pub fn extract_text_content(content: &OpenAIContent) -> String { .join(""), } } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Regression test for GHSA-5q4v-c4v3-v7wr: `AI_HTTP_CLIENT` must not follow redirects. + #[tokio::test] + async fn ai_http_client_does_not_follow_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Serve a single 302 to the link-local metadata address; the client must + // surface the 302 rather than connect onward to it. + tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data\r\n\ + Content-Length: 0\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + } + }); + + let resp = AI_HTTP_CLIENT + .get(format!("http://{}/", addr)) + .send() + .await + .expect("request should succeed without following the redirect"); + + assert_eq!( + resp.status().as_u16(), + 302, + "AI HTTP client must surface the redirect response, not follow it" + ); + } +} diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 2161504725..879f560916 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -26,7 +26,7 @@ use windmill_ai::{ providers::create_query_builder, query_builder::{BuildRequestArgs, ParsedResponse}, types::*, - utils::{should_use_structured_output_tool, AI_HTTP_HEADERS}, + utils::{should_use_structured_output_tool, AI_HTTP_CLIENT, AI_HTTP_HEADERS}, }; use windmill_common::{ cache, @@ -986,7 +986,9 @@ pub async fn run_agent( // Helper to build HTTP request with headers let build_http_request = |body: String| { - let mut req = HTTP_CLIENT + // `endpoint` derives from the user-controlled provider base_url: use + // AI_HTTP_CLIENT, not the shared HTTP_CLIENT. See AI_HTTP_CLIENT. + let mut req = AI_HTTP_CLIENT .post(&endpoint) .timeout(timeout) .header("Content-Type", "application/json");