diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 887cab0f3e..ca6ee1b59b 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -19,6 +19,25 @@ pub struct ProxyBuildArgs<'a> { pub credentials: &'a ProviderCredentials, } +/// Refuses a client-supplied proxy path that could resolve outside the provider's base +/// path once appended to it. URL parsing treats `\` as `/`, drops tabs and newlines, trims +/// spaces and ends the path at `?` or `#`, so `.\t./`, `..\`, `.. ` and `..?x` climb a +/// segment like `../`. The path arrives decoded once, so any `%` left is a second encoding +/// (`%2e`, `%2f`) that the URL parser or a gateway in front of the provider may decode again. +pub fn validate_proxy_path(path: &str) -> Result<()> { + let invalid = || Error::BadRequest(format!("Invalid AI proxy path: {path}")); + if path + .chars() + .any(|c| matches!(c, '\\' | ' ' | '?' | '#' | '%') || c.is_control()) + || path + .split('/') + .any(|segment| segment == "." || segment == "..") + { + return Err(invalid()); + } + Ok(()) +} + /// Provider-specific request produced by proxy request builders. #[derive(Clone, Debug)] pub struct ProxyRequest { @@ -271,6 +290,39 @@ mod tests { } } + #[test] + fn proxy_path_refuses_dot_segments() { + for path in [ + "chat/completions", + "v1/messages", + "models", + "openai/deployments/gpt-4.1/chat/completions", + "a..b/c", + ] { + assert!(validate_proxy_path(path).is_ok(), "{path}"); + } + for path in [ + "../key/info", + "..", + "models/../../key/info", + "./x", + "%2e%2e/key/info", + "%2E./key/info", + ".%2E/key/info", + "..\\key/info", + ".\t./key/info", + "..\n/key/info", + ".. ", + "..?x=1", + "..#f", + "%2e%2e?x", + "..%2fkey/info", + "%2e%2e%2fkey", + ] { + assert!(validate_proxy_path(path).is_err(), "{path:?}"); + } + } + #[test] fn builds_openai_compatible_proxy_request() { let credentials = credentials(AIProvider::OpenRouter, "https://openrouter.ai/api/v1/"); diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index fc7a20c57f..fcffa7b227 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -37,8 +37,8 @@ use windmill_ai::providers::{ }, }; use windmill_ai::proxy::{ - fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, - ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, validate_proxy_path, ProxyBuildArgs, + ProxyExecutionMode, ProxyRequest, }; use windmill_ai::retry::send_with_retries; use windmill_audit::{audit_oss::audit_log, ActionKind}; @@ -1103,6 +1103,7 @@ async fn global_proxy( headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { + validate_proxy_path(&ai_path)?; let provider = headers .get("X-Provider") .map(|v| v.to_str().unwrap_or("").to_string()); @@ -1221,6 +1222,7 @@ async fn proxy( headers: HeaderMap, mut body: Bytes, ) -> impl IntoResponse { + validate_proxy_path(&ai_path)?; let provider = headers .get("X-Provider") .map(|v| v.to_str().unwrap_or("").to_string());