From bb78b1c06de5b73b951691460f81a3a2ec6e7f80 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 20 May 2026 13:59:39 +0000 Subject: [PATCH] fix(s3): sandbox stored XSS via download response headers (#9263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] fix(s3): sandbox stored XSS via download response headers Reported chain: a workspace user uploads xss.html via apps_u/upload_s3_file with content_type=text/html&content_disposition=inline; when an admin clicks the resulting download URL the browser renders the attacker page in Windmill's origin and can escalate via the SameSite=Lax session cookie. Fix on the download side only — leaves upload semantics unchanged so existing integrations are not affected: - download_s3_file_internal (used by apps_u/download_s3_file and job_helpers/download_s3_file) emits X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox on every response (EE). - The HTTP static-asset trigger emits the same headers on single-file responses. Static-website responses keep their existing semantics (CSP sandbox would break a legitimate static site); restricting write access to those buckets remains the documented mitigation. Sandbox loads any HTML/SVG into an opaque origin so the page cannot reach the viewer's cookie or /api/*. Images, PDFs, and fetch-driven previews are unaffected (browsers ignore CSP for / and for fetch responses). Companion: windmill-ee-private fix/s3-content-type-xss. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to daffe7bb81cfcaca666c61de1ee838a44d60ebc2 This commit updates the EE repository reference after PR #585 was merged in windmill-ee-private. Previous ee-repo-ref: e889b86ee1c68c2f7cf9b07ec4b8ba6e6b66a169 New ee-repo-ref: daffe7bb81cfcaca666c61de1ee838a44d60ebc2 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/src/triggers/http/handler.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index cff094ca35..142ca727a7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3489c243b0e5a8eb0dbc86e90917fbe72843573b +daffe7bb81cfcaca666c61de1ee838a44d60ebc2 diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index ccde55bab1..ffb61e31fa 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -424,6 +424,7 @@ async fn route_job( .flatten() .unwrap_or("application/octet-stream".parse().unwrap()), ); + response_headers.insert("x-content-type-options", "nosniff".parse().unwrap()); if !trigger.is_static_website { response_headers.insert( "content-disposition", @@ -443,6 +444,19 @@ async fn route_job( }, ), ); + // For single-file triggers, sandbox any HTML/SVG so it can't + // reach the viewer's session cookie. Allow-scripts/forms/etc. + // keep the opaque origin (cookies still blocked) while + // preserving JS for legitimate HTML payloads. Static-website + // triggers intentionally serve a live web app and cannot be + // sandboxed; restrict write access to those buckets at the + // workspace level. + response_headers.insert( + "content-security-policy", + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + .parse() + .unwrap(), + ); } let body_stream = axum::body::Body::from_stream(s3_object.into_stream());