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) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-02 14:47:45 +02:00
committed by GitHub
parent 9e6559a6f6
commit ab2a15b2a8
@@ -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]