This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new db340d3f0b9f [backport camel-4.18.x] CAMEL-25578: camel-lra - verify
participant callbacks with an optional shared secret (#27811)
db340d3f0b9f is described below
commit db340d3f0b9f010decdd1365b66e1acbeff077a1
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 14 13:45:35 2026 +0200
[backport camel-4.18.x] CAMEL-25578: camel-lra - verify participant
callbacks with an optional shared secret (#27811)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../main/camel-main-configuration-metadata.json | 1 +
.../apache/camel/catalog/main/sensitive-keys.json | 1 +
.../service/lra/LRASagaServiceConfigurer.java | 6 +
components/camel-lra/src/main/docs/lra.adoc | 2 +
.../camel/service/lra/LRACallbackSigner.java | 94 +++++++
.../org/apache/camel/service/lra/LRAClient.java | 36 ++-
.../org/apache/camel/service/lra/LRAConstants.java | 2 +
.../apache/camel/service/lra/LRASagaRoutes.java | 92 ++++++-
.../apache/camel/service/lra/LRASagaService.java | 58 ++++
.../apache/camel/service/lra/LRAUrlBuilder.java | 8 +-
.../camel/service/lra/LRACallbackSignerTest.java | 139 ++++++++++
.../apache/camel/service/lra/LRAClientTest.java | 108 ++++++++
.../lra/LRASagaRoutesCallbackSignatureTest.java | 301 +++++++++++++++++++++
.../camel/service/lra/LRASagaRoutesTest.java | 30 ++
.../lra/LRASagaRoutesUnsignedCallbackTest.java | 136 ++++++++++
.../camel/service/lra/LRASagaServiceTest.java | 30 ++
.../camel/service/lra/LRASignedCallbacksIT.java} | 28 +-
.../camel/service/lra/LRAUrlBuilderTest.java | 110 ++++++++
.../src/main/docs/modules/eips/pages/saga-eip.adoc | 28 ++
.../main/LraConfigurationPropertiesConfigurer.java | 7 +
.../camel-main-configuration-metadata.json | 1 +
core/camel-main/src/main/docs/main.adoc | 3 +-
.../camel/main/LraConfigurationProperties.java | 24 ++
.../java/org/apache/camel/util/SensitiveUtils.java | 2 +
.../maven/packaging/UpdateSensitizeHelper.java | 4 +-
25 files changed, 1221 insertions(+), 30 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index 2f8df635ce4e..7a4973537e9b 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -190,6 +190,7 @@
{ "name": "camel.health.registryEnabled", "required": false,
"description": "Whether registry health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.health.routesEnabled", "required": false, "description":
"Whether routes health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.lra.allowedCoordinatorUrls", "required": false,
"description": "Comma-separated URLs, including the context path, of further
LRA coordinators whose LRAs may be joined, completed or compensated. By
default, only an LRA hosted by the configured coordinator is accepted. Use it
when participants reach the same coordinator under different URLs, for example
with different host names.", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
+ { "name": "camel.lra.callbackSecret", "required": false, "description":
"Secret used to sign the participant callback URLs that are enlisted with the
LRA coordinator, and to verify them when the coordinator calls back. When set,
a compensation or completion callback with a missing or invalid signature is
rejected with HTTP 403. All the instances of a participant must use the same
secret.", "sourceType": "org.apache.camel.main.LraConfigurationProperties",
"type": "string", "javaType": [...]
{ "name": "camel.lra.coordinatorContextPath", "required": false,
"description": "The context-path for the LRA coordinator. Is default
\/lra-coordinator", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string",
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret":
false },
{ "name": "camel.lra.coordinatorUrl", "required": false, "description":
"The URL for the LRA coordinator service that orchestrates the transactions",
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type":
"string", "javaType": "java.lang.String", "secret": false },
{ "name": "camel.lra.enabled", "required": false, "description": "To
enable Saga LRA", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean",
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
index c39bd3412a60..182e107489ab 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
@@ -19,6 +19,7 @@
"azureclientsecret",
"blobaccesskey",
"blobstoragesharedkeycredential",
+ "callbacksecret",
"certresourcepassword",
"clientid",
"clientsecret",
diff --git
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
index cba17a6be24b..1f09e9014471 100644
---
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
+++
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
@@ -25,6 +25,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls":
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class,
value)); return true;
+ case "callbacksecret":
+ case "callbackSecret": target.setCallbackSecret(property(camelContext,
java.lang.String.class, value)); return true;
case "camelcontext":
case "camelContext": target.setCamelContext(property(camelContext,
org.apache.camel.CamelContext.class, value)); return true;
case "coordinatorcontextpath":
@@ -44,6 +46,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls": return java.lang.String.class;
+ case "callbacksecret":
+ case "callbackSecret": return java.lang.String.class;
case "camelcontext":
case "camelContext": return org.apache.camel.CamelContext.class;
case "coordinatorcontextpath":
@@ -64,6 +68,8 @@ public class LRASagaServiceConfigurer extends
org.apache.camel.support.component
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls": return
target.getAllowedCoordinatorUrls();
+ case "callbacksecret":
+ case "callbackSecret": return target.getCallbackSecret();
case "camelcontext":
case "camelContext": return target.getCamelContext();
case "coordinatorcontextpath":
diff --git a/components/camel-lra/src/main/docs/lra.adoc
b/components/camel-lra/src/main/docs/lra.adoc
index 9ff6b4708fae..07724aaa4b91 100644
--- a/components/camel-lra/src/main/docs/lra.adoc
+++ b/components/camel-lra/src/main/docs/lra.adoc
@@ -13,6 +13,8 @@
The LRA module provides bindings of the xref:eips:saga-eip.adoc[Saga EIP] with
any https://github.com/eclipse/microprofile-lra[MicroProfile compatible LRA
Coordinator].
+The configuration of the LRA Saga Service, including how to sign the callbacks
that the LRA coordinator sends to the participant, is described in the
xref:eips:saga-eip.adoc[Saga EIP] page.
+
Maven users will need to add the following dependency to their `pom.xml`
for this component:
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
new file mode 100644
index 000000000000..a607fa9b7550
--- /dev/null
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
@@ -0,0 +1,94 @@
+/*
+ * 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.service.lra;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Map;
+import java.util.StringJoiner;
+import java.util.TreeMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Signs the participant callback URLs enlisted with the LRA coordinator, and
verifies the signature when the
+ * coordinator calls them back, using HMAC-SHA256 with the callback secret of
the LRA saga service.
+ * <p>
+ * The signature covers the callback path and every query parameter of the
URL: the step endpoint URIs and the saga
+ * options. Parameters are sorted and encoded individually, so the signature
does not depend on the order or the
+ * encoding used by the coordinator when it calls the URL back.
+ */
+final class LRACallbackSigner {
+
+ private static final String ALGORITHM = "HmacSHA256";
+
+ private final SecretKeySpec key;
+
+ LRACallbackSigner(String secret) {
+ this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),
ALGORITHM);
+ }
+
+ /**
+ * Computes the signature of a callback URL.
+ *
+ * @param callbackPath the participant path the URL points to (compensate
or complete)
+ * @param parameters the query parameters of the URL, without the
signature
+ * @return the signature, Base64 URL-safe encoded without
padding
+ */
+ String sign(String callbackPath, Map<String, String> parameters) {
+ try {
+ Mac mac = Mac.getInstance(ALGORITHM);
+ mac.init(key);
+ byte[] digest = mac.doFinal(canonicalForm(callbackPath,
parameters).getBytes(StandardCharsets.UTF_8));
+ return
Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Cannot compute the signature of
the LRA participant callback URL", e);
+ }
+ }
+
+ /**
+ * Checks the signature of a callback URL, in constant time.
+ *
+ * @param callbackPath the participant path that was called (compensate
or complete)
+ * @param parameters the query parameters of the called URL, without
the signature, may be <tt>null</tt> when
+ * they could not be read
+ * @param signature the signature received with the URL, may be
<tt>null</tt>
+ * @return true if the signature matches the path and the
parameters
+ */
+ boolean verify(String callbackPath, Map<String, String> parameters, String
signature) {
+ if (parameters == null || signature == null) {
+ return false;
+ }
+ byte[] expected = sign(callbackPath,
parameters).getBytes(StandardCharsets.UTF_8);
+ return MessageDigest.isEqual(expected,
signature.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String canonicalForm(String callbackPath, Map<String,
String> parameters) {
+ StringJoiner joiner = new StringJoiner("&", callbackPath + "?", "");
+ new TreeMap<>(parameters).forEach((name, value) ->
joiner.add(encode(name) + "=" + encode(value)));
+ return joiner.toString();
+ }
+
+ private static String encode(String value) {
+ // a null option value is sent as an empty parameter value, so it is
signed as one
+ return URLEncoder.encode(value != null ? value : "",
StandardCharsets.UTF_8);
+ }
+}
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
index 0d9747c14075..5a2eac625b70 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
@@ -24,7 +24,9 @@ import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
@@ -41,6 +43,10 @@ import static
org.apache.camel.service.lra.LRAConstants.HEADER_LINK;
import static org.apache.camel.service.lra.LRAConstants.HEADER_TIME_LIMIT;
import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
public class LRAClient implements Closeable {
@@ -115,8 +121,23 @@ public class LRAClient implements Closeable {
.compensation(step.getCompensation())
.completion(step.getCompletion());
- String compensationURL =
participantBaseUrl.path(PARTICIPANT_PATH_COMPENSATE).build();
- String completionURL =
participantBaseUrl.path(PARTICIPANT_PATH_COMPLETE).build();
+ LRACallbackSigner signer = sagaService.getCallbackSigner();
+ if (signer != null) {
+ // the LRA is signed too, so that a callback URL is only valid
for the LRA it was enlisted for
+ participantBaseUrl = participantBaseUrl.query(URL_LRA_KEY,
lra.toString());
+ }
+ LRAUrlBuilder compensationUrl =
participantBaseUrl.path(PARTICIPANT_PATH_COMPENSATE);
+ LRAUrlBuilder completionUrl =
participantBaseUrl.path(PARTICIPANT_PATH_COMPLETE);
+ if (signer != null) {
+ Map<String, String> parameters = callbackParameters(step, lra);
+ compensationUrl = compensationUrl.query(URL_SIGNATURE_KEY,
+ signer.sign(PARTICIPANT_PATH_COMPENSATE, parameters));
+ completionUrl = completionUrl.query(URL_SIGNATURE_KEY,
+ signer.sign(PARTICIPANT_PATH_COMPLETE, parameters));
+ }
+
+ String compensationURL = compensationUrl.build();
+ String completionURL = completionUrl.build();
StringBuilder link = new StringBuilder();
link.append('<').append(compensationURL).append('>').append(";
rel=compensate");
@@ -184,6 +205,17 @@ public class LRAClient implements Closeable {
return HttpRequest.newBuilder().uri(uri);
}
+ /**
+ * The query parameters of the signed participant callback URLs of a step,
as the participant receives them back.
+ */
+ private static Map<String, String> callbackParameters(LRASagaStep step,
URL lra) {
+ Map<String, String> parameters = new HashMap<>(step.getOptions());
+ step.getCompensation().ifPresent(endpoint ->
parameters.put(URL_COMPENSATION_KEY, endpoint.getEndpointUri()));
+ step.getCompletion().ifPresent(endpoint ->
parameters.put(URL_COMPLETION_KEY, endpoint.getEndpointUri()));
+ parameters.put(URL_LRA_KEY, lra.toString());
+ return parameters;
+ }
+
private URL toURL(Object url) {
if (url == null) {
return null;
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
index 1c3feead0ec9..69ddc4ab67a7 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
@@ -33,6 +33,8 @@ public final class LRAConstants {
static final String URL_COMPENSATION_KEY = "Camel-Saga-Compensate";
static final String URL_COMPLETION_KEY = "Camel-Saga-Complete";
+ static final String URL_SIGNATURE_KEY = "Camel-Saga-Signature";
+ static final String URL_LRA_KEY = "Camel-Saga-Lra";
private LRAConstants() {
}
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
index 4d442cff869c..048f786a5ba7 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
@@ -22,6 +22,7 @@ import java.util.*;
import java.util.stream.Collectors;
import org.apache.camel.Exchange;
+import org.apache.camel.Message;
import org.apache.camel.builder.RouteBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,7 +45,7 @@ public class LRASagaRoutes extends RouteBuilder {
rest(sagaService.getLocalParticipantContextPath())
.put(PARTICIPANT_PATH_COMPENSATE).to("direct:lra-compensation");
from("direct:lra-compensation").routeId("lra-compensation")
- .process(this::verifyRequest)
+ .process(this::verifyCompensationRequest)
.choice()
.when(header(URL_COMPENSATION_KEY).isNotNull())
.toD("${header." + URL_COMPENSATION_KEY + "}")
@@ -53,7 +54,7 @@ public class LRASagaRoutes extends RouteBuilder {
rest(sagaService.getLocalParticipantContextPath())
.put(PARTICIPANT_PATH_COMPLETE).to("direct:lra-completion");
from("direct:lra-completion").routeId("lra-completion")
- .process(this::verifyRequest)
+ .process(this::verifyCompletionRequest)
.choice()
.when(header(URL_COMPLETION_KEY).isNotNull())
.toD("${header." + URL_COMPLETION_KEY + "}")
@@ -69,8 +70,8 @@ public class LRASagaRoutes extends RouteBuilder {
// first, split by parameter separator '&'
// then collect the map with the variable name '[0]' and value
'[1]', both url decoded
result = Arrays.stream(queryStr.split("&")).collect(
- Collectors.toMap(element ->
decode(saveArrayAccess(element.split("="), 0)),
- element ->
decode(saveArrayAccess(element.split("="), 1))));
+ Collectors.toMap(element ->
decode(saveArrayAccess(element.split("=", 2), 0)),
+ element ->
decode(saveArrayAccess(element.split("=", 2), 1))));
} else {
LOG.debug("query param is empty, nothing to parse.");
@@ -93,14 +94,34 @@ public class LRASagaRoutes extends RouteBuilder {
return URLDecoder.decode(encodedString, StandardCharsets.UTF_8);
}
+ private void verifyCompensationRequest(Exchange exchange) {
+ verifyRequest(exchange, true);
+ }
+
+ private void verifyCompletionRequest(Exchange exchange) {
+ verifyRequest(exchange, false);
+ }
+
/**
- * Check if the request is pointing to an allowed URI to prevent
unauthorized remote uri invocation
+ * Check if the request is pointing to an allowed URI to prevent
unauthorized remote uri invocation. When a callback
+ * secret is configured, the signature of the called URL is verified
first, and the step URIs are only taken from
+ * the signed parameters.
*/
- private void verifyRequest(Exchange exchange) {
+ private void verifyRequest(Exchange exchange, boolean isCompensation) {
if (exchange.getIn().getHeader(Exchange.SAGA_LONG_RUNNING_ACTION) ==
null) {
throw new IllegalArgumentException("Missing " +
Exchange.SAGA_LONG_RUNNING_ACTION + " header in received request");
}
+ LRACallbackSigner signer = sagaService.getCallbackSigner();
+ if (signer != null) {
+ // the signed parameters are the only source of the step URIs: no
fallback to the query below
+ if (applySignedParameters(exchange, signer, isCompensation)) {
+ checkAllowed(exchange.getIn().getHeader(URL_COMPENSATION_KEY,
String.class));
+ checkAllowed(exchange.getIn().getHeader(URL_COMPLETION_KEY,
String.class));
+ }
+ return;
+ }
+
Set<String> usedURIs = new HashSet<>();
String compensationURI =
exchange.getIn().getHeader(URL_COMPENSATION_KEY, String.class);
if (compensationURI != null) {
@@ -139,4 +160,63 @@ public class LRASagaRoutes extends RouteBuilder {
}
}
+ private void checkAllowed(String uri) {
+ if (uri != null && !sagaService.getRegisteredURIs().contains(uri)) {
+ throw new IllegalArgumentException("URI " + uri + " is not
allowed");
+ }
+ }
+
+ /**
+ * Verifies the signature of the called URL, and sets the step URIs and
the saga options from its query parameters,
+ * so that only signed values reach the step endpoint. A callback with a
missing or invalid signature is answered
+ * with HTTP 403 and not routed any further.
+ *
+ * @return true if the signature is valid
+ */
+ private boolean applySignedParameters(Exchange exchange, LRACallbackSigner
signer, boolean isCompensation) {
+ Message message = exchange.getIn();
+ String callbackPath = isCompensation ? PARTICIPANT_PATH_COMPENSATE :
PARTICIPANT_PATH_COMPLETE;
+
+ // the raw query first, as some consumers, such as camel-netty-http,
decode CamelHttpQuery
+ String query = message.getHeader(Exchange.HTTP_RAW_QUERY,
String.class);
+ if (query == null) {
+ query = message.getHeader(Exchange.HTTP_QUERY, String.class);
+ }
+ Map<String, String> parameters;
+ try {
+ parameters = parseQuery(query);
+ } catch (IllegalStateException e) {
+ // a repeated parameter, which a URL enlisted by the participant
never has
+ parameters = null;
+ }
+ String signature = parameters != null ?
parameters.remove(URL_SIGNATURE_KEY) : null;
+
+ message.removeHeader(URL_COMPENSATION_KEY);
+ message.removeHeader(URL_COMPLETION_KEY);
+ message.removeHeader(URL_SIGNATURE_KEY);
+ if (!signer.verify(callbackPath, parameters, signature) ||
parameters.get(URL_LRA_KEY) == null) {
+ LOG.warn("Rejected a call to the LRA participant endpoint {}
without a valid signature", callbackPath);
+ message.setHeader(Exchange.HTTP_RESPONSE_CODE, 403);
+ message.setBody(null);
+ exchange.setRouteStop(true);
+ return false;
+ }
+ // the step works on the LRA the URL was signed for, not on the one
named by the request
+ message.setHeader(Exchange.SAGA_LONG_RUNNING_ACTION,
parameters.remove(URL_LRA_KEY));
+ parameters.forEach(message::setHeader);
+ // keep the signature out of the step route
+ removeSignature(message, Exchange.HTTP_RAW_QUERY);
+ removeSignature(message, Exchange.HTTP_QUERY);
+ return true;
+ }
+
+ private static void removeSignature(Message message, String queryHeader) {
+ String query = message.getHeader(queryHeader, String.class);
+ if (query != null) {
+ message.setHeader(queryHeader, Arrays.stream(query.split("&"))
+ .filter(parameter ->
!parameter.startsWith(URL_SIGNATURE_KEY + "="))
+ .collect(Collectors.joining("&")));
+ }
+ }
+
}
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
index 6a6359d22fca..3c67e2691d0d 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
@@ -39,6 +39,8 @@ import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.annotations.JdkService;
import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.ObjectHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* A Camel saga service based on Microprofile LRA
(https://github.com/eclipse/microprofile-lra).
@@ -48,6 +50,11 @@ import org.apache.camel.util.ObjectHelper;
@ManagedResource(description = "Managed LRASagaService")
public class LRASagaService extends ServiceSupport implements StaticService,
CamelSagaService {
+ private static final Logger LOG =
LoggerFactory.getLogger(LRASagaService.class);
+ private static final List<String> RESERVED_OPTION_NAMES = List.of(
+ LRAConstants.URL_COMPENSATION_KEY,
LRAConstants.URL_COMPLETION_KEY, LRAConstants.URL_SIGNATURE_KEY,
+ LRAConstants.URL_LRA_KEY);
+
private CamelContext camelContext;
private ScheduledExecutorService executorService;
private LRAClient client;
@@ -65,6 +72,9 @@ public class LRASagaService extends ServiceSupport implements
StaticService, Cam
private String localParticipantContextPath =
LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
@Metadata(label = "security")
private String allowedCoordinatorUrls;
+ @Metadata(label = "security", secret = true)
+ private String callbackSecret;
+ private LRACallbackSigner callbackSigner;
public LRASagaService() {
}
@@ -187,11 +197,31 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
@Override
public void registerStep(CamelSagaStep step) {
+ checkOptionNames(step);
// Register which uris should be exposed
step.getCompensation().map(Endpoint::getEndpointUri).map(this.sagaURIs::add);
step.getCompletion().map(Endpoint::getEndpointUri).map(this.sagaURIs::add);
}
+ /**
+ * The saga options travel in the query of the participant callback URLs,
next to the parameters of the saga
+ * service, so an option cannot use one of their names. Header names are
case-insensitive, so neither can it use
+ * them in another case.
+ */
+ private static void checkOptionNames(CamelSagaStep step) {
+ if (step.getOptions() == null) {
+ return;
+ }
+ for (String name : step.getOptions().keySet()) {
+ for (String reserved : RESERVED_OPTION_NAMES) {
+ if (reserved.equalsIgnoreCase(name)) {
+ throw new IllegalArgumentException(
+ "The saga option " + name + " uses a name reserved
by the LRA saga service");
+ }
+ }
+ }
+ }
+
@Override
protected void doStart() throws Exception {
for (URI coordinator : additionalCoordinators()) {
@@ -203,6 +233,16 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
+ " URL could be read from:
" + coordinator);
}
}
+ if (callbackSecret != null && callbackSecret.isBlank()) {
+ // for example an empty environment variable, which would
otherwise switch the verification off
+ throw new IllegalArgumentException(
+ "callbackSecret is set on the LRA saga service but blank:
set a secret, or remove the option");
+ }
+ if (callbackSigner == null) {
+ LOG.warn("Callbacks to the LRA participant endpoints under {} are
not signed. Configure a callbackSecret"
+ + " on the LRA saga service, or make sure that only the
LRA coordinator can reach these endpoints.",
+ localParticipantContextPath);
+ }
if (this.executorService == null) {
this.executorService = camelContext.getExecutorServiceManager()
.newDefaultScheduledThreadPool(this, "saga-lra");
@@ -310,6 +350,24 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
this.allowedCoordinatorUrls = allowedCoordinatorUrls;
}
+ public String getCallbackSecret() {
+ return callbackSecret;
+ }
+
+ /**
+ * Secret used to sign the participant callback URLs that are enlisted
with the LRA coordinator, and to verify them
+ * when the coordinator calls back. When set, a compensation or completion
callback with a missing or invalid
+ * signature is rejected with HTTP 403. All the instances of a participant
must use the same secret.
+ */
+ public void setCallbackSecret(String callbackSecret) {
+ this.callbackSecret = callbackSecret;
+ this.callbackSigner = ObjectHelper.isNotEmpty(callbackSecret) ? new
LRACallbackSigner(callbackSecret) : null;
+ }
+
+ LRACallbackSigner getCallbackSigner() {
+ return callbackSigner;
+ }
+
public Set<String> getRegisteredURIs() {
return sagaURIs;
}
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
index e3f4bf80c9c4..b222ec250356 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
+++
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.service.lra;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
@@ -93,7 +95,7 @@ public class LRAUrlBuilder {
} else {
copy.query += "&";
}
- copy.query += toNonnullString(key) + "=" + toNonnullString(value);
+ copy.query += encode(toNonnullString(key)) + "=" +
encode(toNonnullString(value));
return copy;
}
@@ -116,6 +118,10 @@ public class LRAUrlBuilder {
return first + "/" + second;
}
+ private String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
private String toNonnullString(Object obj) {
return obj != null ? obj.toString() : "";
}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
new file mode 100644
index 000000000000..4b78e6bfb0d8
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.service.lra;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LRACallbackSignerTest {
+
+ private final LRACallbackSigner signer = new
LRACallbackSigner("a-callback-secret");
+
+ @Test
+ void signatureMatchesTheSignedParameters() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+
+ assertTrue(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(),
signature));
+ }
+
+ @Test
+ void signatureDoesNotDependOnTheParameterOrder() {
+ Map<String, String> ordered = new LinkedHashMap<>();
+ ordered.put("id", "42");
+ ordered.put("name", "Nicola");
+ Map<String, String> reversed = new LinkedHashMap<>();
+ reversed.put("name", "Nicola");
+ reversed.put("id", "42");
+
+ assertEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, ordered),
signer.sign(PARTICIPANT_PATH_COMPENSATE, reversed));
+ }
+
+ @Test
+ void changedParameterValueDoesNotMatch() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+ Map<String, String> changed = parameters();
+ changed.put("id", "43");
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, changed,
signature));
+ }
+
+ @Test
+ void addedParameterDoesNotMatch() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+ Map<String, String> added = parameters();
+ added.put("extra", "value");
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, added,
signature));
+ }
+
+ @Test
+ void removedParameterDoesNotMatch() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+ Map<String, String> removed = parameters();
+ removed.remove("name");
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, removed,
signature));
+ }
+
+ @Test
+ void signatureIsBoundToTheCallbackPath() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPLETE, parameters(),
signature));
+ }
+
+ @Test
+ void signatureIsBoundToTheSecret() {
+ String signature = new
LRACallbackSigner("another-secret").sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(),
signature));
+ }
+
+ @Test
+ void missingSignatureDoesNotMatch() {
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(),
null));
+ }
+
+ @Test
+ void missingParametersDoNotMatch() {
+ String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE,
parameters());
+
+ assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, null,
signature));
+ }
+
+ @Test
+ void nullValueIsSignedAsAnEmptyValue() {
+ Map<String, String> withNull = parameters();
+ withNull.put("note", null);
+ Map<String, String> withEmpty = parameters();
+ withEmpty.put("note", "");
+
+ assertEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, withNull),
signer.sign(PARTICIPANT_PATH_COMPENSATE, withEmpty));
+ }
+
+ @Test
+ void separatorsInValuesDoNotChangeTheParameterBoundaries() {
+ Map<String, String> one = new HashMap<>();
+ one.put("a", "b&c=d");
+ Map<String, String> two = new HashMap<>();
+ two.put("a", "b");
+ two.put("c", "d");
+
+ assertNotEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, one),
signer.sign(PARTICIPANT_PATH_COMPENSATE, two));
+ }
+
+ private static Map<String, String> parameters() {
+ Map<String, String> parameters = new HashMap<>();
+ parameters.put(URL_COMPENSATION_KEY, "direct://cancelOrder");
+ parameters.put(URL_COMPLETION_KEY, "direct://confirmOrder");
+ parameters.put("id", "42");
+ parameters.put("name", "Nicola");
+ return parameters;
+ }
+}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
index e1cc66f8e93d..c8709286d2cf 100644
---
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
@@ -18,16 +18,37 @@ package org.apache.camel.service.lra;
import java.net.MalformedURLException;
import java.net.URI;
+import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
+import org.apache.camel.saga.CamelSagaStep;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.builder.ExpressionBuilder;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
+
public class LRAClientTest extends CamelTestSupport {
public LRAClientTest() {
@@ -118,6 +139,93 @@ public class LRAClientTest extends CamelTestSupport {
Assertions.assertEquals(actual.build(), expected.build());
}
+ @DisplayName("Tests whether join() signs the participant callback URLs
when a callback secret is set")
+ @Test
+ void testJoinSignsCallbackUrlsWithCallbackSecret() throws Exception {
+ Map<String, Map<String, String>> callbacks =
joinAndCaptureCallbacks("a-callback-secret");
+
+ LRACallbackSigner signer = new LRACallbackSigner("a-callback-secret");
+ for (String path : new String[] { PARTICIPANT_PATH_COMPENSATE,
PARTICIPANT_PATH_COMPLETE }) {
+ Map<String, String> parameters = callbacks.get(path);
+ Assertions.assertEquals("42", parameters.get("id"), "saga option
must be in the " + path + " URL");
+ Assertions.assertEquals("http://localhost/lra-coordinator/0_1",
parameters.get(URL_LRA_KEY),
+ "the LRA must be in the " + path + " URL");
+ String signature = parameters.remove(URL_SIGNATURE_KEY);
+ Assertions.assertTrue(signer.verify(path, parameters, signature),
+ "the " + path + " URL must carry a valid signature");
+ }
+ }
+
+ @DisplayName("Tests whether join() leaves the participant callback URLs
unsigned without a callback secret")
+ @Test
+ void testJoinDoesNotSignCallbackUrlsWithoutCallbackSecret() throws
Exception {
+ Map<String, Map<String, String>> callbacks =
joinAndCaptureCallbacks(null);
+
+ for (Map<String, String> parameters : callbacks.values()) {
+ Assertions.assertEquals("42", parameters.get("id"), "saga option
must be in the callback URL");
+ Assertions.assertFalse(parameters.containsKey(URL_SIGNATURE_KEY),
"callback URL must not be signed");
+ Assertions.assertFalse(parameters.containsKey(URL_LRA_KEY),
"callback URL must not carry the LRA");
+ }
+ }
+
+ /**
+ * Joins an LRA through a mocked HTTP client, and returns the query
parameters of the enlisted callback URLs, by
+ * participant path.
+ */
+ private Map<String, Map<String, String>> joinAndCaptureCallbacks(String
callbackSecret) throws Exception {
+ ScheduledExecutorService executor =
Executors.newSingleThreadScheduledExecutor();
+ try {
+ LRASagaService sagaService = new LRASagaService() {
+ @Override
+ public ScheduledExecutorService getExecutorService() {
+ return executor;
+ }
+ };
+ applyMockProperties(sagaService);
+ sagaService.setCallbackSecret(callbackSecret);
+
+ HttpClient httpClient = Mockito.mock(HttpClient.class);
+ @SuppressWarnings("unchecked")
+ HttpResponse<String> response = Mockito.mock(HttpResponse.class);
+ Mockito.when(response.statusCode()).thenReturn(200);
+ ArgumentCaptor<HttpRequest> request =
ArgumentCaptor.forClass(HttpRequest.class);
+ Mockito.when(httpClient.sendAsync(request.capture(),
Mockito.<HttpResponse.BodyHandler<String>> any()))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ Map<String, Expression> options = new HashMap<>();
+ options.put("id", ExpressionBuilder.constantExpression("42"));
+ CamelSagaStep step = new CamelSagaStep(
+ context().getEndpoint("direct:cancelOrder"),
context().getEndpoint("direct:confirmOrder"), options, null);
+ Exchange exchange = new DefaultExchange(context());
+
+ new LRAClient(sagaService, httpClient)
+
.join(URI.create("http://localhost/lra-coordinator/0_1").toURL(),
+ LRASagaStep.fromCamelSagaStep(step, exchange),
exchange)
+ .get(10, TimeUnit.SECONDS);
+
+ String link =
request.getValue().headers().firstValue("Link").orElseThrow();
+ Map<String, Map<String, String>> callbacks = new HashMap<>();
+ Matcher matcher = Pattern.compile("<[^>?]+(/[a-z]+)\\?([^>]*)>;
rel=").matcher(link);
+ while (matcher.find()) {
+ callbacks.put(matcher.group(1), parseQuery(matcher.group(2)));
+ }
+ Assertions.assertEquals(2, callbacks.size(), "the Link header must
hold the compensate and complete URLs");
+ return callbacks;
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private static Map<String, String> parseQuery(String query) {
+ Map<String, String> parameters = new HashMap<>();
+ for (String parameter : query.split("&")) {
+ String[] pair = parameter.split("=", 2);
+ parameters.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
+ URLDecoder.decode(pair[1], StandardCharsets.UTF_8));
+ }
+ return parameters;
+ }
+
private void applyMockProperties(LRASagaService sagaService) {
sagaService.setCoordinatorUrl("mockCoordinatorUrl");
sagaService.setLocalParticipantUrl("mockLocalParticipantUrl");
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
new file mode 100644
index 000000000000..eeae18563b86
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
@@ -0,0 +1,301 @@
+/*
+ * 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.service.lra;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.StringJoiner;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static
org.apache.camel.service.lra.LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests the participant callback routes when a callback secret is configured
on the LRA saga service.
+ */
+class LRASagaRoutesCallbackSignatureTest extends CamelTestSupport {
+
+ private static final String CALLBACK_SECRET = "a-callback-secret";
+ // the LRA a callback URL is signed for, and another one named by the
requests
+ private static final String LRA = "http://localhost/lra-coordinator/0_1";
+ private static final String REQUEST_LRA =
"http://localhost/lra-coordinator/0_2";
+
+ @RegisterExtension
+ AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+ private final LRACallbackSigner signer = new
LRACallbackSigner(CALLBACK_SECRET);
+ private final LRASagaService sagaService = new LRASagaService();
+ private final HttpClient client = HttpClient.newHttpClient();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+
+ // the tests never start a saga, so the coordinator is not called
+ sagaService.setCoordinatorUrl("http://localhost:1");
+ sagaService.setLocalParticipantUrl("http://localhost:" +
port.getPort());
+ sagaService.setCallbackSecret(CALLBACK_SECRET);
+ context.addService(sagaService);
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ restConfiguration().port(port.getPort());
+ }
+ });
+ return context;
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // registers the compensation and completion endpoints of the
saga step
+ from("direct:order")
+ .saga().propagation(SagaPropagation.SUPPORTS)
+ .option("orderId", header("orderId"))
+ .compensation("mock:cancel")
+ .completion("mock:confirm")
+ .to("mock:order");
+ }
+ };
+ }
+
+ @Test
+ void signedCompensationIsRouted() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(1);
+ cancel.expectedHeaderReceived("orderId", "42");
+ cancel.expectedHeaderReceived(Exchange.SAGA_LONG_RUNNING_ACTION, LRA);
+
+ assertEquals(200,
+ callback(PARTICIPANT_PATH_COMPENSATE,
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void signedCompletionIsRouted() throws Exception {
+ MockEndpoint confirm = getMockEndpoint("mock:confirm");
+ confirm.expectedMessageCount(1);
+ confirm.expectedHeaderReceived("orderId", "42");
+ confirm.expectedHeaderReceived(Exchange.SAGA_LONG_RUNNING_ACTION, LRA);
+
+ assertEquals(200,
+ callback(PARTICIPANT_PATH_COMPLETE,
signedQuery(PARTICIPANT_PATH_COMPLETE, parameters("42"))));
+
+ confirm.assertIsSatisfied();
+ }
+
+ @Test
+ void callbackWithoutSignatureIsRejected() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(0);
+
+ assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE,
query(parameters("42"))));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void callbackWithChangedOptionIsRejected() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(0);
+ String signed = signedQuery(PARTICIPANT_PATH_COMPENSATE,
parameters("42"));
+
+ assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE,
signed.replace("orderId=42", "orderId=43")));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void compensationSignatureIsRejectedForCompletion() throws Exception {
+ MockEndpoint confirm = getMockEndpoint("mock:confirm");
+ confirm.expectedMessageCount(0);
+
+ assertEquals(403,
+ callback(PARTICIPANT_PATH_COMPLETE,
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+ confirm.assertIsSatisfied();
+ }
+
+ @Test
+ void signedValuesTakePrecedenceOverRequestHeaders() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(1);
+ cancel.expectedHeaderReceived("orderId", "42");
+
+ assertEquals(200, callback(PARTICIPANT_PATH_COMPENSATE,
+ signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42")),
"orderId", "43"));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void signedCallbackToUnregisteredEndpointIsRejected() throws Exception {
+ MockEndpoint other = getMockEndpoint("mock:other");
+ other.expectedMessageCount(0);
+ Map<String, String> parameters = parameters("42");
+ parameters.put(URL_COMPENSATION_KEY, "mock://other");
+
+ assertEquals(500, callback(PARTICIPANT_PATH_COMPENSATE,
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters)));
+
+ other.assertIsSatisfied();
+ }
+
+ @Test
+ void callbackForAnotherLraIsRejected() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(0);
+ String signed = signedQuery(PARTICIPANT_PATH_COMPENSATE,
parameters("42"));
+
+ assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE,
signed.replace(encode(LRA), encode(REQUEST_LRA))));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void signedCallbackWithoutLraIsRejected() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(0);
+ Map<String, String> parameters = parameters("42");
+ parameters.remove(URL_LRA_KEY);
+
+ assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE,
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters)));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void signatureIsKeptOutOfTheStepRoute() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(1);
+
+ assertEquals(200,
+ callback(PARTICIPANT_PATH_COMPENSATE,
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+ cancel.assertIsSatisfied();
+ Message received = cancel.getReceivedExchanges().get(0).getIn();
+ for (String header : new String[] { Exchange.HTTP_QUERY,
Exchange.HTTP_RAW_QUERY }) {
+ assertFalse(received.getHeader(header, "",
String.class).contains(URL_SIGNATURE_KEY), header);
+ }
+ }
+
+ @Test
+ void rawQueryIsReadBeforeTheDecodedOne() throws Exception {
+ // camel-netty-http sets CamelHttpQuery to the decoded query, and
CamelHttpRawQuery to the query as sent;
+ // the rest-dsl route inlines the verification, so it is called
directly, as LRASagaRoutesTest does
+ String rawQuery = signedQuery(PARTICIPANT_PATH_COMPENSATE,
parameters("4&2=x+y%"));
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION,
REQUEST_LRA);
+ exchange.getIn().setHeader(Exchange.HTTP_RAW_QUERY, rawQuery);
+ exchange.getIn().setHeader(Exchange.HTTP_QUERY,
URLDecoder.decode(rawQuery, StandardCharsets.UTF_8));
+
+ Method verify =
LRASagaRoutes.class.getDeclaredMethod("verifyCompensationRequest",
Exchange.class);
+ verify.setAccessible(true);
+ verify.invoke(new LRASagaRoutes(sagaService), exchange);
+
+ assertNull(exchange.getIn().getHeader(Exchange.HTTP_RESPONSE_CODE));
+ assertEquals("4&2=x+y%", exchange.getIn().getHeader("orderId"));
+ assertEquals(LRA,
exchange.getIn().getHeader(Exchange.SAGA_LONG_RUNNING_ACTION));
+ }
+
+ @Test
+ void signedCallbackWithoutStepUrisDoesNotReadThemFromTheQuery() throws
Exception {
+ // a step without compensation and completion URIs, whose signed
option value holds an encoded parameter that
+ // the decoded CamelHttpQuery of camel-netty-http shows as a step URI
+ Map<String, String> parameters = new LinkedHashMap<>();
+ parameters.put("note", "x&" + URL_COMPENSATION_KEY + "=mock://cancel");
+ parameters.put(URL_LRA_KEY, LRA);
+ String rawQuery = signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters);
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION,
REQUEST_LRA);
+ exchange.getIn().setHeader(Exchange.HTTP_RAW_QUERY, rawQuery);
+ exchange.getIn().setHeader(Exchange.HTTP_QUERY,
URLDecoder.decode(rawQuery, StandardCharsets.UTF_8));
+
+ Method verify =
LRASagaRoutes.class.getDeclaredMethod("verifyCompensationRequest",
Exchange.class);
+ verify.setAccessible(true);
+ verify.invoke(new LRASagaRoutes(sagaService), exchange);
+
+ assertNull(exchange.getIn().getHeader(Exchange.HTTP_RESPONSE_CODE));
+ assertEquals("x&" + URL_COMPENSATION_KEY + "=mock://cancel",
exchange.getIn().getHeader("note"));
+ assertNull(exchange.getIn().getHeader(URL_COMPENSATION_KEY));
+ }
+
+ private int callback(String path, String query, String... headers) throws
Exception {
+ HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
+ "http://localhost:" + port.getPort() +
DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH + path + "?" + query))
+ .header(Exchange.SAGA_LONG_RUNNING_ACTION, REQUEST_LRA)
+ .PUT(HttpRequest.BodyPublishers.noBody());
+ if (headers.length > 0) {
+ request.headers(headers);
+ }
+ return client.send(request.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+
+ private String signedQuery(String path, Map<String, String> parameters) {
+ return query(parameters) + "&" + URL_SIGNATURE_KEY + "=" +
signer.sign(path, parameters);
+ }
+
+ private static String query(Map<String, String> parameters) {
+ StringJoiner query = new StringJoiner("&");
+ parameters.forEach((name, value) -> query.add(encode(name) + "=" +
encode(value)));
+ return query.toString();
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ private static Map<String, String> parameters(String orderId) {
+ Map<String, String> parameters = new LinkedHashMap<>();
+ parameters.put("orderId", orderId);
+ parameters.put(URL_COMPENSATION_KEY, "mock://cancel");
+ parameters.put(URL_COMPLETION_KEY, "mock://confirm");
+ parameters.put(URL_LRA_KEY, LRA);
+ return parameters;
+ }
+}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
index c9762505cf6e..022bec7885d5 100644
---
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
@@ -79,6 +79,36 @@ public class LRASagaRoutesTest {
"query parameter value for name 'key2' has unexpected
content");
}
+ @DisplayName("Tests parseQuery() preserves values containing '='
characters")
+ @Test
+ void testParseQueryWithEqualsInValue() throws InvocationTargetException,
IllegalAccessException, NoSuchMethodException {
+
+ Map<String, String> testResult = (Map<String, String>)
getParseQueryMethod()
+ .invoke(new LRASagaRoutes(null),
+ "key1=value%3Dwith%3Dequals&key2=normal");
+
+ Assertions.assertEquals(2, testResult.size(), "query parameter count
must be two");
+ Assertions.assertEquals("value=with=equals", testResult.get("key1"),
+ "value containing '=' characters must be preserved");
+ Assertions.assertEquals("normal", testResult.get("key2"));
+ }
+
+ @DisplayName("Tests parseQuery() decodes URL-encoded special characters
correctly")
+ @Test
+ void testParseQueryWithEncodedSpecialChars()
+ throws InvocationTargetException, IllegalAccessException,
NoSuchMethodException {
+
+ Map<String, String> testResult = (Map<String, String>)
getParseQueryMethod()
+ .invoke(new LRASagaRoutes(null),
+ "key1=value+with%26ampersand&key2=a%2Bb");
+
+ Assertions.assertEquals(2, testResult.size(), "query parameter count
must be two");
+ Assertions.assertEquals("value with&ersand", testResult.get("key1"),
+ "encoded '&' and '+' must be decoded correctly");
+ Assertions.assertEquals("a+b", testResult.get("key2"),
+ "encoded '+' must be decoded correctly");
+ }
+
@DisplayName("Tests parseQuery() to handle incorrect query string (no
value is given)")
@Test
void testParseQuerySuccessEncoded() throws InvocationTargetException,
IllegalAccessException, NoSuchMethodException {
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
new file mode 100644
index 000000000000..f9a215280db4
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.service.lra;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static
org.apache.camel.service.lra.LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests the participant callback routes when no callback secret is configured
on the LRA saga service: the callbacks
+ * are routed as before, without a signature.
+ */
+class LRASagaRoutesUnsignedCallbackTest extends CamelTestSupport {
+
+ @RegisterExtension
+ AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+ private final HttpClient client = HttpClient.newHttpClient();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+
+ LRASagaService sagaService = new LRASagaService();
+ // the tests never start a saga, so the coordinator is not called
+ sagaService.setCoordinatorUrl("http://localhost:1");
+ sagaService.setLocalParticipantUrl("http://localhost:" +
port.getPort());
+ context.addService(sagaService);
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ restConfiguration().port(port.getPort());
+ }
+ });
+ return context;
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // registers the compensation and completion endpoints of the
saga step
+ from("direct:order")
+ .saga().propagation(SagaPropagation.SUPPORTS)
+ .option("orderId", header("orderId"))
+ .compensation("mock:cancel")
+ .completion("mock:confirm")
+ .to("mock:order");
+ }
+ };
+ }
+
+ @Test
+ void compensationIsRouted() throws Exception {
+ MockEndpoint cancel = getMockEndpoint("mock:cancel");
+ cancel.expectedMessageCount(1);
+ cancel.expectedHeaderReceived("orderId", "42");
+
+ assertEquals(200,
+ callback(PARTICIPANT_PATH_COMPENSATE, "orderId=42&" +
URL_COMPENSATION_KEY + "=" + encode("mock://cancel")));
+
+ cancel.assertIsSatisfied();
+ }
+
+ @Test
+ void completionIsRouted() throws Exception {
+ MockEndpoint confirm = getMockEndpoint("mock:confirm");
+ confirm.expectedMessageCount(1);
+ confirm.expectedHeaderReceived("orderId", "42");
+
+ assertEquals(200,
+ callback(PARTICIPANT_PATH_COMPLETE, "orderId=42&" +
URL_COMPLETION_KEY + "=" + encode("mock://confirm")));
+
+ confirm.assertIsSatisfied();
+ }
+
+ @Test
+ void callbackToUnregisteredEndpointIsRejected() throws Exception {
+ MockEndpoint other = getMockEndpoint("mock:other");
+ other.expectedMessageCount(0);
+
+ assertEquals(500, callback(PARTICIPANT_PATH_COMPENSATE,
URL_COMPENSATION_KEY + "=" + encode("mock://other")));
+
+ other.assertIsSatisfied();
+ }
+
+ private int callback(String path, String query) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(
+ "http://localhost:" + port.getPort() +
DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH + path + "?" + query))
+ .header(Exchange.SAGA_LONG_RUNNING_ACTION,
"http://localhost/lra-coordinator/0_1")
+ .PUT(HttpRequest.BodyPublishers.noBody())
+ .build();
+ return client.send(request,
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
index 08c349de1bfe..fa74a03bfc5c 100644
---
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
@@ -17,9 +17,13 @@
package org.apache.camel.service.lra;
import java.net.URL;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
+import org.apache.camel.Endpoint;
import org.apache.camel.Exchange;
+import org.apache.camel.saga.CamelSagaStep;
+import org.apache.camel.support.builder.ExpressionBuilder;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
@@ -75,6 +79,32 @@ public class LRASagaServiceTest extends CamelTestSupport {
Mockito.verify(client).newLRA(exchange);
}
+ @DisplayName("Tests whether doStart() fails when the callback secret is
set but blank")
+ @Test
+ void testBlankCallbackSecretFailsToStart() {
+ LRASagaService sagaService = new LRASagaService();
+ applyMockProperties(sagaService);
+ sagaService.setCallbackSecret(" ");
+
+ Assertions.assertThrows(IllegalArgumentException.class,
sagaService::doStart);
+ }
+
+ @DisplayName("Tests whether registerStep() rejects a saga option named
like a parameter of the callback URLs, in any case")
+ @Test
+ void testRegisterStepRejectsReservedOptionNames() {
+ LRASagaService sagaService = new LRASagaService();
+ Endpoint compensation = context().getEndpoint("direct:compensate1");
+ for (String name : new String[] {
+ "Camel-Saga-Compensate", "Camel-Saga-Complete",
"Camel-Saga-Signature", "Camel-Saga-Lra",
+ "camel-saga-signature" }) {
+ CamelSagaStep step = new CamelSagaStep(
+ compensation, null, Map.of(name,
ExpressionBuilder.constantExpression("value")), null);
+
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
sagaService.registerStep(step), name);
+ }
+ Assertions.assertTrue(sagaService.getRegisteredURIs().isEmpty());
+ }
+
private void applyMockProperties(LRASagaService sagaService) {
sagaService.setCoordinatorUrl("mockCoordinatorUrl");
sagaService.setLocalParticipantUrl("mockLocalParticipantUrl");
diff --git
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
similarity index 51%
copy from
components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
copy to
components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
index 1c3feead0ec9..813a6c418293 100644
---
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
@@ -16,25 +16,15 @@
*/
package org.apache.camel.service.lra;
-public final class LRAConstants {
-
- public static final String DEFAULT_COORDINATOR_CONTEXT_PATH =
"/lra-coordinator";
- public static final String DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH =
"/lra-participant";
-
- static final String COORDINATOR_PATH_START = "/start";
- static final String COORDINATOR_PATH_CLOSE = "/close";
- static final String COORDINATOR_PATH_CANCEL = "/cancel";
-
- static final String PARTICIPANT_PATH_COMPENSATE = "/compensate";
- static final String PARTICIPANT_PATH_COMPLETE = "/complete";
-
- static final String HEADER_LINK = "Link";
- static final String HEADER_TIME_LIMIT = "TimeLimit";
-
- static final String URL_COMPENSATION_KEY = "Camel-Saga-Compensate";
- static final String URL_COMPLETION_KEY = "Camel-Saga-Complete";
+/**
+ * Runs the saga option tests with signed participant callbacks, so the LRA
coordinator calls back signed URLs.
+ */
+class LRASignedCallbacksIT extends LRAOptionsIT {
- private LRAConstants() {
+ @Override
+ protected LRASagaService createLRASagaService() {
+ LRASagaService sagaService = super.createLRASagaService();
+ sagaService.setCallbackSecret("a-callback-secret");
+ return sagaService;
}
-
}
diff --git
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
new file mode 100644
index 000000000000..3af26e8a1507
--- /dev/null
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.service.lra;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.util.Map;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class LRAUrlBuilderTest {
+
+ @DisplayName("Tests that query() URL-encodes special characters in values")
+ @Test
+ void testQueryEncodesSpecialCharacters() {
+ String url = new LRAUrlBuilder()
+ .host("http://localhost:8080")
+ .path("lra-participant")
+ .query("key", "value with spaces&and=special+chars#hash")
+ .build();
+
+
assertTrue(url.contains("key=value+with+spaces%26and%3Dspecial%2Bchars%23hash"),
+ "Special characters must be URL-encoded in query values: " +
url);
+ }
+
+ @DisplayName("Tests that compensation URI with query params is properly
encoded")
+ @Test
+ void testCompensationUriWithQueryParams() {
+ String url = new LRAUrlBuilder()
+ .host("http://localhost:8080")
+ .path("lra-participant")
+
.compensation("seda:cancelOrder?concurrentConsumers=2&size=1000")
+ .build();
+
+ URI uri = URI.create(url);
+ assertNotNull(uri.getQuery(), "URL must have a valid query string");
+
+ String query = uri.getRawQuery();
+ assertTrue(query.contains("Camel-Saga-Compensate="),
+ "Query must contain the compensation key");
+
assertTrue(query.contains("seda%3AcancelOrder%3FconcurrentConsumers%3D2%26size%3D1000"),
+ "Compensation URI must be fully encoded: " + query);
+ }
+
+ @DisplayName("Tests full round-trip: LRAUrlBuilder encodes, parseQuery
decodes back to original")
+ @Test
+ void testCallbackUrlRoundTrip() throws Exception {
+ String compensationUri =
"seda:cancelOrder?concurrentConsumers=2&size=1000";
+ String completionUri = "direct:complete";
+ String optionValue = "ACME®ion=EU";
+
+ String url = new LRAUrlBuilder()
+ .host("http://localhost:8080")
+ .path("lra-participant")
+ .query("myOption", optionValue)
+ .compensation(compensationUri)
+ .completion(completionUri)
+ .path("compensate")
+ .build();
+
+ URI uri = URI.create(url);
+ String rawQuery = uri.getRawQuery();
+
+ Method parseQuery =
LRASagaRoutes.class.getDeclaredMethod("parseQuery", String.class);
+ parseQuery.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ Map<String, String> parsed = (Map<String, String>)
parseQuery.invoke(new LRASagaRoutes(null), rawQuery);
+
+ assertEquals(compensationUri, parsed.get("Camel-Saga-Compensate"),
+ "Compensation URI must survive encode/decode round-trip");
+ assertEquals(completionUri, parsed.get("Camel-Saga-Complete"),
+ "Completion URI must survive encode/decode round-trip");
+ assertEquals(optionValue, parsed.get("myOption"),
+ "Option value with special characters must survive
encode/decode round-trip");
+ }
+
+ @DisplayName("Tests that simple direct: URIs still work after encoding")
+ @Test
+ void testSimpleDirectUri() {
+ String url = new LRAUrlBuilder()
+ .host("http://localhost:8080")
+ .path("lra-participant")
+ .compensation("direct://saga1_participant1_compensate")
+ .completion("direct://saga1_participant1_complete")
+ .path("compensate")
+ .build();
+
+ URI uri = URI.create(url);
+ assertNotNull(uri.getQuery(), "URL must have a valid query string");
+ }
+}
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
index 164b7c433e10..6e40be135258 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
@@ -139,6 +139,34 @@
sagaService.setLocalParticipantUrl("http://my-host-as-seen-by-lra-service:8080/c
context.addService(sagaService);
----
+==== Signing the participant callbacks
+
+The participant endpoints are meant to be called by the LRA coordinator only.
+They call the compensation or completion endpoint of a saga step, with the
saga options as headers, as described by the callback URL that the participant
enlisted with the coordinator.
+
+When a callback secret is set on the LRA Saga Service, the participant signs
the callback URLs it enlists, and verifies the signature when the coordinator
calls them back.
+A callback with a missing or invalid signature is rejected with HTTP status
403, and its compensation or completion endpoint is not called.
+The signature covers the saga step, its options and the LRA the URL was
enlisted for, and the compensation or completion endpoint gets its
`Long-Running-Action` header from the signed URL.
+A signed URL can still be called again for its own LRA by whoever obtains it,
so keep the storage and the logs of the coordinator private.
+
+[source,java]
+----
+sagaService.setCallbackSecret(callbackSecret);
+----
+
+With Camel Main, the secret is set with the `camel.lra.callbackSecret` option,
preferably resolved from an environment variable or a vault:
+
+[source,properties]
+----
+camel.lra.callbackSecret = {{env:LRA_CALLBACK_SECRET}}
+----
+
+Use a long random value, and the same value on all the instances of a
participant, as the coordinator can call back any of them.
+A blank secret, for example from an empty environment variable, fails the
start of the LRA Saga Service.
+The coordinator calls back the URLs that were enlisted when each saga step
started, so set or change the secret when no saga is in progress: the callbacks
of steps enlisted before the change are rejected otherwise.
+
+Without a callback secret, the callbacks are not verified, so the participant
endpoints must only be reachable by the LRA coordinator, for example by not
exposing the `/lra-participant` path outside the network of the coordinator.
+
==== Using the LRA Saga Service in Spring Boot
Spring Boot users can use a simplified configuration model for the LRA Saga
Service. Maven users can
diff --git
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
index 3bdb1349e7c1..14f789dff4ff 100644
---
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
+++
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
@@ -23,6 +23,7 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
static {
Map<String, Object> map = new CaseInsensitiveMap();
map.put("AllowedCoordinatorUrls", java.lang.String.class);
+ map.put("CallbackSecret", java.lang.String.class);
map.put("CoordinatorContextPath", java.lang.String.class);
map.put("CoordinatorUrl", java.lang.String.class);
map.put("Enabled", boolean.class);
@@ -37,6 +38,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls":
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class,
value)); return true;
+ case "callbacksecret":
+ case "callbackSecret": target.setCallbackSecret(property(camelContext,
java.lang.String.class, value)); return true;
case "coordinatorcontextpath":
case "coordinatorContextPath":
target.setCoordinatorContextPath(property(camelContext, java.lang.String.class,
value)); return true;
case "coordinatorurl":
@@ -60,6 +63,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls": return java.lang.String.class;
+ case "callbacksecret":
+ case "callbackSecret": return java.lang.String.class;
case "coordinatorcontextpath":
case "coordinatorContextPath": return java.lang.String.class;
case "coordinatorurl":
@@ -79,6 +84,8 @@ public class LraConfigurationPropertiesConfigurer extends
org.apache.camel.suppo
switch (ignoreCase ? name.toLowerCase() : name) {
case "allowedcoordinatorurls":
case "allowedCoordinatorUrls": return
target.getAllowedCoordinatorUrls();
+ case "callbacksecret":
+ case "callbackSecret": return target.getCallbackSecret();
case "coordinatorcontextpath":
case "coordinatorContextPath": return
target.getCoordinatorContextPath();
case "coordinatorurl":
diff --git
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index 2f8df635ce4e..7a4973537e9b 100644
---
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -190,6 +190,7 @@
{ "name": "camel.health.registryEnabled", "required": false,
"description": "Whether registry health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.health.routesEnabled", "required": false, "description":
"Whether routes health check is enabled", "sourceType":
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean",
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
{ "name": "camel.lra.allowedCoordinatorUrls", "required": false,
"description": "Comma-separated URLs, including the context path, of further
LRA coordinators whose LRAs may be joined, completed or compensated. By
default, only an LRA hosted by the configured coordinator is accepted. Use it
when participants reach the same coordinator under different URLs, for example
with different host names.", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
+ { "name": "camel.lra.callbackSecret", "required": false, "description":
"Secret used to sign the participant callback URLs that are enlisted with the
LRA coordinator, and to verify them when the coordinator calls back. When set,
a compensation or completion callback with a missing or invalid signature is
rejected with HTTP 403. All the instances of a participant must use the same
secret.", "sourceType": "org.apache.camel.main.LraConfigurationProperties",
"type": "string", "javaType": [...]
{ "name": "camel.lra.coordinatorContextPath", "required": false,
"description": "The context-path for the LRA coordinator. Is default
\/lra-coordinator", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "string",
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret":
false },
{ "name": "camel.lra.coordinatorUrl", "required": false, "description":
"The URL for the LRA coordinator service that orchestrates the transactions",
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type":
"string", "javaType": "java.lang.String", "secret": false },
{ "name": "camel.lra.enabled", "required": false, "description": "To
enable Saga LRA", "sourceType":
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean",
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git a/core/camel-main/src/main/docs/main.adoc
b/core/camel-main/src/main/docs/main.adoc
index 0aa7f14d4836..f525c7652cb2 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -674,12 +674,13 @@ The camel.resilience4j supports 21 options, which are
listed below.
=== Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 6 options, which are listed below.
+The camel.lra supports 7 options, which are listed below.
[width="100%",cols="2,5,^1,2",options="header"]
|===
| Name | Description | Default | Type
| *camel.lra.allowedCoordinator{zwsp}Urls* | Comma-separated URLs, including
the context path, of further LRA coordinators whose LRAs may be joined,
completed or compensated. By default, only an LRA hosted by the configured
coordinator is accepted. Use it when participants reach the same coordinator
under different URLs, for example with different host names. | | String
+| *camel.lra.callbackSecret* | Secret used to sign the participant callback
URLs that are enlisted with the LRA coordinator, and to verify them when the
coordinator calls back. When set, a compensation or completion callback with a
missing or invalid signature is rejected with HTTP 403. All the instances of a
participant must use the same secret. | | String
| *camel.lra.coordinatorContext{zwsp}Path* | The context-path for the LRA
coordinator. Is default /lra-coordinator | /lra-coordinator | String
| *camel.lra.coordinatorUrl* | The URL for the LRA coordinator service that
orchestrates the transactions | | String
| *camel.lra.enabled* | To enable Saga LRA | false | boolean
diff --git
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
index 6ae967f1a0a8..46f191e74ba7 100644
---
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
+++
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
@@ -38,6 +38,8 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
private String localParticipantContextPath = "/lra-participant";
@Metadata(label = "security")
private String allowedCoordinatorUrls;
+ @Metadata(label = "security", secret = true)
+ private String callbackSecret;
public LraConfigurationProperties(MainConfigurationProperties parent) {
this.parent = parent;
@@ -124,6 +126,19 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
this.allowedCoordinatorUrls = allowedCoordinatorUrls;
}
+ public String getCallbackSecret() {
+ return callbackSecret;
+ }
+
+ /**
+ * Secret used to sign the participant callback URLs that are enlisted
with the LRA coordinator, and to verify them
+ * when the coordinator calls back. When set, a compensation or completion
callback with a missing or invalid
+ * signature is rejected with HTTP 403. All the instances of a participant
must use the same secret.
+ */
+ public void setCallbackSecret(String callbackSecret) {
+ this.callbackSecret = callbackSecret;
+ }
+
/**
* The URL for the LRA coordinator service that orchestrates the
transactions
*/
@@ -173,4 +188,13 @@ public class LraConfigurationProperties implements
BootstrapCloseable {
return this;
}
+ /**
+ * Secret used to sign the participant callback URLs that are enlisted
with the LRA coordinator, and to verify them
+ * when the coordinator calls back.
+ */
+ public LraConfigurationProperties withCallbackSecret(String
callbackSecret) {
+ this.callbackSecret = callbackSecret;
+ return this;
+ }
+
}
diff --git
a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
index 2f06aa898cac..425d2d39dc5f 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
@@ -48,6 +48,7 @@ public final class SensitiveUtils {
"azureclientsecret",
"blobaccesskey",
"blobstoragesharedkeycredential",
+ "callbacksecret",
"certresourcepassword",
"clientid",
"clientsecret",
@@ -139,6 +140,7 @@ public final class SensitiveUtils {
+
"|\\Qazureclientsecret\\E"
+ "|\\Qblobaccesskey\\E"
+
"|\\Qblobstoragesharedkeycredential\\E"
+ + "|\\Qcallbacksecret\\E"
+
"|\\Qcertresourcepassword\\E"
+ "|\\Qclientid\\E"
+ "|\\Qclientsecret\\E"
diff --git
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
index 0998272cd9b1..48a239825316 100644
---
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
+++
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
@@ -59,7 +59,9 @@ public class UpdateSensitizeHelper extends
AbstractGeneratorMojo {
// extra keys that are regarded as secret which may not yet been in any
component
// they MUST be in lowercase and without a dash
private static final String[] EXTRA_KEYS
- = new String[] { "apipassword", "apiuser", "apiusername",
"api_key", "api_secret", "secret", "keystorePassword" };
+ = new String[] {
+ "apipassword", "apiuser", "apiusername", "api_key",
"api_secret", "callbacksecret", "secret",
+ "keystorePassword" };
@Parameter(defaultValue =
"${project.basedir}/src/generated/resources/org/apache/camel/catalog/")
protected File jsonDir;