From 29f4cd4b6f58a29b83b84a7a9b8a439d20ade00e Mon Sep 17 00:00:00 2001 From: lucsoft Date: Sun, 12 Jul 2026 10:26:55 +0200 Subject: [PATCH] feat(triggers): serve binary HTTP-route responses via base64 transfer encoding (#10058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in `wm_content_transfer_encoding: "base64"` field to the composite result. When set (together with `wm_content_type`), result_to_response decodes the string result into raw bytes before sending it, so sync HTTP routes/webhooks can return arbitrary binary payloads (PDFs, images, ...) with any content type — not just as base64 text or via object storage. Explicit and safe: the encoding is never guessed, invalid base64 is a hard error (no silent fallback to the encoded text), an unsupported encoding is rejected, and a transfer encoding without a content type is rejected. Existing string responses are unchanged. Closes #5986 --- backend/windmill-api-jobs/src/execution.rs | 152 ++++++++++++++++++++- 1 file changed, 148 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 8ec60a69ba..b4707fca9f 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -12,6 +12,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; +use base64::Engine as _; use http::{HeaderMap, HeaderName, HeaderValue}; use hyper::StatusCode; use serde::Deserialize; @@ -254,6 +255,8 @@ pub struct WindmillCompositeResult { windmill_content_type: Option, #[serde(alias = "wm_headers")] windmill_headers: Option>, + #[serde(alias = "wm_content_transfer_encoding")] + windmill_content_transfer_encoding: Option, result: Option>, } @@ -375,11 +378,13 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result windmill_status_code, windmill_content_type, windmill_headers, + windmill_content_transfer_encoding, result: result_value, }) => { if windmill_content_type.is_none() && windmill_status_code.is_none() && windmill_headers.is_none() + && windmill_content_transfer_encoding.is_none() { return Ok(( if success { @@ -425,18 +430,54 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result let serialized_json_result = result_value .map(|val| val.get().to_owned()) .unwrap_or_else(String::new); - let serialized_result = - serde_json::from_str::(serialized_json_result.as_str()) - .ok() - .unwrap_or(serialized_json_result); + let parsed_string = + serde_json::from_str::(serialized_json_result.as_str()).ok(); + let result_is_json_string = parsed_string.is_some(); + let serialized_result = parsed_string.unwrap_or(serialized_json_result); headers.insert( http::header::CONTENT_TYPE, HeaderValue::from_str(content_type.as_str()).map_err(|err| { Error::internal_err(format!("Invalid content type {content_type}: {err}")) })?, ); + // Invalid base64 is a hard error, never a silent fallback to the encoded text. + match windmill_content_transfer_encoding.as_deref() { + Some("base64") => { + // Only a JSON string carries base64; a number/bool/null/array/object + // must not have its raw JSON text decoded into arbitrary bytes. + if !result_is_json_string { + return Err(Error::ExecutionErr( + "windmill_content_transfer_encoding \"base64\" requires result \ + to be a base64-encoded string" + .to_string(), + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(serialized_result.as_bytes()) + .map_err(|err| { + Error::ExecutionErr(format!( + "windmill_content_transfer_encoding is \"base64\" but the \ + result is not valid base64: {err}" + )) + })?; + return Ok((status_code_or_default, headers, decoded).into_response()); + } + Some(other) => { + return Err(Error::ExecutionErr(format!( + "Unsupported windmill_content_transfer_encoding \"{other}\" \ + (only \"base64\" is supported)" + ))); + } + None => {} + } return Ok((status_code_or_default, headers, serialized_result).into_response()); } + if windmill_content_transfer_encoding.is_some() { + return Err(Error::ExecutionErr( + "windmill_content_transfer_encoding requires windmill_content_type to be set" + .to_string(), + )); + } if let Some(result_value) = result_value { return Ok((status_code_or_default, headers, Json(result_value)).into_response()); } else { @@ -960,3 +1001,106 @@ pub async fn push_script_job_by_path_into_queue<'c>( Ok((uuid, resolved_delete_secs, None)) } } + +#[cfg(test)] +mod result_to_response_tests { + use super::*; + + fn raw(json: &str) -> Box { + serde_json::from_str(json).expect("valid json") + } + + async fn body_bytes(resp: Response) -> Vec { + axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body") + .to_vec() + } + + #[tokio::test] + async fn base64_result_is_decoded_to_raw_bytes() { + // 0x00 0x01 0x02 0xFF is not valid UTF-8, so it can only survive as bytes. + let bytes = vec![0u8, 1, 2, 255]; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let resp = result_to_response( + raw(&format!( + r#"{{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"{b64}"}}"# + )), + true, + ) + .expect("response"); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "application/pdf" + ); + assert_eq!(body_bytes(resp).await, bytes); + } + + #[tokio::test] + async fn invalid_base64_is_a_hard_error() { + let res = result_to_response( + raw( + r#"{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"not valid base64!!"}"#, + ), + true, + ); + assert!(res.is_err(), "invalid base64 must not silently fall back"); + } + + #[tokio::test] + async fn base64_mode_rejects_non_string_results() { + // A number/bool whose raw JSON text happens to be valid base64 (right length, + // base64 alphabet) must not be decoded into bytes — it must be a hard error. + for result in ["12345678", "true", "null", "[1,2,3]"] { + let res = result_to_response( + raw(&format!( + r#"{{"wm_content_type":"application/octet-stream","wm_content_transfer_encoding":"base64","result":{result}}}"# + )), + true, + ); + assert!( + res.is_err(), + "base64 mode must reject non-string result: {result}" + ); + } + } + + #[tokio::test] + async fn unsupported_transfer_encoding_is_rejected() { + let res = result_to_response( + raw( + r#"{"wm_content_type":"text/plain","wm_content_transfer_encoding":"gzip","result":"x"}"#, + ), + true, + ); + assert!(res.is_err()); + } + + #[tokio::test] + async fn transfer_encoding_without_content_type_is_rejected() { + let res = result_to_response( + raw(r#"{"wm_content_transfer_encoding":"base64","result":"aGk="}"#), + true, + ); + assert!(res.is_err()); + } + + #[tokio::test] + async fn string_result_is_still_served_verbatim() { + // Regression: without a transfer encoding, a string result is sent as-is + // (quotes stripped), not base64-decoded. + let resp = result_to_response( + raw(r#"{"wm_content_type":"text/html","result":"

hi

"}"#), + true, + ) + .expect("response"); + + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "text/html" + ); + assert_eq!(body_bytes(resp).await, b"

hi

"); + } +}