Merge branch 'main' into free-token-limit

This commit is contained in:
Diego Imbert
2026-07-15 11:39:47 +02:00
committed by GitHub
2 changed files with 76 additions and 2 deletions
+72
View File
@@ -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"
);
}
}
+4 -2
View File
@@ -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");