This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/29260-to-camel-4.22.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit c3fe83340242f52e7323c2c73f73dbdc94525c75 Author: Guillaume Nodet <[email protected]> AuthorDate: Thu Nov 5 17:15:32 2026 +0000 [backport camel-4.22.x] CAMEL-26281: camel-whatsapp - verify the X-Hub-Signature-256 signature of webhook events by default --- .../apache/camel/catalog/components/whatsapp.json | 4 +- .../camel/catalog/docs/whatsapp-component.adoc | 8 +- .../whatsapp/WhatsAppEndpointConfigurer.java | 6 + .../whatsapp/WhatsAppEndpointUriFactory.java | 3 +- .../apache/camel/component/whatsapp/whatsapp.json | 4 +- .../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 | 290 +++++++++++++++++++++ .../whatsapp/WhatsAppWebhookStartupTest.java | 99 +++++++ .../component/whatsapp/WhatsAppWebhookTest.java | 48 +++- .../java/org/apache/camel/util/SecurityUtils.java | 1 + .../dsl/WhatsAppEndpointBuilderFactory.java | 44 +++- 14 files changed, 545 insertions(+), 25 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..b15c3df41ed2 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,8 @@ "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" } + "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..b15c3df41ed2 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,8 @@ "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" } + "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 a120824636cf..c32037830fe7 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 @@ -34,6 +34,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; @@ -57,8 +58,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).toString())) { + if (!event) { // Parse params from the webhook verification request Map<String, String> queryParams = parseQueryParam(exchange); @@ -89,12 +91,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; } @@ -105,7 +107,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); }); @@ -116,7 +126,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 new file mode 100644 index 000000000000..d9e329512fc9 --- /dev/null +++ b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java @@ -0,0 +1,290 @@ +/* + * 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 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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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); + configuration.setWebhookSecret(SECRET); + processor = new WhatsAppWebhookProcessor(route, configuration); + context.start(); + } + + @AfterEach + void tearDown() throws Exception { + context.close(); + } + + @Test + void emptyQueryValueAndMissingEqualsSign() { + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, "hub.challenge=&hub.mode=subscribe&flag"); + + assertThat(WhatsAppWebhookProcessor.parseQueryParam(exchange)) + .containsExactlyInAnyOrderEntriesOf(Map.of("hub.challenge", "", "hub.mode", "subscribe", "flag", "")); + + process(exchange); + // hub.verify_token is missing + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400); + } + + @Test + void verifiedWithEmptyChallenge() { + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, + "hub.mode=subscribe&hub.verify_token=my%20token%26more&hub.challenge="); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo(""); + assertThat(calls).hasValue(1); + } + + @Test + void encodedTokenAndChallengeAreDecoded() { + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, + "hub.mode=subscribe&hub.verify_token=my+token%26more&hub.challenge=a%3Db%20c"); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo("a=b c"); + assertThat(calls).hasValue(1); + } + + @Test + void rawQueryIsPreferred() { + // netty-http sets the decoded query in CamelHttpQuery and the raw query in CamelHttpRawQuery + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, + "hub.mode=subscribe&hub.verify_token=my token&more&hub.challenge=100%"); + exchange.getIn().setHeader(Exchange.HTTP_RAW_QUERY, + "hub.mode=subscribe&hub.verify_token=my%20token%26more&hub.challenge=100%25"); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo("100%"); + assertThat(calls).hasValue(1); + } + + @Test + void wrongTokenIsRejected() { + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, + "hub.mode=subscribe&hub.verify_token=my%20token&hub.challenge=123"); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(403); + assertThat(exchange.getMessage().getBody()).isNull(); + } + + @Test + void invalidEncodingIsBadRequest() { + Exchange exchange = verificationRequest(Exchange.HTTP_QUERY, + "hub.mode=subscribe&hub.verify_token=%zz&hub.challenge=123"); + + process(exchange); + + assertThat(exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400); + assertThat(exchange.getException()).isNull(); + assertThat(calls).hasValue(0); + } + + @Test + void missingHttpMethodIsAnEvent() throws Exception { + Exchange exchange = new DefaultExchange(context); + exchange.getIn().setBody(EVENT); + exchange.getIn().setHeader("X-Hub-Signature-256", sign(EVENT, SECRET)); + + process(exchange); + + assertThat(exchange.getException()).isNull(); + assertThat(routed).hasValue(EVENT); + assertThat(calls).hasValue(1); + } + + @Test + 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(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"); + exchange.getIn().setHeader(header, query); + return exchange; + } + + private void process(Exchange exchange) { + processor.process(exchange, doneSync -> { + }); + } +} 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 7b34a56d8b36..5eb7501f3967 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,6 +17,12 @@ 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; @@ -31,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(); @@ -74,17 +82,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() { @@ -98,7 +131,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. *
