From f8ebc1f2525cea928debf59b5dbcb8cb0a20be28 Mon Sep 17 00:00:00 2001 From: Daniel Alfaro Date: Wed, 13 May 2026 13:30:09 -0500 Subject: [PATCH 1/2] Add WebhookSignatureValidator utility Verifies MercadoPago webhook notifications by recomputing the HMAC-SHA256 signature locally and comparing it in constant time (MessageDigest.isEqual) against the value in the x-signature header. Stateless, performs no outbound HTTP calls. - com.mercadopago.webhook.WebhookSignatureValidator - com.mercadopago.exceptions.MPInvalidWebhookSignatureException (extends MPException; checked; Lombok @Getter) - com.mercadopago.exceptions.SignatureFailureReason enum - Optional Duration tolerance for replay protection - Forward-compatible supportedVersions parameter (default List.of("v1")) - 16 JUnit 5 tests covering the canonical cases Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MPInvalidWebhookSignatureException.java | 40 +++ .../exceptions/SignatureFailureReason.java | 41 +++ .../webhook/WebhookSignatureValidator.java | 247 ++++++++++++++++++ .../WebhookSignatureValidatorTest.java | 195 ++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 src/main/java/com/mercadopago/exceptions/MPInvalidWebhookSignatureException.java create mode 100644 src/main/java/com/mercadopago/exceptions/SignatureFailureReason.java create mode 100644 src/main/java/com/mercadopago/webhook/WebhookSignatureValidator.java create mode 100644 src/test/java/com/mercadopago/webhook/WebhookSignatureValidatorTest.java diff --git a/src/main/java/com/mercadopago/exceptions/MPInvalidWebhookSignatureException.java b/src/main/java/com/mercadopago/exceptions/MPInvalidWebhookSignatureException.java new file mode 100644 index 00000000..de4878c2 --- /dev/null +++ b/src/main/java/com/mercadopago/exceptions/MPInvalidWebhookSignatureException.java @@ -0,0 +1,40 @@ +package com.mercadopago.exceptions; + +import lombok.Getter; + +/** + * Exception thrown by {@link com.mercadopago.webhook.WebhookSignatureValidator} + * when a webhook notification cannot be confirmed as originating from MercadoPago. + * + *

The exception carries enough context to support structured logging without + * exposing internal details in the HTTP response. The {@link #getReason()} + * indicates why validation failed, while {@link #getRequestId()} and + * {@link #getTimestamp()} echo the inputs that were available at the point of + * failure (when applicable) to correlate against MercadoPago's notifications + * dashboard. + */ +@Getter +public class MPInvalidWebhookSignatureException extends MPException { + + private static final long serialVersionUID = 1L; + + private final SignatureFailureReason reason; + private final String requestId; + private final String timestamp; + + /** + * Creates a new {@code MPInvalidWebhookSignatureException}. + * + * @param reason the specific failure mode that triggered the exception + * @param requestId the {@code x-request-id} header value, when available; may be {@code null} + * @param timestamp the {@code ts} value extracted from the signature header, when + * parsing reached that point; may be {@code null} + */ + public MPInvalidWebhookSignatureException( + SignatureFailureReason reason, String requestId, String timestamp) { + super("Invalid webhook signature: " + reason); + this.reason = reason; + this.requestId = requestId; + this.timestamp = timestamp; + } +} diff --git a/src/main/java/com/mercadopago/exceptions/SignatureFailureReason.java b/src/main/java/com/mercadopago/exceptions/SignatureFailureReason.java new file mode 100644 index 00000000..8986b046 --- /dev/null +++ b/src/main/java/com/mercadopago/exceptions/SignatureFailureReason.java @@ -0,0 +1,41 @@ +package com.mercadopago.exceptions; + +/** + * Enumerates the reasons why + * {@link com.mercadopago.webhook.WebhookSignatureValidator} may reject a + * MercadoPago webhook notification. + * + *

Integrators are encouraged to log this value alongside the {@code x-request-id} + * for correlation against the MercadoPago notifications dashboard. + */ +public enum SignatureFailureReason { + + /** The {@code x-signature} header was missing, empty, or whitespace. */ + MISSING_SIGNATURE_HEADER, + + /** The header did not match the expected {@code ts=...,vN=...} format. */ + MALFORMED_SIGNATURE_HEADER, + + /** The header parsed correctly but no {@code ts=} component was present. */ + MISSING_TIMESTAMP, + + /** + * No hash was found in the header for any of the supported versions. Typically + * indicates MercadoPago has migrated to a new signature version and the SDK + * needs to be upgraded. + */ + MISSING_HASH, + + /** + * The computed HMAC did not match the value in the header. Most often caused by + * an incorrect secret signature or a forged request. + */ + SIGNATURE_MISMATCH, + + /** + * The header timestamp fell outside the configured tolerance window against the + * current clock. May indicate clock drift on the integrator's server or a replay + * attack. + */ + TIMESTAMP_OUT_OF_TOLERANCE, +} diff --git a/src/main/java/com/mercadopago/webhook/WebhookSignatureValidator.java b/src/main/java/com/mercadopago/webhook/WebhookSignatureValidator.java new file mode 100644 index 00000000..9dd1e996 --- /dev/null +++ b/src/main/java/com/mercadopago/webhook/WebhookSignatureValidator.java @@ -0,0 +1,247 @@ +package com.mercadopago.webhook; + +import com.mercadopago.exceptions.MPInvalidWebhookSignatureException; +import com.mercadopago.exceptions.SignatureFailureReason; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Verifies the authenticity of an incoming MercadoPago webhook notification by + * recomputing the HMAC-SHA256 signature locally and comparing it against the value + * carried in the {@code x-signature} header. + * + *

This is a stateless, CPU-only utility. It performs no outbound HTTP calls and + * does not depend on {@link com.mercadopago.MercadoPagoConfig}; the integrator + * passes the secret signature explicitly on every call. The comparison is performed + * in constant time (via {@link MessageDigest#isEqual(byte[], byte[])}) to mitigate + * timing attacks. + * + *

On failure, the validator throws {@link MPInvalidWebhookSignatureException} + * with a specific {@link SignatureFailureReason}. The integrator should respond with + * HTTP 401 to MercadoPago in all failure cases, log the request id for correlation, + * and not expose the failure reason in the HTTP response body. + * + *

QR Code notifications are not signed by MercadoPago — do not call this + * validator for those events; they will always fail signature verification. + */ +public final class WebhookSignatureValidator { + + private static final List DEFAULT_SUPPORTED_VERSIONS = List.of("v1"); + private static final Pattern VERSION_KEY_REGEX = Pattern.compile("^v\\d+$"); + private static final Pattern DIGITS_REGEX = Pattern.compile("^\\d+$"); + + private WebhookSignatureValidator() { + // Static-only class — prevent instantiation. + } + + /** + * Validates the signature of a MercadoPago webhook notification. + * + * @param xSignature raw value of the {@code x-signature} request header + * @param xRequestId value of the {@code x-request-id} request header; may be {@code null} + * or blank, in which case the {@code request-id:} pair is omitted from the + * manifest before computing the HMAC + * @param dataId value of the {@code data.id} query parameter; may be {@code null} or + * blank, in which case the {@code id:} pair is omitted. When present, + * it is lowercased before being included in the manifest + * @param secret secret signature configured for the application in Tus Integraciones, + * used as the HMAC key + * @throws MPInvalidWebhookSignatureException when the signature is missing, malformed, or + * does not match the expected HMAC + * @throws NullPointerException when {@code secret} is {@code null} + */ + public static void validate(String xSignature, String xRequestId, String dataId, String secret) + throws MPInvalidWebhookSignatureException { + validate(xSignature, xRequestId, dataId, secret, null, null, null); + } + + /** + * Validates the signature with an optional replay-window tolerance. See + * {@link #validate(String, String, String, String, Duration, List, Supplier)} for the full + * documentation. + */ + public static void validate( + String xSignature, String xRequestId, String dataId, String secret, Duration tolerance) + throws MPInvalidWebhookSignatureException { + validate(xSignature, xRequestId, dataId, secret, tolerance, null, null); + } + + /** + * Full-featured overload that exposes all options. + * + * @param xSignature raw value of the {@code x-signature} request header + * @param xRequestId value of the {@code x-request-id} request header; may be {@code null} + * @param dataId value of the {@code data.id} query parameter; may be {@code null} + * @param secret secret signature configured for the application + * @param tolerance optional maximum allowed drift between the timestamp in the header + * and the current clock; {@code null} disables the check + * @param supportedVersions optional ordered list of signature versions to accept; defaults + * to {@code ["v1"]}. The first version found in the header is used + * @param clock optional clock supplier used for the tolerance check; intended for + * tests. Defaults to {@link Instant#now()} + * @throws MPInvalidWebhookSignatureException when the signature is missing, malformed, or + * does not match the expected HMAC + * @throws NullPointerException when {@code secret} is {@code null} + */ + public static void validate( + String xSignature, + String xRequestId, + String dataId, + String secret, + Duration tolerance, + List supportedVersions, + Supplier clock) + throws MPInvalidWebhookSignatureException { + Objects.requireNonNull(secret, "secret must not be null"); + + String signature = normalize(xSignature); + String requestId = normalize(xRequestId); + String normalizedDataId = normalize(dataId); + List versions = + (supportedVersions == null || supportedVersions.isEmpty()) + ? DEFAULT_SUPPORTED_VERSIONS + : supportedVersions; + Supplier nowSupplier = clock != null ? clock : Instant::now; + + if (signature == null) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.MISSING_SIGNATURE_HEADER, requestId, null); + } + + ParsedSignature parsed = parseSignatureHeader(signature); + + if (parsed.timestamp == null && parsed.hashes.isEmpty()) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.MALFORMED_SIGNATURE_HEADER, requestId, null); + } + + if (parsed.timestamp == null) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.MISSING_TIMESTAMP, requestId, null); + } + + if (!DIGITS_REGEX.matcher(parsed.timestamp).matches()) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.MALFORMED_SIGNATURE_HEADER, requestId, parsed.timestamp); + } + + String receivedHash = null; + for (String version : versions) { + String hash = parsed.hashes.get(version); + if (hash != null) { + receivedHash = hash; + break; + } + } + + if (receivedHash == null) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.MISSING_HASH, requestId, parsed.timestamp); + } + + String manifest = buildManifest(normalizedDataId, requestId, parsed.timestamp); + String computed = computeHmacHex(secret, manifest); + + if (!constantTimeEquals(computed, receivedHash)) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.SIGNATURE_MISMATCH, requestId, parsed.timestamp); + } + + if (tolerance != null) { + long tsMs = Long.parseLong(parsed.timestamp); + long nowMs = nowSupplier.get().toEpochMilli(); + long drift = Math.abs(nowMs - tsMs); + if (drift > tolerance.toMillis()) { + throw new MPInvalidWebhookSignatureException( + SignatureFailureReason.TIMESTAMP_OUT_OF_TOLERANCE, requestId, parsed.timestamp); + } + } + } + + private static String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static ParsedSignature parseSignatureHeader(String header) { + Map hashes = new HashMap<>(); + String ts = null; + for (String part : header.split(",")) { + int eq = part.indexOf('='); + if (eq <= 0 || eq == part.length() - 1) { + continue; + } + String key = part.substring(0, eq).trim().toLowerCase(); + String value = part.substring(eq + 1).trim(); + if (key.isEmpty() || value.isEmpty()) { + continue; + } + if ("ts".equals(key)) { + ts = value; + } else if (VERSION_KEY_REGEX.matcher(key).matches()) { + hashes.put(key, value); + } + } + return new ParsedSignature(ts, hashes); + } + + private static String buildManifest(String dataId, String requestId, String timestamp) { + StringBuilder sb = new StringBuilder(); + if (dataId != null) { + sb.append("id:").append(dataId.toLowerCase()).append(';'); + } + if (requestId != null) { + sb.append("request-id:").append(requestId).append(';'); + } + sb.append("ts:").append(timestamp).append(';'); + return sb.toString(); + } + + private static String computeHmacHex(String secret, String message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hashBytes = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hashBytes.length * 2); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + // HmacSHA256 is mandated by every JRE; failures here indicate a broken runtime. + throw new IllegalStateException("HMAC-SHA256 not available in this JVM", e); + } + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null || a.length() != b.length()) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + + /** Internal carrier for the parsed {@code x-signature} components. */ + private static final class ParsedSignature { + final String timestamp; + final Map hashes; + + ParsedSignature(String timestamp, Map hashes) { + this.timestamp = timestamp; + this.hashes = hashes; + } + } +} diff --git a/src/test/java/com/mercadopago/webhook/WebhookSignatureValidatorTest.java b/src/test/java/com/mercadopago/webhook/WebhookSignatureValidatorTest.java new file mode 100644 index 00000000..79cac0e8 --- /dev/null +++ b/src/test/java/com/mercadopago/webhook/WebhookSignatureValidatorTest.java @@ -0,0 +1,195 @@ +package com.mercadopago.webhook; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.mercadopago.exceptions.MPInvalidWebhookSignatureException; +import com.mercadopago.exceptions.SignatureFailureReason; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link WebhookSignatureValidator}. Self-contained; no network access. */ +class WebhookSignatureValidatorTest { + + private static final String SECRET = "your_secret_key_here"; + private static final String REQUEST_ID = "2066ca19-c6f1-498a-be75-1923005edd06"; + private static final String DATA_ID_RAW = "ORD01JQ4S4KY8HWQ6NA5PXB65B3D3"; + private static final String DATA_ID_LOWER = "ord01jq4s4ky8hwq6na5pxb65b3d3"; + private static final String TS = "1742505638683"; + + private static String computeHash(String dataId, String requestId, String ts, String secret) { + try { + StringBuilder mb = new StringBuilder(); + if (dataId != null && !dataId.isEmpty()) { + mb.append("id:").append(dataId.toLowerCase()).append(';'); + } + if (requestId != null && !requestId.isEmpty()) { + mb.append("request-id:").append(requestId).append(';'); + } + mb.append("ts:").append(ts).append(';'); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hashBytes = mac.doFinal(mb.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hashBytes.length * 2); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String buildHeader(String hash) { + return "ts=" + TS + ",v1=" + hash; + } + + private static String buildHeader(String hash, String ts) { + return "ts=" + ts + ",v1=" + hash; + } + + private static String validHash() { + return computeHash(DATA_ID_LOWER, REQUEST_ID, TS, SECRET); + } + + // case 1 + @Test + void happyPathLowercase() { + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(validHash()), REQUEST_ID, DATA_ID_LOWER, SECRET)); + } + + // case 2 + @Test + void uppercaseDataIdIsLowercased() { + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(validHash()), REQUEST_ID, DATA_ID_RAW, SECRET)); + } + + // case 3 + @Test + void malformedHeaderThrowsMalformed() { + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate("this-is-garbage", REQUEST_ID, DATA_ID_LOWER, SECRET)); + assertEquals(SignatureFailureReason.MALFORMED_SIGNATURE_HEADER, ex.getReason()); + assertEquals(REQUEST_ID, ex.getRequestId()); + } + + // case 4 + @Test + void missingHeaderThrowsMissingHeader() { + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate(null, REQUEST_ID, DATA_ID_LOWER, SECRET)); + assertEquals(SignatureFailureReason.MISSING_SIGNATURE_HEADER, ex.getReason()); + } + + // case 5 + @Test + void missingTsThrowsMissingTimestamp() { + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate("v1=" + validHash(), REQUEST_ID, DATA_ID_LOWER, SECRET)); + assertEquals(SignatureFailureReason.MISSING_TIMESTAMP, ex.getReason()); + } + + // case 6 + @Test + void missingV1ThrowsMissingHash() { + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate("ts=" + TS, REQUEST_ID, DATA_ID_LOWER, SECRET)); + assertEquals(SignatureFailureReason.MISSING_HASH, ex.getReason()); + assertEquals(TS, ex.getTimestamp()); + } + + // case 7 + @Test + void tamperedHashThrowsSignatureMismatch() { + String h = validHash(); + String tampered = h.substring(0, h.length() - 2) + (h.endsWith("00") ? "ff" : "00"); + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate(buildHeader(tampered), REQUEST_ID, DATA_ID_LOWER, SECRET)); + assertEquals(SignatureFailureReason.SIGNATURE_MISMATCH, ex.getReason()); + } + + // case 8 + @Test + void timestampOutsideToleranceThrows() { + String staleTs = String.valueOf(Instant.now().toEpochMilli() - 30 * 60 * 1000L); + String h = computeHash(DATA_ID_LOWER, REQUEST_ID, staleTs, SECRET); + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate( + buildHeader(h, staleTs), REQUEST_ID, DATA_ID_LOWER, SECRET, Duration.ofMinutes(5))); + assertEquals(SignatureFailureReason.TIMESTAMP_OUT_OF_TOLERANCE, ex.getReason()); + } + + @Test + void timestampWithinTolerancePasses() { + String currentTs = String.valueOf(Instant.now().toEpochMilli()); + String h = computeHash(DATA_ID_LOWER, REQUEST_ID, currentTs, SECRET); + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate( + buildHeader(h, currentTs), REQUEST_ID, DATA_ID_LOWER, SECRET, Duration.ofMinutes(5))); + } + + // case 9 + @Test + void dataIdAbsentExcludesIdPair() { + String h = computeHash(null, REQUEST_ID, TS, SECRET); + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(h), REQUEST_ID, null, SECRET)); + } + + // case 10 + @Test + void requestIdAbsentExcludesRequestIdPair() { + String h = computeHash(DATA_ID_LOWER, null, TS, SECRET); + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(h), null, DATA_ID_LOWER, SECRET)); + } + + // case 11 + @Test + void bothAbsentYieldsTsOnly() { + String h = computeHash(null, null, TS, SECRET); + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(h), "", " ", SECRET)); + } + + // case 12 + @Test + void nonPaymentTopicUsesSameAlgorithm() { + String orderId = "ord01abc123"; + String h = computeHash(orderId, REQUEST_ID, TS, SECRET); + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate(buildHeader(h), REQUEST_ID, orderId, SECRET)); + } + + @Test + void supportsV1WhenBothPresent() { + String header = "ts=" + TS + ",v1=" + validHash() + ",v2=aaaa"; + assertDoesNotThrow(() -> + WebhookSignatureValidator.validate( + header, REQUEST_ID, DATA_ID_LOWER, SECRET, null, List.of("v1"), null)); + } + + @Test + void onlyV2InHeaderOnlyV1SupportedThrowsMissingHash() { + String header = "ts=" + TS + ",v2=somehash"; + MPInvalidWebhookSignatureException ex = assertThrows(MPInvalidWebhookSignatureException.class, + () -> WebhookSignatureValidator.validate( + header, REQUEST_ID, DATA_ID_LOWER, SECRET, null, List.of("v1"), null)); + assertEquals(SignatureFailureReason.MISSING_HASH, ex.getReason()); + } + + @Test + void nullSecretThrowsNpe() { + assertThrows(NullPointerException.class, () -> + WebhookSignatureValidator.validate(buildHeader(validHash()), REQUEST_ID, DATA_ID_LOWER, null)); + } +} From 01ddd89dda972f0412b074b479f997420e91ecfa Mon Sep 17 00:00:00 2001 From: Daniel Alfaro Date: Wed, 20 May 2026 19:31:18 -0500 Subject: [PATCH 2/2] bump version --- README.md | 2 +- pom.xml | 2 +- src/main/java/com/mercadopago/MercadoPagoConfig.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fbdd6368..8bf1bb75 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ already. com.mercadopago sdk-java - 2.9.2 + 3.1.0 ``` diff --git a/pom.xml b/pom.xml index 5edb2e3f..f9cbcac5 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.mercadopago sdk-java - 2.9.2 + 3.1.0 jar Mercadopago SDK diff --git a/src/main/java/com/mercadopago/MercadoPagoConfig.java b/src/main/java/com/mercadopago/MercadoPagoConfig.java index a05b08a5..4ea94235 100644 --- a/src/main/java/com/mercadopago/MercadoPagoConfig.java +++ b/src/main/java/com/mercadopago/MercadoPagoConfig.java @@ -15,7 +15,7 @@ /** Mercado Pago configuration class. */ public class MercadoPagoConfig { - public static final String CURRENT_VERSION = "2.9.2"; + public static final String CURRENT_VERSION = "3.1.0"; public static final String PRODUCT_ID = "BC32A7VTRPP001U8NHJ0";