From d8e5a573c7ed3ff59e775efb4dd7e246193ed82d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 30 Apr 2026 01:30:42 +0000 Subject: [PATCH] fix: trim and validate ai proxy header values with clear errors Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/windmill-api/src/ai.rs | 60 +++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index a2c4ceb811..265e1592d3 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -8,18 +8,18 @@ use axum::routing::get; use axum::Json; use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router}; use futures::StreamExt; -use http::{HeaderMap, Method}; +use http::{HeaderMap, HeaderValue, Method}; use quick_cache::sync::Cache; use reqwest::{Client, RequestBuilder}; use serde::{Deserialize, Serialize}; use serde_json::{json, value::RawValue}; use std::collections::HashMap; use std::time::Duration; -use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; use windmill_common::utils::configure_client; @@ -229,6 +229,23 @@ async fn resolve_var( } } +/// Build an HTTP header value with a clear error message identifying which +/// header is malformed. Trims surrounding whitespace because workspace +/// variables (api keys, tokens) are commonly copy-pasted with trailing +/// newlines that would otherwise produce reqwest's opaque +/// "builder error: failed to parse header value". +fn try_header_value(header_name: &str, value: &str) -> Result { + HeaderValue::from_str(value.trim()).map_err(|_| { + Error::BadRequest(format!( + "Invalid value for HTTP header '{}': it contains characters that cannot be sent in an HTTP header \ + (e.g. newline, embedded whitespace, or non-ASCII byte). If this value comes from a workspace variable \ + or AI resource (api key, organization id, custom header), check it for stray whitespace, embedded \ + newlines, or non-ASCII characters.", + header_name + )) + }) +} + impl AIRequestConfig { pub async fn new( provider: &AIProvider, @@ -452,42 +469,63 @@ impl AIRequestConfig { request = request.header("anthropic-beta", "context-1m-2025-08-07"); } - // Add authentication headers + // Add authentication headers. Header values come from workspace variables + // and are validated here so the error names which header is malformed, + // instead of surfacing reqwest's opaque deferred builder error on .send(). if let Some(api_key) = self.api_key { + let api_key = api_key.trim(); if is_azure { - request = request.header("api-key", api_key.clone()) + request = request.header("api-key", try_header_value("api-key", api_key)?); } else if is_google_ai { // Note: GoogleAI requests are intercepted earlier (see the GoogleAI // handler block above) and never reach this code path. This branch // is kept as a safety net for the standard Gemini API auth format. - request = request.header("x-goog-api-key", api_key.clone()) + request = request.header( + "x-goog-api-key", + try_header_value("x-goog-api-key", api_key)?, + ); } else { - request = request.header("authorization", format!("Bearer {}", api_key.clone())) + request = request.header( + "authorization", + try_header_value("authorization", &format!("Bearer {}", api_key))?, + ); } // For standard Anthropic API, also add X-API-Key header (but not for Vertex AI) if is_anthropic && !is_anthropic_vertex { - request = request.header("X-API-Key", api_key); + request = request.header("X-API-Key", try_header_value("X-API-Key", api_key)?); } } if let Some(access_token) = self.access_token { - request = request.header("authorization", format!("Bearer {}", access_token)) + request = request.header( + "authorization", + try_header_value("authorization", &format!("Bearer {}", access_token.trim()))?, + ); } request = request.body(body); if let Some(org_id) = self.organization_id { - request = request.header("OpenAI-Organization", org_id); + request = request.header( + "OpenAI-Organization", + try_header_value("OpenAI-Organization", &org_id)?, + ); } // Apply custom headers from AI_HTTP_HEADERS environment variable for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); + request = request.header( + header_name.as_str(), + try_header_value(header_name, header_value)?, + ); } // Apply custom headers from the resource for (header_name, header_value) in &self.custom_headers { - request = request.header(header_name.as_str(), header_value.as_str()); + request = request.header( + header_name.as_str(), + try_header_value(header_name, header_value)?, + ); } Ok(request)