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 69eefc26c3b9541a61fbd51f0160616769a59539 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 2 20:50:26 2026 +0100 CAMEL-26164: camel-whatsapp - fix webhook query parsing and a missing HTTP method (#28940) … HTTP method The webhook verification request split each query pair on = and read the second part without a check, so an empty value or a pair without = failed with ArrayIndexOutOfBoundsException. Values were not URL-decoded, so an encoded hub.verify_token did not match and an encoded hub.challenge was echoed back encoded. The query is now parsed with indexOf, decoded once (from CamelHttpRawQuery when present, as netty-http sets CamelHttpQuery already decoded), and an invalid encoding gives 400. A request without the CamelHttpMethod header failed with a NullPointerException; it is now handled as an event (POST), and an event without a body is read as an empty body. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> (cherry picked from commit 9ee1e7cfade69c1dea913167643a97249007ee74) --- .../whatsapp/WhatsAppWebhookProcessor.java | 44 ++++-- .../whatsapp/WhatsAppWebhookProcessorTest.java | 163 +++++++++++++++++++++ .../component/whatsapp/WhatsAppWebhookTest.java | 16 ++ 3 files changed, 213 insertions(+), 10 deletions(-) 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..0a8bbe0e7e57 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 @@ -18,6 +18,7 @@ package org.apache.camel.component.whatsapp; import java.io.IOException; import java.io.InputStream; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; @@ -58,9 +59,17 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { public boolean process(Exchange exchange, AsyncCallback callback) { String content; - if ("GET".equalsIgnoreCase(exchange.getIn().getHeader(Exchange.HTTP_METHOD).toString())) { + if ("GET".equalsIgnoreCase(exchange.getIn().getHeader(Exchange.HTTP_METHOD, String.class))) { // Parse params from the webhook verification request - Map<String, String> queryParams = parseQueryParam(exchange); + Map<String, String> queryParams; + try { + queryParams = parseQueryParam(exchange); + } catch (IllegalArgumentException e) { + LOG.warn("Rejecting WhatsApp webhook verification request with an invalid query: {}", e.getMessage()); + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 400); + callback.done(true); + return true; + } String mode = queryParams.get(MODE_QUERY_PARAM); String token = queryParams.get(VERIFY_TOKEN_QUERY_PARAM); @@ -82,7 +91,7 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { } else { byte[] requestBody; try (InputStream body = exchange.getIn().getBody(InputStream.class)) { - requestBody = body.readAllBytes(); + requestBody = body != null ? body.readAllBytes() : new byte[0]; } catch (IOException e) { exchange.setException(e); callback.done(true); @@ -131,15 +140,30 @@ public class WhatsAppWebhookProcessor extends AsyncProcessorSupport { } } - private Map<String, String> parseQueryParam(Exchange exchange) { + /** + * Parses the query parameters of the webhook verification request. The raw query is preferred (netty-http provides + * the decoded query in {@link Exchange#HTTP_QUERY}), and every key and value is URL decoded once. A parameter with + * an empty value or without {@code =} gets an empty value. + * + * @throws IllegalArgumentException if the query has an invalid URL encoding + */ + static Map<String, String> parseQueryParam(Exchange exchange) { Map<String, String> queryParams = new HashMap<>(); - if (exchange.getIn().getHeader(Exchange.HTTP_QUERY) != null) { - String[] pairs = exchange.getIn().getHeader(Exchange.HTTP_QUERY).toString().split("&"); - for (String pair : pairs) { - String[] keyValuePair = pair.split("="); - - queryParams.put(keyValuePair[0], keyValuePair[1]); + String query = exchange.getIn().getHeader(Exchange.HTTP_RAW_QUERY, String.class); + if (query == null) { + query = exchange.getIn().getHeader(Exchange.HTTP_QUERY, String.class); + } + if (query != null) { + for (String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + int pos = pair.indexOf('='); + String key = pos == -1 ? pair : pair.substring(0, pos); + String value = pos == -1 ? "" : pair.substring(pos + 1); + queryParams.put(URLDecoder.decode(key, StandardCharsets.UTF_8), + URLDecoder.decode(value, StandardCharsets.UTF_8)); } } 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..0ca0c7a1acd9 --- /dev/null +++ b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookProcessorTest.java @@ -0,0 +1,163 @@ +/* + * 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.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +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 CamelContext context; + private WhatsAppWebhookProcessor processor; + private final AtomicInteger calls = new AtomicInteger(); + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + WhatsAppConfiguration configuration = new WhatsAppConfiguration(); + configuration.setWebhookVerifyToken(VERIFY_TOKEN); + processor = new WhatsAppWebhookProcessor(exchange -> calls.incrementAndGet(), 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() { + Exchange exchange = new DefaultExchange(context); + exchange.getIn().setBody("{\"object\":\"whatsapp_business_account\"}"); + + process(exchange); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo("{\"object\":\"whatsapp_business_account\"}"); + assertThat(calls).hasValue(1); + } + + @Test + void eventWithoutBody() { + Exchange exchange = new DefaultExchange(context); + exchange.getIn().setHeader(Exchange.HTTP_METHOD, "POST"); + + process(exchange); + + assertThat(exchange.getException()).isNull(); + assertThat(exchange.getMessage().getBody()).isEqualTo(""); + assertThat(calls).hasValue(1); + } + + 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/WhatsAppWebhookTest.java b/components/camel-whatsapp/src/test/java/org/apache/camel/component/whatsapp/WhatsAppWebhookTest.java index 7b34a56d8b36..564df7cb7668 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,7 @@ package org.apache.camel.component.whatsapp; import java.io.InputStream; +import java.util.Map; import org.apache.camel.Exchange; import org.apache.camel.RoutesBuilder; @@ -50,6 +51,21 @@ public class WhatsAppWebhookTest extends WhatsAppTestSupport { mock.assertIsSatisfied(); } + @Test + public void testWebhookRegistrationEncodedQuery() throws Exception { + WebhookConfiguration config + = ((WebhookEndpoint) context().getRoute("webhook").getConsumer().getEndpoint()).getConfiguration(); + // a parameter with an empty value, and an encoded challenge + String query = "empty=&hub.mode=subscribe&hub.verify_token=" + VERIFY_TOKEN + "&hub.challenge=a%26b"; + + MockEndpoint mock = getMockEndpoint("mock:endpoint"); + mock.expectedBodiesReceived("a&b"); + + template().sendBodyAndHeaders("netty-http:" + config.computeFullExternalUrl(), null, + Map.of(Exchange.HTTP_METHOD, "GET", Exchange.HTTP_QUERY, query)); + mock.assertIsSatisfied(); + } + @Test public void testWebhookRegistrationFailed() throws Exception { String challenge = "987";
