From 0732e4ee602c1d64d92af0b944f2e3cb1fd4ca2c Mon Sep 17 00:00:00 2001 From: Cristiano Betta Date: Thu, 1 Oct 2026 17:17:44 +0200 Subject: [PATCH 1/2] fix(webhooks): compare signatures in constant time The old comparison stopped at the first character that differed, so how long a rejection took showed how much of a guessed signature was right. Co-Authored-By: Claude Opus 5.5 --- src/main/java/com/gr4vy/sdk/Webhooks.java | 7 ++++++- src/test/java/com/gr4vy/sdk/WebhooksTest.java | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gr4vy/sdk/Webhooks.java b/src/main/java/com/gr4vy/sdk/Webhooks.java index 0d8b0e6e..d0716b72 100644 --- a/src/main/java/com/gr4vy/sdk/Webhooks.java +++ b/src/main/java/com/gr4vy/sdk/Webhooks.java @@ -1,6 +1,7 @@ package com.gr4vy.sdk; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Arrays; import java.util.List; @@ -69,7 +70,11 @@ public static void verifyWebhook(String payload, throw new IllegalArgumentException("Invalid secret"); } - if(!signatures.contains(expectedSignature)) { + // Compare in constant time, so the check doesn't leak how much of one matched. + byte[] expected = expectedSignature.getBytes(StandardCharsets.UTF_8); + boolean matched = signatures.stream() + .anyMatch(signature -> MessageDigest.isEqual(signature.getBytes(StandardCharsets.UTF_8), expected)); + if(!matched) { throw new IllegalArgumentException("No matching signature found"); } } diff --git a/src/test/java/com/gr4vy/sdk/WebhooksTest.java b/src/test/java/com/gr4vy/sdk/WebhooksTest.java index 39c99dd5..78f8fd48 100644 --- a/src/test/java/com/gr4vy/sdk/WebhooksTest.java +++ b/src/test/java/com/gr4vy/sdk/WebhooksTest.java @@ -78,4 +78,23 @@ void testVerifyWebhookMissingTimestampHeader() { assertEquals(thrown.getMessage(), "Missing header values"); } + + @Test + void testVerifyWebhookAcceptsAValidSignatureInAnyPosition() { + String header = "other,78aca0c78005107a654a957b8566fa6e0e5e06aea92d7da72a6da9e5a690d013"; + + assertDoesNotThrow(() -> Webhooks.verifyWebhook(payload, secret, header, 0, timestampHeader)); + } + + @Test + void testVerifyWebhookRejectsANearlyMatchingSignature() { + String header = "78aca0c78005107a654a957b8566fa6e0e5e06aea92d7da72a6da9e5a690d014"; + + IllegalArgumentException thrown = assertThrows( + IllegalArgumentException.class, + () -> Webhooks.verifyWebhook(payload, secret, header, 0, timestampHeader) + ); + + assertEquals(thrown.getMessage(), "No matching signature found"); + } } From 212bebb34c31a60d08e6ad30d0e278bb6eba15cb Mon Sep 17 00:00:00 2001 From: Cristiano Betta Date: Fri, 2 Oct 2026 16:53:27 +0100 Subject: [PATCH 2/2] fix(webhooks): time the comparison by the expected signature's length MessageDigest.isEqual takes time in proportion to its first argument, which was the signature from the header. Put the fixed-length expected one first. Co-Authored-By: Claude Opus 5.5 --- src/main/java/com/gr4vy/sdk/Webhooks.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gr4vy/sdk/Webhooks.java b/src/main/java/com/gr4vy/sdk/Webhooks.java index d0716b72..532b81cb 100644 --- a/src/main/java/com/gr4vy/sdk/Webhooks.java +++ b/src/main/java/com/gr4vy/sdk/Webhooks.java @@ -73,7 +73,7 @@ public static void verifyWebhook(String payload, // Compare in constant time, so the check doesn't leak how much of one matched. byte[] expected = expectedSignature.getBytes(StandardCharsets.UTF_8); boolean matched = signatures.stream() - .anyMatch(signature -> MessageDigest.isEqual(signature.getBytes(StandardCharsets.UTF_8), expected)); + .anyMatch(signature -> MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.UTF_8))); if(!matched) { throw new IllegalArgumentException("No matching signature found"); }