This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.22.x by this push:
new 884321e6eb00 [backport camel-4.22.x] CAMEL-25578: camel-lra - verify
participant callbacks with an optional shared secret (#27804)
884321e6eb00 is described below
commit 884321e6eb006ad81b48b689f901d83e8ba47535
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 14 14:07:08 2026 +0200
[backport camel-4.22.x] CAMEL-25578: camel-lra - verify participant
callbacks with an optional shared secret (#27804)
CAMEL-25578: camel-lra - verify participant callbacks with an optional
shared secret (#27719)
(cherry picked from commit 3dc1b532f65c82f4ff97114579aacb2d95bf42eb)
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../org/apache/camel/catalog/docs/lra.adoc | 2 +
.../org/apache/camel/catalog/docs/main.adoc | 3 +-
.../org/apache/camel/catalog/docs/saga-eip.adoc | 28 ++
.../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 | 72 ++++-
.../apache/camel/service/lra/LRASagaService.java | 58 ++++
.../camel/service/lra/LRACallbackSignerTest.java | 139 ++++++++++
.../apache/camel/service/lra/LRAClientTest.java | 108 ++++++++
.../lra/LRASagaRoutesCallbackSignatureTest.java | 301 +++++++++++++++++++++
.../lra/LRASagaRoutesUnsignedCallbackTest.java | 136 ++++++++++
.../service/lra/LRASagaServiceAllowlistTest.java | 18 ++
.../camel/service/lra/LRASagaServiceTest.java | 10 +
.../camel/service/lra/LRASignedCallbacksIT.java} | 28 +-
.../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 | 1 +
26 files changed, 1086 insertions(+), 25 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lra.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lra.adoc
index abd05efa3360..d44d0319da57 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lra.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lra.adoc
@@ -11,6 +11,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/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
index 2ed417ded36c..521f9b229b0d 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
@@ -725,12 +725,13 @@ The camel.resilience4j supports 25 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.allowedCoordinatorUrls` | 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.coordinatorContextPath` | 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/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
index 8e2ecd7671a0..4486b35ae87f 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/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/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 dcba49de3313..7129ecdb1ee7 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
@@ -285,6 +285,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 0d38341e9d04..c9507701774c 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
@@ -23,6 +23,7 @@
"bearertoken",
"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 abd05efa3360..d44d0319da57 100644
--- a/components/camel-lra/src/main/docs/lra.adoc
+++ b/components/camel-lra/src/main/docs/lra.adoc
@@ -11,6 +11,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 3528a0ef62ac..c96f84ed1ea4 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");
@@ -191,6 +212,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 57ae40880e24..dee574480da8 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
@@ -25,6 +25,7 @@ import java.util.Set;
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;
@@ -106,13 +107,24 @@ public class LRASagaRoutes extends RouteBuilder {
/**
* Check if the request is pointing to an allowed URI to prevent
unauthorized remote uri invocation. Validates only
- * the URI relevant to the callback type against the corresponding
allowlist.
+ * the URI relevant to the callback type against the corresponding
allowlist. When a callback secret is configured,
+ * the signature of the called URL is verified first.
*/
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(isCompensation ?
URL_COMPENSATION_KEY : URL_COMPLETION_KEY,
+ String.class), isCompensation);
+ }
+ return;
+ }
+
String compensationURI =
exchange.getIn().getHeader(URL_COMPENSATION_KEY, String.class);
String completionURI = exchange.getIn().getHeader(URL_COMPLETION_KEY,
String.class);
@@ -133,7 +145,10 @@ public class LRASagaRoutes extends RouteBuilder {
}
}
- String uri = isCompensation ? compensationURI : completionURI;
+ checkAllowed(isCompensation ? compensationURI : completionURI,
isCompensation);
+ }
+
+ private void checkAllowed(String uri, boolean isCompensation) {
if (uri != null) {
Set<String> allowedURIs = isCompensation
? sagaService.getRegisteredCompensationURIs()
@@ -144,4 +159,57 @@ public class LRASagaRoutes extends RouteBuilder {
}
}
+ /**
+ * 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 7ccc1bfdae08..a07081000ad5 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
@@ -44,6 +44,8 @@ import org.apache.camel.spi.annotations.JdkService;
import org.apache.camel.support.LifecycleStrategySupport;
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).
@@ -53,6 +55,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;
@@ -73,6 +80,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", security = "secret")
+ private String callbackSecret;
+ private LRACallbackSigner callbackSigner;
public LRASagaService() {
}
@@ -195,6 +205,7 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
@Override
public void registerStep(CamelSagaStep step) {
+ checkOptionNames(step);
step.getCompensation().map(Endpoint::getEndpointUri).ifPresent(compensationURIs::add);
step.getCompletion().map(Endpoint::getEndpointUri).ifPresent(completionURIs::add);
if (step.getRouteId() != null) {
@@ -202,6 +213,25 @@ public class LRASagaService extends ServiceSupport
implements StaticService, Cam
}
}
+ /**
+ * 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");
+ }
+ }
+ }
+ }
+
void unregisterSteps(String routeId) {
List<CamelSagaStep> steps = stepsByRouteId.remove(routeId);
if (steps == null) {
@@ -246,6 +276,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()
@@ -374,6 +414,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> getRegisteredCompensationURIs() {
return compensationURIs;
}
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 67eb73251ef9..d29f627ecc78 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.junit6.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..a80f8f6604db
--- /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.junit6.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/LRASagaRoutesUnsignedCallbackTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
new file mode 100644
index 000000000000..4d3b5fd1a1a2
--- /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.junit6.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/LRASagaServiceAllowlistTest.java
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java
index 905ed19416b6..8035847afc65 100644
---
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java
+++
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java
@@ -17,10 +17,12 @@
package org.apache.camel.service.lra;
import java.util.Collections;
+import java.util.Map;
import java.util.Set;
import org.apache.camel.Endpoint;
import org.apache.camel.saga.CamelSagaStep;
+import org.apache.camel.support.builder.ExpressionBuilder;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -28,6 +30,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class LRASagaServiceAllowlistTest extends CamelTestSupport {
@@ -145,4 +148,19 @@ public class LRASagaServiceAllowlistTest extends
CamelTestSupport {
assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://compensate1"));
}
+
+ @DisplayName("registerStep rejects a saga option named like a parameter of
the callback URLs, in any case")
+ @Test
+ void testRegisterStepRejectsReservedOptionNames() {
+ 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);
+
+ assertThrows(IllegalArgumentException.class, () ->
sagaService.registerStep(step), name);
+ }
+ assertTrue(sagaService.getRegisteredURIs().isEmpty());
+ }
}
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 3365ba5a3a5d..c65387a522b3 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
@@ -75,6 +75,16 @@ 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);
+ }
+
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/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 8e2ecd7671a0..4486b35ae87f 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 dcba49de3313..7129ecdb1ee7 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
@@ -285,6 +285,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 2ed417ded36c..521f9b229b0d 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -725,12 +725,13 @@ The camel.resilience4j supports 25 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.allowedCoordinatorUrls` | 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.coordinatorContextPath` | 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..3a8aa5b3b43c 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", security = "secret")
+ 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 7a84456a27d3..0de887f8ab46 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
@@ -62,6 +62,7 @@ public final class SensitiveUtils {
"bearertoken",
"blobaccesskey",
"blobstoragesharedkeycredential",
+ "callbacksecret",
"certresourcepassword",
"clientid",
"clientsecret",
@@ -162,6 +163,7 @@ public final class SensitiveUtils {
+ "|\\Qbearertoken\\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 20be84f0518f..b1a751086c03 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
@@ -84,6 +84,7 @@ public class UpdateSensitizeHelper extends
AbstractGeneratorMojo {
private static final String[] EXTRA_KEYS
= new String[] {
"apipassword", "apiuser", "apiusername", "api_key",
"api-key", "api_secret", "authorization",
+ "callbacksecret",
SECRET, "keystorePassword" };
// extra security options from camel-main properties that are not in
component JSON files