From 4b06881918b76c5a411cc70b318e46efcc1393a7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 30 May 2026 11:33:27 +0200 Subject: [PATCH] fix(ai): validate token_url for SSRF in OAuth credentials flow (#9385) get_token_using_oauth resolved the AI OAuth resource's token_url and POSTed to it without any SSRF validation, while base_url is validated in get_base_url. A workspace member with resources:write could point token_url at an internal/metadata address (e.g. 169.254.169.254), turning the server into an authenticated blind SSRF probe. Validate the resolved token_url with validate_url_for_ssrf before the request, gated behind the same ALLOW_PRIVATE_AI_BASE_URLS opt-in as base_url so private AI deployments keep working consistently for both URL fields. ALLOW_PRIVATE_AI_BASE_URLS is now pub so windmill-api can reuse it instead of re-parsing the env var. --- backend/windmill-ai/src/ai_providers.rs | 2 +- backend/windmill-api/src/ai.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index fb29454ca5..52d04910de 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -20,7 +20,7 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 3196c9df0c..0e096eb798 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -305,6 +305,22 @@ async fn get_token_using_oauth( resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + // Validate the resolved token_url against SSRF rules before issuing the request, + // mirroring the protection applied to base_url in `get_base_url` (same + // ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could + // point token_url at an internal/metadata address. + if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; + windmill_common::ssrf::validate_url_for_ssrf(&resource.token_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), + })?; + } let mut params = HashMap::new(); params.insert("grant_type", "client_credentials"); params.insert("scope", "https://cognitiveservices.azure.com/.default");