This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit bd46da21217eadffeca850cec9e128a2f9d3534d Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 5 18:06:03 2026 +0100 CAMEL-26281: camel-whatsapp - verify the X-Hub-Signature-256 signature of webhook events by default (#29260) In webhook mode, the signature of every event is now verified with webhookSecret, and a webhook route without webhookSecret fails to start. The new webhookVerification option (default true) turns the check off, for example in development. An event that reaches the route is answered with an empty 200 instead of the request body. Backport to camel-4.18.x: the parts that do not exist on this branch are left out (the body type annotation, the YAML tab in the docs, and the security option metadata of webhookVerification with SecurityUtils, which camel-4.18.x does not have), and the upgrade guide note stays on main. (cherry picked from commit 39039a5ce04062c7552a2227b23c6dda70ba6475) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/components/whatsapp.json | 3 +- .../whatsapp/WhatsAppEndpointConfigurer.java | 6 + .../whatsapp/WhatsAppEndpointUriFactory.java | 3 +- .../apache/camel/component/whatsapp/whatsapp.json | 3 +- .../src/main/docs/whatsapp-component.adoc | 8 +- .../component/whatsapp/WhatsAppConfiguration.java | 20 ++- .../camel/component/whatsapp/WhatsAppEndpoint.java | 11 ++ .../whatsapp/WhatsAppWebhookProcessor.java | 24 ++-- .../whatsapp/WhatsAppWebhookProcessorTest.java | 139 ++++++++++++++++++++- .../whatsapp/WhatsAppWebhookStartupTest.java | 99 +++++++++++++++ .../component/whatsapp/WhatsAppWebhookTest.java | 47 +++++-- .../dsl/WhatsAppEndpointBuilderFactory.java | 44 ++++++- 12 files changed, 377 insertions(+), 30 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/whatsapp.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/whatsapp.json index e6a569255bb7..3402745d765d 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/whatsapp.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/whatsapp.json @@ -47,6 +47,7 @@ "webhookVerifyToken": { "index": 6, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" }, "whatsappService": { "index": 7, "kind": "parameter", "displayName": "Whatsapp Service", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.whatsapp.WhatsAppService", "deprecated": false, "autowired": false, "secret": false, "description": "WhatsApp service implementation" }, "authorizationToken": { "index": 8, "kind": "parameter", "displayName": "Authorization Token", "group": "security", "label": "security", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The authorization access token taken from whatsapp-business dashb [...] - "webhookSecret": { "index": 9, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound webhook event payloads [...] + "webhookSecret": { "index": 9, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound webhook event payloads [...] + "webhookVerification": { "index": 10, "kind": "parameter", "displayName": "Webhook Verification", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Whether, in webhook mode, the X-Hub-Signature-256 signature of every [...] } } diff --git a/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointConfigurer.java b/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointConfigurer.java index d4d2aee025a5..b65bb506880c 100644 --- a/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointConfigurer.java +++ b/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointConfigurer.java @@ -37,6 +37,8 @@ public class WhatsAppEndpointConfigurer extends PropertyConfigurerSupport implem case "webhookPath": target.getConfiguration().setWebhookPath(property(camelContext, java.lang.String.class, value)); return true; case "webhooksecret": case "webhookSecret": target.getConfiguration().setWebhookSecret(property(camelContext, java.lang.String.class, value)); return true; + case "webhookverification": + case "webhookVerification": target.getConfiguration().setWebhookVerification(property(camelContext, boolean.class, value)); return true; case "webhookverifytoken": case "webhookVerifyToken": target.getConfiguration().setWebhookVerifyToken(property(camelContext, java.lang.String.class, value)); return true; case "whatsappservice": @@ -62,6 +64,8 @@ public class WhatsAppEndpointConfigurer extends PropertyConfigurerSupport implem case "webhookPath": return java.lang.String.class; case "webhooksecret": case "webhookSecret": return java.lang.String.class; + case "webhookverification": + case "webhookVerification": return boolean.class; case "webhookverifytoken": case "webhookVerifyToken": return java.lang.String.class; case "whatsappservice": @@ -88,6 +92,8 @@ public class WhatsAppEndpointConfigurer extends PropertyConfigurerSupport implem case "webhookPath": return target.getConfiguration().getWebhookPath(); case "webhooksecret": case "webhookSecret": return target.getConfiguration().getWebhookSecret(); + case "webhookverification": + case "webhookVerification": return target.getConfiguration().isWebhookVerification(); case "webhookverifytoken": case "webhookVerifyToken": return target.getConfiguration().getWebhookVerifyToken(); case "whatsappservice": diff --git a/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointUriFactory.java b/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointUriFactory.java index 48e36f6d02c5..046b513646ff 100644 --- a/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointUriFactory.java +++ b/components/camel-whatsapp/src/generated/java/org/apache/camel/component/whatsapp/WhatsAppEndpointUriFactory.java @@ -23,7 +23,7 @@ public class WhatsAppEndpointUriFactory extends org.apache.camel.support.compone private static final Set<String> SECRET_PROPERTY_NAMES; private static final Map<String, String> MULTI_VALUE_PREFIXES; static { - Set<String> props = new HashSet<>(10); + Set<String> props = new HashSet<>(11); props.add("apiVersion"); props.add("authorizationToken"); props.add("baseUri"); @@ -32,6 +32,7 @@ public class WhatsAppEndpointUriFactory extends org.apache.camel.support.compone props.add("phoneNumberId"); props.add("webhookPath"); props.add("webhookSecret"); + props.add("webhookVerification"); props.add("webhookVerifyToken"); props.add("whatsappService"); PROPERTY_NAMES = Collections.unmodifiableSet(props); diff --git a/components/camel-whatsapp/src/generated/resources/META-INF/org/apache/camel/component/whatsapp/whatsapp.json b/components/camel-whatsapp/src/generated/resources/META-INF/org/apache/camel/component/whatsapp/whatsapp.json index e6a569255bb7..3402745d765d 100644 --- a/components/camel-whatsapp/src/generated/resources/META-INF/org/apache/camel/component/whatsapp/whatsapp.json +++ b/components/camel-whatsapp/src/generated/resources/META-INF/org/apache/camel/component/whatsapp/whatsapp.json @@ -47,6 +47,7 @@ "webhookVerifyToken": { "index": 6, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" }, "whatsappService": { "index": 7, "kind": "parameter", "displayName": "Whatsapp Service", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.whatsapp.WhatsAppService", "deprecated": false, "autowired": false, "secret": false, "description": "WhatsApp service implementation" }, "authorizationToken": { "index": 8, "kind": "parameter", "displayName": "Authorization Token", "group": "security", "label": "security", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The authorization access token taken from whatsapp-business dashb [...] - "webhookSecret": { "index": 9, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound webhook event payloads [...] + "webhookSecret": { "index": 9, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound webhook event payloads [...] + "webhookVerification": { "index": 10, "kind": "parameter", "displayName": "Webhook Verification", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Whether, in webhook mode, the X-Hub-Signature-256 signature of every [...] } } diff --git a/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc b/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc index 4377ad4f1836..6a49396d2c18 100644 --- a/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc +++ b/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc @@ -102,7 +102,7 @@ In Java DSL: [source,java] --------------------------------------------------------- -fromF("webhook:whatsapp:%s?authorizationToken=%s&webhookVerifyToken=%s", "<phoneNumberId>", "<AuthorizationToken>", "<webhookVerifyToken>").log("${body}") +fromF("webhook:whatsapp:%s?authorizationToken=%s&webhookVerifyToken=%s&webhookSecret=%s", "<phoneNumberId>", "<AuthorizationToken>", "<webhookVerifyToken>", "<appSecret>").log("${body}") --------------------------------------------------------- You can follow the @@ -110,4 +110,10 @@ https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks[s to enable and configure the webhook. The webhook component will expose an endpoint that can be used into the whatsapp administration console. +WhatsApp signs every event it posts to the webhook with the app secret of the Meta app, in the `X-Hub-Signature-256` +header. Set `webhookSecret` to that app secret (from the Meta app dashboard): an event without a valid signature is +answered with HTTP 403 and does not reach the route, and an event that reaches the route is answered with an empty +HTTP 200. A webhook route without `webhookSecret` fails to start. To accept the events without verifying their +signature, for example in development, set `webhookVerification` to `false`. + Refer to the xref:webhook-component.adoc[camel-webhook component] documentation for instructions on how to set it. diff --git a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppConfiguration.java b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppConfiguration.java index 419ddcd8ba92..3a1e852ade4c 100644 --- a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppConfiguration.java +++ b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppConfiguration.java @@ -47,12 +47,18 @@ public class WhatsAppConfiguration { private String webhookPath = "camel-whatsapp/webhook"; @UriParam(description = "The app secret used to verify the X-Hub-Signature-256 signature of inbound webhook event" - + " payloads (from the Meta/WhatsApp app dashboard). When set, event callbacks with a missing" - + " or invalid signature are rejected with HTTP 403; when not set, no signature verification" - + " is performed.", + + " payloads (from the Meta/WhatsApp app dashboard). An event callback with a missing or invalid" + + " signature is rejected with HTTP 403. Required in webhook mode, unless webhookVerification is" + + " false.", label = "security", secret = true) private String webhookSecret; + @UriParam(description = "Whether, in webhook mode, the X-Hub-Signature-256 signature of every event callback is verified" + + " with webhookSecret. When true, the webhook route fails to start without webhookSecret. Set it to" + + " false to accept event callbacks without verifying their signature, for example in development.", + label = "security", defaultValue = "true") + private boolean webhookVerification = true; + public WhatsAppConfiguration() { } @@ -112,6 +118,14 @@ public class WhatsAppConfiguration { this.webhookSecret = webhookSecret; } + public boolean isWebhookVerification() { + return webhookVerification; + } + + public void setWebhookVerification(boolean webhookVerification) { + this.webhookVerification = webhookVerification; + } + @Override public String toString() { return "WhatsAppConfiguration{" + "authorizationToken='" + authorizationToken + '\'' + ", baseUri='" + baseUri + '\'' diff --git a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppEndpoint.java b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppEndpoint.java index 9902ce8bfabc..963b52a89bd3 100644 --- a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppEndpoint.java +++ b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppEndpoint.java @@ -31,6 +31,7 @@ import org.apache.camel.component.whatsapp.service.WhatsAppServiceRestAPIAdapter import org.apache.camel.spi.UriEndpoint; import org.apache.camel.spi.UriParam; import org.apache.camel.support.ScheduledPollEndpoint; +import org.apache.camel.util.ObjectHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -97,6 +98,16 @@ public class WhatsAppEndpoint extends ScheduledPollEndpoint implements WebhookCa @Override public Processor createWebhookHandler(Processor next) { + if (!configuration.isWebhookVerification()) { + LOG.warn("Webhook signature verification is disabled (webhookVerification=false): the events posted to {} are" + + " not verified", + this); + } else if (ObjectHelper.isEmpty(configuration.getWebhookSecret())) { + throw new IllegalArgumentException( + "webhookSecret must be configured in webhook mode: it is the app secret of the Meta app dashboard, used to" + + " verify the X-Hub-Signature-256 signature of the events. Set" + + " webhookVerification=false to accept the events without verifying them"); + } return new WhatsAppWebhookProcessor(next, configuration); } diff --git a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessor.java b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessor.java index 0a8bbe0e7e57..9c592e8bc19c 100644 --- a/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessor.java +++ b/components/camel-whatsapp/src/main/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessor.java @@ -35,6 +35,7 @@ import org.apache.camel.Exchange; import org.apache.camel.Processor; import org.apache.camel.support.AsyncProcessorConverterHelper; import org.apache.camel.support.AsyncProcessorSupport; +import org.apache.camel.util.ObjectHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,8 +59,9 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { @Override public boolean process(Exchange exchange, AsyncCallback callback) { String content; + boolean event = !"GET".equalsIgnoreCase(exchange.getIn().getHeader(Exchange.HTTP_METHOD, String.class)); - if ("GET".equalsIgnoreCase(exchange.getIn().getHeader(Exchange.HTTP_METHOD, String.class))) { + if (!event) { // Parse params from the webhook verification request Map<String, String> queryParams; try { @@ -98,12 +100,12 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { return true; } - // When a webhook secret is configured, verify the X-Hub-Signature-256 payload signature - String webhookSecret = configuration.getWebhookSecret(); - if (webhookSecret != null && !isValidSignature(requestBody, - exchange.getIn().getHeader(HUB_SIGNATURE_HEADER, String.class), webhookSecret)) { + // verify the X-Hub-Signature-256 payload signature, unless verification is turned off + if (configuration.isWebhookVerification() && !isValidSignature(requestBody, + exchange.getIn().getHeader(HUB_SIGNATURE_HEADER, String.class), configuration.getWebhookSecret())) { LOG.warn("Rejecting WhatsApp webhook event with missing or invalid {} signature", HUB_SIGNATURE_HEADER); exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 403); + exchange.getMessage().setBody(null); callback.done(true); return true; } @@ -114,7 +116,15 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { exchange.getMessage().setBody(content); return next.process(exchange, doneSync -> { - exchange.getMessage().setBody(content); + if (event) { + // an event is answered with an empty 200 + exchange.getMessage().setBody(null); + if (!exchange.isFailed() && exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE) == null) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 200); + } + } else { + exchange.getMessage().setBody(content); + } callback.done(doneSync); }); @@ -125,7 +135,7 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { * configured webhook secret, using a constant-time comparison. */ static boolean isValidSignature(byte[] payload, String signatureHeader, String secret) { - if (signatureHeader == null) { + if (signatureHeader == null || ObjectHelper.isEmpty(secret)) { return false; } try { diff --git a/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java index 0ca0c7a1acd9..d9e329512fc9 100644 --- a/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java +++ b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java @@ -16,11 +16,18 @@ */ package org.apache.camel.component.whatsapp; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; +import org.apache.camel.Processor; import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.support.DefaultExchange; import org.junit.jupiter.api.AfterEach; @@ -32,17 +39,25 @@ import static org.assertj.core.api.Assertions.assertThat; class WhatsAppWebhookProcessorTest { private static final String VERIFY_TOKEN = "my token&more"; + private static final String SECRET = "my-app-secret"; + private static final String EVENT = "{\"object\":\"whatsapp_business_account\"}"; private CamelContext context; private WhatsAppWebhookProcessor processor; private final AtomicInteger calls = new AtomicInteger(); + private final AtomicReference<Object> routed = new AtomicReference<>(); + private final Processor route = exchange -> { + calls.incrementAndGet(); + routed.set(exchange.getMessage().getBody()); + }; @BeforeEach void setUp() { context = new DefaultCamelContext(); WhatsAppConfiguration configuration = new WhatsAppConfiguration(); configuration.setWebhookVerifyToken(VERIFY_TOKEN); - processor = new WhatsAppWebhookProcessor(exchange -> calls.incrementAndGet(), configuration); + configuration.setWebhookSecret(SECRET); + processor = new WhatsAppWebhookProcessor(route, configuration); context.start(); } @@ -126,29 +141,141 @@ class WhatsAppWebhookProcessorTest { } @Test - void missingHttpMethodIsAnEvent() { + void missingHttpMethodIsAnEvent() throws Exception { Exchange exchange = new DefaultExchange(context); - exchange.getIn().setBody("{\"object\":\"whatsapp_business_account\"}"); + exchange.getIn().setBody(EVENT); + exchange.getIn().setHeader("X-Hub-Signature-256", sign(EVENT, SECRET)); process(exchange); assertThat(exchange.getException()).isNull(); - assertThat(exchange.getMessage().getBody()).isEqualTo("{\"object\":\"whatsapp_business_account\"}"); + assertThat(routed).hasValue(EVENT); assertThat(calls).hasValue(1); } @Test - void eventWithoutBody() { + void eventWithoutBody() throws Exception { Exchange exchange = new DefaultExchange(context); exchange.getIn().setHeader(Exchange.HTTP_METHOD, "POST"); + exchange.getIn().setHeader("X-Hub-Signature-256", sign("", SECRET)); process(exchange); assertThat(exchange.getException()).isNull(); - assertThat(exchange.getMessage().getBody()).isEqualTo(""); + assertThat(routed).hasValue(""); + assertThat(calls).hasValue(1); + } + + @Test + void eventWithValidSignatureIsRouted() throws Exception { + Exchange exchange = event(EVENT, sign(EVENT, SECRET)); + + process(exchange); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(200); + assertThat(routed).hasValue(EVENT); + assertThat(calls).hasValue(1); + } + + @Test + void eventWithoutSignatureIsRejectedWith403AndNotRouted() { + Exchange exchange = event(EVENT, null); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(403); + assertThat(exchange.getMessage().getBody()).isNull(); + assertThat(calls).hasValue(0); + } + + @Test + void eventWithSignatureOfAnotherSecretIsRejectedWith403AndNotRouted() throws Exception { + Exchange exchange = event(EVENT, sign(EVENT, "another-secret")); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(403); + assertThat(calls).hasValue(0); + } + + @Test + void eventIsRejectedWith403WhenTheSecretIsMissing() { + // the endpoint refuses to create the handler without a secret; the processor does not route either + WhatsAppWebhookProcessor withoutSecret = new WhatsAppWebhookProcessor(route, new WhatsAppConfiguration()); + Exchange exchange = event(EVENT, "sha256="); + + withoutSecret.process(exchange, doneSync -> { + }); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(403); + assertThat(calls).hasValue(0); + } + + @Test + void eventIsRoutedWithoutSignatureWhenVerificationIsDisabled() throws Exception { + WhatsAppEndpoint endpoint = context.getEndpoint( + "whatsapp:123?authorizationToken=token&webhookVerification=false", WhatsAppEndpoint.class); + Processor withoutVerification = endpoint.createWebhookHandler(route); + Exchange exchange = event(EVENT, null); + + withoutVerification.process(exchange); + + assertThat(exchange.getException()).isNull(); + assertThat(routed).hasValue(EVENT); assertThat(calls).hasValue(1); } + @Test + void eventReplyHasEmptyBody() throws Exception { + WhatsAppWebhookProcessor replying = new WhatsAppWebhookProcessor( + exchange -> exchange.getMessage().setBody("the result of the route"), configurationWithSecret()); + Exchange exchange = event(EVENT, sign(EVENT, SECRET)); + + replying.process(exchange, doneSync -> { + }); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(200); + assertThat(exchange.getMessage().getBody()).isNull(); + } + + @Test + void eventReplyKeepsTheResponseCodeOfTheRoute() throws Exception { + WhatsAppWebhookProcessor replying = new WhatsAppWebhookProcessor( + exchange -> exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 202), configurationWithSecret()); + Exchange exchange = event(EVENT, sign(EVENT, SECRET)); + + replying.process(exchange, doneSync -> { + }); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(202); + assertThat(exchange.getMessage().getBody()).isNull(); + } + + private static WhatsAppConfiguration configurationWithSecret() { + WhatsAppConfiguration configuration = new WhatsAppConfiguration(); + configuration.setWebhookSecret(SECRET); + return configuration; + } + + private Exchange event(String body, String signature) { + Exchange exchange = new DefaultExchange(context); + exchange.getIn().setHeader(Exchange.HTTP_METHOD, "POST"); + exchange.getIn().setBody(body); + if (signature != null) { + exchange.getIn().setHeader("X-Hub-Signature-256", signature); + } + return exchange; + } + + private static String sign(String payload, String secret) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } + private Exchange verificationRequest(String header, String query) { Exchange exchange = new DefaultExchange(context); exchange.getIn().setHeader(Exchange.HTTP_METHOD, "GET"); diff --git a/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookStartupTest.java b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookStartupTest.java new file mode 100644 index 000000000000..5405769d9ea1 --- /dev/null +++ b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookStartupTest.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.whatsapp; + +import org.apache.camel.CamelContext; +import org.apache.camel.ServiceStatus; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.test.AvailablePortFinder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WhatsAppWebhookStartupTest { + + @RegisterExtension + AvailablePortFinder.Port port = AvailablePortFinder.find(); + + private CamelContext context; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + } + + @AfterEach + void tearDown() throws Exception { + context.close(); + } + + @Test + void webhookRouteWithoutSecretFailsToStart() throws Exception { + addWebhookRoute(""); + + assertThatThrownBy(context::start) + .rootCause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("webhookSecret") + .hasMessageContaining("webhookVerification=false"); + } + + @Test + void webhookRouteWithEmptySecretFailsToStart() throws Exception { + addWebhookRoute("&webhookSecret="); + + assertThatThrownBy(context::start) + .rootCause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("webhookSecret"); + } + + @Test + void webhookRouteWithSecretStarts() throws Exception { + addWebhookRoute("&webhookSecret=my-app-secret"); + + context.start(); + + assertThat(context.getRouteController().getRouteStatus("webhook")).isEqualTo(ServiceStatus.Started); + } + + @Test + void webhookRouteWithVerificationDisabledStartsWithoutSecret() throws Exception { + addWebhookRoute("&webhookVerification=false"); + + context.start(); + + assertThat(context.getRouteController().getRouteStatus("webhook")).isEqualTo(ServiceStatus.Started); + } + + private void addWebhookRoute(String options) throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + restConfiguration().component("netty-http").host("localhost").port(port.getPort()); + + from("webhook:whatsapp:123?authorizationToken=token&webhookAutoRegister=false" + options).id("webhook") + .to("mock:endpoint"); + } + }); + } +} diff --git a/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookTest.java b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookTest.java index 6fb4c4575cde..d74be02f579d 100644 --- a/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookTest.java +++ b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookTest.java @@ -17,8 +17,13 @@ package org.apache.camel.component.whatsapp; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + import org.apache.camel.Exchange; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; @@ -31,6 +36,8 @@ import org.junit.jupiter.api.Test; public class WhatsAppWebhookTest extends WhatsAppTestSupport { + private static final String WEBHOOK_SECRET = "my-app-secret"; + private int port; @Test @@ -88,17 +95,42 @@ public class WhatsAppWebhookTest extends WhatsAppTestSupport { = ((WebhookEndpoint) context().getRoute("webhook").getConsumer().getEndpoint()).getConfiguration(); String url = config.computeFullExternalUrl(); - try (InputStream content = getClass().getClassLoader().getResourceAsStream("webhook-request.json")) { - MockEndpoint mock = getMockEndpoint("mock:endpoint"); - mock.expectedMinimumMessageCount(1); - - template().sendBodyAndHeader("netty-http:" + url, content, Exchange.CONTENT_TYPE, "application/json"); - mock.assertIsSatisfied(); + byte[] content; + try (InputStream in = getClass().getClassLoader().getResourceAsStream("webhook-request.json")) { + content = in.readAllBytes(); } MockEndpoint mock = getMockEndpoint("mock:endpoint"); + mock.expectedMinimumMessageCount(1); + + String reply = template().requestBodyAndHeaders("netty-http:" + url, content, + Map.of(Exchange.CONTENT_TYPE, "application/json", "X-Hub-Signature-256", sign(content)), String.class); mock.assertIsSatisfied(); Assertions.assertThat(mock.getExchanges().get(0).getIn().getBody(String.class)).contains("\"type\": \"text\""); + Assertions.assertThat(reply).isNullOrEmpty(); + } + + @Test + public void testWebhookEventWithoutSignatureIsRejected() throws Exception { + WebhookConfiguration config + = ((WebhookEndpoint) context().getRoute("webhook").getConsumer().getEndpoint()).getConfiguration(); + String url = config.computeFullExternalUrl(); + + MockEndpoint mock = getMockEndpoint("mock:endpoint"); + mock.expectedMessageCount(0); + + Assertions + .assertThatThrownBy(() -> template().sendBodyAndHeader("netty-http:" + url, + "{\"object\":\"whatsapp_business_account\"}", Exchange.CONTENT_TYPE, "application/json")) + .rootCause() + .hasMessageContaining("statusCode: 403"); + mock.assertIsSatisfied(); + } + + private static String sign(byte[] payload) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload)); } @Override @@ -117,7 +149,8 @@ public class WhatsAppWebhookTest extends WhatsAppTestSupport { public void configure() { restConfiguration().host("localhost").port(port); - from("webhook:whatsapp:" + phoneNumberId + "?webhookAutoRegister=false").id("webhook") + from("webhook:whatsapp:" + phoneNumberId + "?webhookAutoRegister=false&webhookSecret=" + WEBHOOK_SECRET) + .id("webhook") .convertBodyTo(String.class).to("mock:endpoint"); } }; diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/WhatsAppEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/WhatsAppEndpointBuilderFactory.java index e658a6112be8..215ef916a6f1 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/WhatsAppEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/WhatsAppEndpointBuilderFactory.java @@ -63,9 +63,9 @@ public interface WhatsAppEndpointBuilderFactory { /** * The app secret used to verify the X-Hub-Signature-256 signature of * inbound webhook event payloads (from the Meta/WhatsApp app - * dashboard). When set, event callbacks with a missing or invalid - * signature are rejected with HTTP 403; when not set, no signature - * verification is performed. + * dashboard). An event callback with a missing or invalid signature is + * rejected with HTTP 403. Required in webhook mode, unless + * webhookVerification is false. * * The option is a: <code>java.lang.String</code> type. * @@ -78,6 +78,44 @@ public interface WhatsAppEndpointBuilderFactory { doSetProperty("webhookSecret", webhookSecret); return this; } + /** + * Whether, in webhook mode, the X-Hub-Signature-256 signature of every + * event callback is verified with webhookSecret. When true, the webhook + * route fails to start without webhookSecret. Set it to false to accept + * event callbacks without verifying their signature, for example in + * development. + * + * The option is a: <code>boolean</code> type. + * + * Default: true + * Group: security + * + * @param webhookVerification the value to set + * @return the dsl builder + */ + default WhatsAppEndpointBuilder webhookVerification(boolean webhookVerification) { + doSetProperty("webhookVerification", webhookVerification); + return this; + } + /** + * Whether, in webhook mode, the X-Hub-Signature-256 signature of every + * event callback is verified with webhookSecret. When true, the webhook + * route fails to start without webhookSecret. Set it to false to accept + * event callbacks without verifying their signature, for example in + * development. + * + * The option will be converted to a <code>boolean</code> type. + * + * Default: true + * Group: security + * + * @param webhookVerification the value to set + * @return the dsl builder + */ + default WhatsAppEndpointBuilder webhookVerification(String webhookVerification) { + doSetProperty("webhookVerification", webhookVerification); + return this; + } } /**
