From ab2a15b2a859096eabde718bf6e60289ae187118 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 2 Jun 2026 14:47:45 +0200 Subject: [PATCH] fix(triggers): prevent Zoom challenge handler from being used as a signing oracle (#9413) The Zoom URL-validation challenge handler in `handle_challenge_request` would HMAC-sign any arbitrary `plainToken` and return the result. Since Zoom webhook verification checks `HMAC-SHA256(secret, "v0:{ts}:{body}")`, an attacker could craft a `plainToken` in that format to obtain a valid signature for a forged body, bypassing authentication on a later request. Unlike the Twitch handler, the Zoom handler verifies no signature on the challenge request (Zoom's protocol does not include one). Reject any `plainToken` containing `:` or longer than 128 chars: legitimate Zoom validation tokens are short random hex strings that never contain colons, while the exploit requires the colon-bearing `v0:{ts}:{body}` format. Fixes WIN-2008 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/http_trigger_auth.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/windmill-trigger-http/src/http_trigger_auth.rs b/backend/windmill-trigger-http/src/http_trigger_auth.rs index 19766cdbc2..10927d1ef9 100644 --- a/backend/windmill-trigger-http/src/http_trigger_auth.rs +++ b/backend/windmill-trigger-http/src/http_trigger_auth.rs @@ -337,6 +337,20 @@ mod zoom { return Ok(None); } + // Prevent this challenge endpoint from being used as a signing oracle. + // Legitimate Zoom validation tokens are short random hex strings that + // never contain colons. The exploit requires crafting a plainToken in the + // `v0:{timestamp}:{body}` webhook-signing format (always containing colons) + // to obtain a valid signature for an arbitrary body. Reject any token that + // does not look like a legitimate Zoom validation token. + if zoom_request_body.payload.plain_token.contains(':') + || zoom_request_body.payload.plain_token.len() > 128 + { + return Err(AuthenticationError::InvalidChallengeResponse( + "Zoom: invalid plainToken format".to_string(), + )); + } + let hmac_signature = calculate_hmac_signature( HmacAlgorithm::Sha256, &signature_config_data.secret_key, @@ -1540,6 +1554,52 @@ mod tests { assert!(response.is_none()); } + #[test] + fn test_zoom_challenge_normal_token_succeeds() { + // A legitimate Zoom validation token is a short random alphanumeric string. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let response = handler + .handle_challenge_request(&HeaderMap::new(), &config_data, payload) + .unwrap(); + assert!(response.is_some()); + } + + #[test] + fn test_zoom_challenge_token_with_colons_rejected() { + // Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format + // would let an attacker obtain a valid webhook signature for an arbitrary body. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + + #[test] + fn test_zoom_challenge_token_too_long_rejected() { + // A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token. + let long_token = "a".repeat(129); + let payload = format!( + r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#, + long_token + ); + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + // --- Custom webhook end-to-end --- #[test]