fix(s3): sandbox stored XSS via download response headers (#9263)

* [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 <img>/<embed> and
for fetch responses).

Companion: windmill-ee-private fix/s3-content-type-xss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-05-20 13:59:39 +00:00
committed by GitHub
parent 9c28bbfd69
commit bb78b1c06d
2 changed files with 15 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
3489c243b0e5a8eb0dbc86e90917fbe72843573b
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
@@ -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());