Files
windmill/backend/windmill-ai/src/utils.rs
T
3cf7a390a3 fix: pin validated DNS address to close SSRF DNS-rebinding TOCTOU (#10303)
* [ee] fix: pin validated DNS address to close SSRF DNS-rebinding TOCTOU

validate_url_for_ssrf resolved the host, checked every address was
public, then discarded them. Callers re-used the hostname and let stock
reqwest re-resolve at connect time, so a TTL-0 DNS rebinder that answered
a public IP at check-time and an internal one (e.g. 169.254.169.254) at
connect-time slipped straight through the guard.

Return the resolved addresses as a ValidatedTarget and pin them onto the
client that connects, so validate-time and connect-time target the same
address. Covers the AI proxy and worker AI-agent base_url (the primary
readable-SSRF sink), AI OAuth token_url, MCP server + OAuth
registration/discovery/token endpoints, SAML metadata, and the WebSocket
trigger connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 22abd6d4e229f1206a13ebee8a6a9b808cd82a0d

This commit updates the EE repository reference after PR #684 was merged in windmill-ee-private.

Previous ee-repo-ref: 700feb02ef1b96758ba9425358dbebc83bc02c61

New ee-repo-ref: 22abd6d4e229f1206a13ebee8a6a9b808cd82a0d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-24 15:04:40 +02:00

295 lines
11 KiB
Rust

use crate::{
ai_providers::AIProvider,
ai_types::{ContentPart, OpenAIContent, OpenAIMessage},
};
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).
///
/// This pooled client does no DNS pinning: callers reaching a user-controlled
/// base_url must go through [`pinned_ai_client_for`] so the connect targets
/// the SSRF-validated address (DNS-rebinding TOCTOU). It is the safe default
/// only for trusted/fixed hosts.
pub static ref AI_HTTP_CLIENT: reqwest::Client = {
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"
);
}
ai_http_client_builder()
.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)> = {
std::env::var("AI_HTTP_HEADERS")
.ok()
.map(|headers_str| {
headers_str
.split(',')
.filter_map(|header| {
let parts: Vec<&str> = header.splitn(2, ':').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
let value = parts[1].trim().to_string();
if !name.is_empty() && !value.is_empty() {
Some((name, value))
} else {
None
}
} else {
None
}
})
.collect()
})
.unwrap_or_default()
};
}
/// Shared configuration for every client that targets a user-configured AI
/// provider `base_url` (the pooled [`AI_HTTP_CLIENT`] and per-request DNS-pinned
/// clients from [`pinned_ai_client_for`]). Redirects are disabled by default
/// because the SSRF check on base_url is single-shot; DNS pinning likewise only
/// covers the original host, so a redirect could bounce a validated public host
/// into a private/internal one (see `ALLOW_AI_BASE_URL_REDIRECTS`).
pub fn ai_http_client_builder() -> reqwest::ClientBuilder {
let redirect = if *ALLOW_AI_BASE_URL_REDIRECTS {
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 the client for a single outbound AI request to `url`, pinning DNS to
/// the SSRF-validated address so the connect cannot rebind to an internal IP
/// after the check (DNS-rebinding TOCTOU).
///
/// Returns the shared pooled [`AI_HTTP_CLIENT`] unchanged when there is nothing
/// to pin — an IP-literal host, or a deployment that opted into private AI
/// endpoints via `ALLOW_PRIVATE_AI_BASE_URLS`. The same opt-out and error hint
/// as `AIProvider::get_base_url` apply, so this is consistent with the
/// credential-time validation while additionally closing the connect-time window.
pub async fn pinned_ai_client_for(
url: &str,
) -> windmill_common::error::Result<std::borrow::Cow<'static, reqwest::Client>> {
use std::borrow::Cow;
use windmill_common::error::{to_anyhow, Error};
use windmill_common::ssrf::SsrfValidationError;
if *crate::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS {
return Ok(Cow::Borrowed(&AI_HTTP_CLIENT));
}
let target = windmill_common::ssrf::validate_url_for_ssrf(url)
.await
.map_err(|e| match e {
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
"{e}. If you need to use private/internal AI endpoints, \
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
)),
e => Error::from(e),
})?;
if target.pinned_addrs().is_empty() {
return Ok(Cow::Borrowed(&AI_HTTP_CLIENT));
}
let client = target
.apply_dns_pinning(ai_http_client_builder())
.build()
.map_err(to_anyhow)?;
Ok(Cow::Owned(client))
}
/// AWS Bedrock do not handle structured output query param, so we use a tool for structured output. Same for every Claude models.
pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> bool {
model.contains("claude") || provider == &AIProvider::AWSBedrock
}
/// Collect the system prompt for providers that take it in a dedicated top-level field
/// (Anthropic's `system`, OpenAI's `instructions`) instead of inline in the message list.
///
/// Every system message in `messages` is joined, since manual-memory conversations can carry
/// system messages of their own alongside the one the caller prepends from `system_prompt`.
/// `system_prompt` is a fallback used only when `messages` holds no system message, for callers
/// that pass it without prepending it. Only text content survives, so pass just the messages the
/// provider cannot render inline: Anthropic's API takes no system role at all and hands over
/// everything, while OpenAI's accepts system messages inside `input` and hands over only the
/// leading ones. Whatever is passed here must be left out of the message list the provider
/// sends, or the same prompt goes over the wire twice.
pub fn collect_system_prompt(
messages: &[OpenAIMessage],
system_prompt: Option<&str>,
) -> Option<String> {
let from_messages = messages
.iter()
.filter(|message| message.role == "system")
.filter_map(|message| message.content.as_ref().map(extract_text_content))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>();
if from_messages.is_empty() {
system_prompt
.filter(|prompt| !prompt.is_empty())
.map(str::to_string)
} else {
Some(from_messages.join("\n\n"))
}
}
/// Extract text content from OpenAIContent, joining parts with space if multiple
pub fn extract_text_content(content: &OpenAIContent) -> String {
match content {
OpenAIContent::Text(text) => text.clone(),
OpenAIContent::Parts(parts) => parts
.iter()
.filter_map(|p| {
if let ContentPart::Text { text } = p {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(""),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn message(role: &str, text: &str) -> OpenAIMessage {
OpenAIMessage {
role: role.to_string(),
content: Some(OpenAIContent::Text(text.to_string())),
..Default::default()
}
}
#[test]
fn joins_every_system_message() {
let messages = vec![
message("system", "be helpful"),
message("user", "hi"),
message("system", "be terse"),
];
assert_eq!(
collect_system_prompt(&messages, Some("be helpful")),
Some("be helpful\n\nbe terse".to_string())
);
}
/// A dedicated system field is text-only, so non-text parts cannot be carried over.
#[test]
fn keeps_only_text_parts_of_a_system_message() {
let messages = vec![OpenAIMessage {
role: "system".to_string(),
content: Some(OpenAIContent::Parts(vec![
ContentPart::Text { text: "be terse".to_string() },
ContentPart::ImageUrl {
image_url: crate::ai_types::ImageUrlData {
url: "data:image/png;base64,x".to_string(),
},
},
])),
..Default::default()
}];
assert_eq!(
collect_system_prompt(&messages, None),
Some("be terse".to_string())
);
}
/// The argument is a fallback, not an extra source: system messages win outright.
#[test]
fn prefers_system_messages_over_the_argument() {
let messages = vec![message("system", "be terse"), message("user", "hi")];
assert_eq!(
collect_system_prompt(&messages, Some("unused fallback")),
Some("be terse".to_string())
);
}
#[test]
fn falls_back_to_the_system_prompt_argument() {
let messages = vec![message("user", "hi")];
assert_eq!(
collect_system_prompt(&messages, Some("be helpful")),
Some("be helpful".to_string())
);
}
#[test]
fn treats_empty_prompts_as_absent() {
assert_eq!(
collect_system_prompt(&[message("user", "hi")], Some("")),
None
);
assert_eq!(collect_system_prompt(&[message("user", "hi")], None), None);
assert_eq!(collect_system_prompt(&[message("system", "")], None), None);
}
/// 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"
);
}
}