This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch camel-4.22.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4c3f56f998645c5d7200326ba6bffc785489872f 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.22.x: the parts that do not exist on this branch are left out (the body type annotation, the YAML tab in the docs, the security option owners map), 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 | 5 +- .../camel/catalog/docs/whatsapp-component.adoc | 8 +- .../whatsapp/WhatsAppEndpointConfigurer.java | 6 + .../whatsapp/WhatsAppEndpointUriFactory.java | 3 +- .../apache/camel/component/whatsapp/whatsapp.json | 5 +- .../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 +++++-- .../java/org/apache/camel/util/SecurityUtils.java | 1 + .../dsl/WhatsAppEndpointBuilderFactory.java | 44 ++++++- 14 files changed, 387 insertions(+), 33 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 ca98a234bfad..d8b514e02855 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 @@ -46,7 +46,8 @@ "webhookPath": { "index": 5, "kind": "parameter", "displayName": "Webhook Path", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "webhook", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook path" }, "whatsappService": { "index": 6, "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": 7, "kind": "parameter", "displayName": "Authorization Token", "group": "security", "label": "security", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The authorization access token taken from w [...] - "webhookSecret": { "index": 8, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound [...] - "webhookVerifyToken": { "index": 9, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" } + "webhookSecret": { "index": 8, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound [...] + "webhookVerification": { "index": 9, "kind": "parameter", "displayName": "Webhook Verification", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:dev", "insecureValue": "false", "defaultValue": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Whether, in webh [...] + "webhookVerifyToken": { "index": 10, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/whatsapp-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/whatsapp-component.adoc index 0405c63bf012..ab5cfb1cd062 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/whatsapp-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/whatsapp-component.adoc @@ -101,7 +101,7 @@ In Java DSL: ._Java-only: uses fromF with string formatting_ [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 @@ -109,4 +109,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/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 a7810bed287e..1554f709fc90 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 @@ -24,7 +24,7 @@ public class WhatsAppEndpointUriFactory extends org.apache.camel.support.compone private static final Set<String> ENDPOINT_IDENTITY_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"); @@ -33,6 +33,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 ca98a234bfad..d8b514e02855 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 @@ -46,7 +46,8 @@ "webhookPath": { "index": 5, "kind": "parameter", "displayName": "Webhook Path", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "webhook", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook path" }, "whatsappService": { "index": 6, "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": 7, "kind": "parameter", "displayName": "Authorization Token", "group": "security", "label": "security", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The authorization access token taken from w [...] - "webhookSecret": { "index": 8, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound [...] - "webhookVerifyToken": { "index": 9, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" } + "webhookSecret": { "index": 8, "kind": "parameter", "displayName": "Webhook Secret", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "The app secret used to verify the X-Hub-Signature-256 signature of inbound [...] + "webhookVerification": { "index": 9, "kind": "parameter", "displayName": "Webhook Verification", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:dev", "insecureValue": "false", "defaultValue": true, "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Whether, in webh [...] + "webhookVerifyToken": { "index": 10, "kind": "parameter", "displayName": "Webhook Verify Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.whatsapp.WhatsAppConfiguration", "configurationField": "configuration", "description": "Webhook verify token" } } } diff --git a/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc b/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc index 0405c63bf012..ab5cfb1cd062 100644 --- a/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc +++ b/components/camel-whatsapp/src/main/docs/whatsapp-component.adoc @@ -101,7 +101,7 @@ In Java DSL: ._Java-only: uses fromF with string formatting_ [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 @@ -109,4 +109,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 e252a7e1e592..e9dc888c163f 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", security = "secret") 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", security = "insecure:dev", insecureValue = "false") + 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 564df7cb7668..fbbf372ddfe9 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; @@ -32,6 +37,8 @@ import org.junit.jupiter.api.extension.RegisterExtension; public class WhatsAppWebhookTest extends WhatsAppTestSupport { + private static final String WEBHOOK_SECRET = "my-app-secret"; + @RegisterExtension AvailablePortFinder.Port port = AvailablePortFinder.find(); @@ -90,17 +97,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)); } protected WhatsAppApiConfig getWhatsAppApiConfig() { @@ -114,7 +146,8 @@ public class WhatsAppWebhookTest extends WhatsAppTestSupport { public void configure() { restConfiguration().host("localhost").port(port.getPort()); - 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/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java b/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java index f0846d4e4f9b..33cfc1c638d9 100644 --- a/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java +++ b/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java @@ -91,6 +91,7 @@ public final class SecurityUtils { map.put("validatecertificates", new SecurityOption(INSECURE_SSL, VALUE_FALSE)); map.put("verifyssl", new SecurityOption(INSECURE_SSL, VALUE_FALSE)); map.put("webhooksecrettokenenabled", new SecurityOption(INSECURE_DEV, VALUE_FALSE)); + map.put("webhookverification", new SecurityOption(INSECURE_DEV, VALUE_FALSE)); map.put("x509hostnameverifier", new SecurityOption(INSECURE_SSL, "")); // SECURITY-OPTIONS: END SECURITY_OPTIONS = Collections.unmodifiableMap(map); 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 5964e997483f..537f5faa8069 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; + } /** * Webhook verify token. *
