You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by gn...@apache.org on 2026/10/13 14:28:00 UTC

(camel) branch camel-4.18.x updated: [backport camel-4.18.x] CAMEL-25584: camel-lra - only join LRAs hosted by the configured LRA coordinator (#27772)

This is an automated email from the ASF dual-hosted git repository.

gnodet 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 04edc01c9dcb [backport camel-4.18.x] CAMEL-25584: camel-lra - only join LRAs hosted by the configured LRA coordinator (#27772)
04edc01c9dcb is described below

commit 04edc01c9dcbd87ca4ed8b6fd76c97a089ad2fb9
Author: Andrea Cosentino <an...@gmail.com>
AuthorDate: Tue Oct 13 16:27:51 2026 +0200

    [backport camel-4.18.x] CAMEL-25584: camel-lra - only join LRAs hosted by the configured LRA coordinator (#27772)
    
    CAMEL-25584: camel-lra - only join LRAs hosted by the configured LRA coordinator (#27725)
    
    The LRA saga service now joins an LRA only when its URL is hosted by the configured
    LRA coordinator (or one of the allowedCoordinatorUrls), instead of joining whatever
    LRA URL arrives in the Long-Running-Action header. The URL check rejects dot segments
    (including ';' and percent-encoded forms), reads host, port and userinfo so host names
    with an underscore work, and validates the allowed coordinator URLs at startup.
    
    Adapted to camel-4.18.x: LRASagaService is ported by hand, as the branch predates CAMEL-24150 and
    CAMEL-24192, and LRAPropagatedSagaTest uses camel-test-junit5. The upgrade-guide notes stay on main.
    
    (cherry picked from commit 86224cd178c36d06fcf76ca8d139d9d68fd22b4e)
    
    Signed-off-by: Andrea Cosentino <an...@gmail.com>
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 .../main/camel-main-configuration-metadata.json    |   1 +
 .../service/lra/LRASagaServiceConfigurer.java      |   6 +
 .../apache/camel/service/lra/LRASagaService.java   | 124 +++++++++++++++++-
 .../camel/service/lra/LRAPropagatedSagaTest.java   | 129 +++++++++++++++++++
 .../lra/LRASagaServiceAllowedCoordinatorTest.java  | 141 +++++++++++++++++++++
 .../src/main/docs/modules/eips/pages/saga-eip.adoc |   5 +
 .../main/LraConfigurationPropertiesConfigurer.java |   7 +
 .../camel-main-configuration-metadata.json         |   1 +
 core/camel-main/src/main/docs/main.adoc            |   3 +-
 .../camel/main/LraConfigurationProperties.java     |  24 ++++
 10 files changed, 439 insertions(+), 2 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 13ce22342152..2f8df635ce4e 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
@@ -189,6 +189,7 @@
     { "name": "camel.health.producersEnabled", "required": false, "description": "Whether producers health check is enabled", "sourceType": "org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", "javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
     { "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.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/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 53d34137e6c8..cba17a6be24b 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
@@ -23,6 +23,8 @@ public class LRASagaServiceConfigurer extends org.apache.camel.support.component
     public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) {
         org.apache.camel.service.lra.LRASagaService target = (org.apache.camel.service.lra.LRASagaService) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": target.setAllowedCoordinatorUrls(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":
@@ -40,6 +42,8 @@ public class LRASagaServiceConfigurer extends org.apache.camel.support.component
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return java.lang.String.class;
         case "camelcontext":
         case "camelContext": return org.apache.camel.CamelContext.class;
         case "coordinatorcontextpath":
@@ -58,6 +62,8 @@ public class LRASagaServiceConfigurer extends org.apache.camel.support.component
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         org.apache.camel.service.lra.LRASagaService target = (org.apache.camel.service.lra.LRASagaService) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return target.getAllowedCoordinatorUrls();
         case "camelcontext":
         case "camelContext": return target.getCamelContext();
         case "coordinatorcontextpath":
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 0d3acb834eb5..6a6359d22fca 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
@@ -16,7 +16,13 @@
  */
 package org.apache.camel.service.lra;
 
+import java.net.MalformedURLException;
 import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
@@ -32,6 +38,7 @@ import org.apache.camel.spi.Configurer;
 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;
 
 /**
  * A Camel saga service based on Microprofile LRA (https://github.com/eclipse/microprofile-lra).
@@ -56,6 +63,8 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam
     private String localParticipantUrl;
     @Metadata
     private String localParticipantContextPath = LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+    @Metadata(label = "security")
+    private String allowedCoordinatorUrls;
 
     public LRASagaService() {
     }
@@ -75,17 +84,107 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam
         return true;
     }
 
+    /**
+     * Returns the coordinator of an LRA, which is joined, completed or compensated by calling its URL. Only an LRA
+     * hosted by the configured coordinator, or by one of the allowed coordinator URLs, is accepted.
+     */
     @Override
     public CompletableFuture<CamelSagaCoordinator> getSaga(String id) {
         CompletableFuture<CamelSagaCoordinator> coordinator;
         try {
-            coordinator = CompletableFuture.completedFuture(new LRASagaCoordinator(this, URI.create(id).toURL()));
+            URI lra = URI.create(id);
+            if (!isHostedByAllowedCoordinator(lra)) {
+                throw new IllegalArgumentException(
+                        "The Long-Running-Action " + id + " is not hosted by the LRA coordinator configured on the LRA"
+                                                   + " saga service. If it is a trusted coordinator, add its URL to"
+                                                   + " allowedCoordinatorUrls.");
+            }
+            coordinator = CompletableFuture.completedFuture(new LRASagaCoordinator(this, lra.toURL()));
         } catch (Exception ex) {
             coordinator = CompletableFuture.failedFuture(ex);
         }
         return coordinator;
     }
 
+    private boolean isHostedByAllowedCoordinator(URI lra) {
+        for (URI coordinator : allowedCoordinators()) {
+            if (isHostedBy(lra, coordinator)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * The URLs of the coordinators whose LRAs are accepted: the configured coordinator, and the allowed coordinator
+     * URLs.
+     */
+    private List<URI> allowedCoordinators() {
+        List<URI> coordinators = new ArrayList<>();
+        coordinators.add(URI.create(new LRAUrlBuilder().host(coordinatorUrl).path(coordinatorContextPath).build()));
+        coordinators.addAll(additionalCoordinators());
+        return coordinators;
+    }
+
+    private List<URI> additionalCoordinators() {
+        List<URI> coordinators = new ArrayList<>();
+        if (ObjectHelper.isNotEmpty(allowedCoordinatorUrls)) {
+            for (String url : allowedCoordinatorUrls.split(",")) {
+                if (!url.isBlank()) {
+                    coordinators.add(URI.create(url.trim()));
+                }
+            }
+        }
+        return coordinators;
+    }
+
+    /**
+     * Whether the LRA URL is under the coordinator URL: same scheme, host and port, no user info, and a path under the
+     * path of the coordinator.
+     */
+    private static boolean isHostedBy(URI lra, URI coordinator) {
+        // host and port are read through URL, which also takes host names that URI does not, such as lra_coordinator
+        URL lraUrl = toUrl(lra);
+        URL coordinatorUrl = toUrl(coordinator);
+        if (lraUrl == null || coordinatorUrl == null || lraUrl.getUserInfo() != null || lraUrl.getHost().isEmpty()) {
+            return false;
+        }
+        String rawPath = lra.getRawPath();
+        if (rawPath == null || hasEncodedOrParameterSegment(rawPath)) {
+            return false;
+        }
+        String lraPath = lra.normalize().getRawPath();
+        String coordinatorPath = Objects.toString(coordinator.normalize().getRawPath(), "");
+        if (!coordinatorPath.endsWith("/")) {
+            coordinatorPath += "/";
+        }
+        return lraUrl.getProtocol().equalsIgnoreCase(coordinatorUrl.getProtocol())
+                && lraUrl.getHost().equalsIgnoreCase(coordinatorUrl.getHost())
+                && port(lraUrl) == port(coordinatorUrl)
+                && lraPath.startsWith(coordinatorPath);
+    }
+
+    /**
+     * Whether the path holds a path parameter, or an encoded dot, slash or backslash. A server may resolve them as dot
+     * segments after this check, so such a path could leave the path of the coordinator.
+     */
+    private static boolean hasEncodedOrParameterSegment(String rawPath) {
+        String path = rawPath.toLowerCase(Locale.ROOT);
+        return path.contains(";") || path.contains("%2e") || path.contains("%2f") || path.contains("%5c");
+    }
+
+    private static URL toUrl(URI uri) {
+        try {
+            return uri.isAbsolute() ? uri.toURL() : null;
+        } catch (MalformedURLException | IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private static int port(URL url) {
+        return url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
+    }
+
     @Override
     public void registerStep(CamelSagaStep step) {
         // Register which uris should be exposed
@@ -95,6 +194,15 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam
 
     @Override
     protected void doStart() throws Exception {
+        for (URI coordinator : additionalCoordinators()) {
+            URL url = toUrl(coordinator);
+            if (url == null || url.getHost().isEmpty()
+                    || !("http".equals(url.getProtocol()) || "https".equals(url.getProtocol()))) {
+                throw new IllegalArgumentException(
+                        "allowedCoordinatorUrls must hold http or https coordinator URLs with a host name, but no such"
+                                                   + " URL could be read from: " + coordinator);
+            }
+        }
         if (this.executorService == null) {
             this.executorService = camelContext.getExecutorServiceManager()
                     .newDefaultScheduledThreadPool(this, "saga-lra");
@@ -188,6 +296,20 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam
         this.localParticipantContextPath = localParticipantContextPath;
     }
 
+    @ManagedAttribute(description = "Allowed coordinator URLs")
+    public String getAllowedCoordinatorUrls() {
+        return 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.
+     */
+    public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+    }
+
     public Set<String> getRegisteredURIs() {
         return sagaURIs;
     }
diff --git a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
new file mode 100644
index 000000000000..8c33bdaca361
--- /dev/null
+++ b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.URL;
+import java.util.concurrent.CompletableFuture;
+
+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 org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests that a saga step joins an LRA propagated in the Long-Running-Action header only when the LRA is hosted by the
+ * configured coordinator.
+ */
+class LRAPropagatedSagaTest extends CamelTestSupport {
+
+    private static final String COORDINATOR_URL = "http://lra-coordinator:8080";
+
+    @RegisterExtension
+    AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+    private final LRAClient client = Mockito.mock(LRAClient.class);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+
+        LRASagaService sagaService = new MockClientLRASagaService(client);
+        sagaService.setCoordinatorUrl(COORDINATOR_URL);
+        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() {
+                from("direct:step")
+                        .saga().propagation(SagaPropagation.SUPPORTS)
+                        .compensation("mock:compensate")
+                        .to("mock:step");
+            }
+        };
+    }
+
+    @Test
+    void joinsAnLraOfTheConfiguredCoordinator() throws Exception {
+        Mockito.when(client.join(Mockito.any(), Mockito.any(), Mockito.any()))
+                .thenReturn(CompletableFuture.completedFuture(null));
+        MockEndpoint step = getMockEndpoint("mock:step");
+        step.expectedMessageCount(1);
+
+        Exchange exchange = send(COORDINATOR_URL + "/lra-coordinator/0_1");
+
+        assertNull(exchange.getException());
+        step.assertIsSatisfied();
+        ArgumentCaptor<URL> joined = ArgumentCaptor.forClass(URL.class);
+        Mockito.verify(client).join(joined.capture(), Mockito.any(), Mockito.any());
+        assertEquals(COORDINATOR_URL + "/lra-coordinator/0_1", joined.getValue().toString());
+    }
+
+    @Test
+    void doesNotJoinAnLraOfAnotherCoordinator() throws Exception {
+        MockEndpoint step = getMockEndpoint("mock:step");
+        step.expectedMessageCount(0);
+
+        Exchange exchange = send("http://elsewhere:8080/lra-coordinator/0_1");
+
+        assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+        step.assertIsSatisfied();
+        Mockito.verifyNoInteractions(client);
+    }
+
+    private Exchange send(String lra) {
+        return template.send("direct:step", e -> e.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, lra));
+    }
+
+    // a named class, as the thread pool of the service is named after its class
+    private static class MockClientLRASagaService extends LRASagaService {
+
+        private final LRAClient client;
+
+        MockClientLRASagaService(LRAClient client) {
+            this.client = client;
+        }
+
+        @Override
+        protected LRAClient createLRAClient() {
+            return client;
+        }
+    }
+}
diff --git a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
new file mode 100644
index 000000000000..f8a1dbea5a78
--- /dev/null
+++ b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.concurrent.CompletionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests which LRAs the LRA saga service accepts to join, complete or compensate.
+ */
+class LRASagaServiceAllowedCoordinatorTest {
+
+    private final LRASagaService sagaService = new LRASagaService();
+
+    @BeforeEach
+    void configure() {
+        sagaService.setCoordinatorUrl("http://lra-coordinator:8080");
+        sagaService.setLocalParticipantUrl("http://participant:8080");
+    }
+
+    @Test
+    void acceptsAnLraOfTheConfiguredCoordinator() {
+        assertAccepted("http://lra-coordinator:8080/lra-coordinator/0_ffff0a1e1f05_1");
+    }
+
+    @Test
+    void comparesSchemeAndHostIgnoringCase() {
+        assertAccepted("HTTP://LRA-Coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void treatsTheDefaultPortLikeAnExplicitOne() {
+        sagaService.setCoordinatorUrl("http://lra-coordinator");
+
+        assertAccepted("http://lra-coordinator:80/lra-coordinator/0_1");
+    }
+
+    @Test
+    void honoursTheCoordinatorContextPath() {
+        sagaService.setCoordinatorContextPath("/api/v1/lra-coordinator");
+
+        assertAccepted("http://lra-coordinator:8080/api/v1/lra-coordinator/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void rejectsAnLraOfAnotherHost() {
+        assertRejected("http://elsewhere:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void rejectsAnLraOfAnotherPortOrScheme() {
+        assertRejected("http://lra-coordinator:9090/lra-coordinator/0_1");
+        assertRejected("https://lra-coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void rejectsAPathOutsideTheCoordinatorContextPath() {
+        assertRejected("http://lra-coordinator:8080/other/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator-other/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/../other/0_1");
+    }
+
+    @Test
+    void rejectsEncodedOrParameterDotSegments() {
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/%2e%2e/other/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/%2E%2E/other/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/..;/other/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/..%2fother/0_1");
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/..%5Cother/0_1");
+    }
+
+    @Test
+    void acceptsAnLraOfACoordinatorWithAnUnderscoreInItsHostName() {
+        sagaService.setCoordinatorUrl("http://lra_coordinator:8080");
+
+        assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1");
+        assertRejected("http://other_coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void rejectsAnLraWithUserInfo() {
+        assertRejected("http://user@lra-coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void acceptsAnLraOfAnAllowedCoordinatorUrl() {
+        sagaService.setAllowedCoordinatorUrls(
+                "http://lra-coordinator.lra.svc:8080/lra-coordinator, https://lra.example.com/coordinator,"
+                                              + " http://lra_coordinator:8080/lra-coordinator");
+
+        assertAccepted("http://lra-coordinator.lra.svc:8080/lra-coordinator/0_1");
+        assertAccepted("https://lra.example.com/coordinator/0_1");
+        assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1");
+        assertRejected("http://elsewhere:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void failsToStartWithARelativeAllowedCoordinatorUrl() {
+        sagaService.setAllowedCoordinatorUrls("lra-coordinator/lra-coordinator");
+
+        assertThrows(IllegalArgumentException.class, sagaService::doStart);
+    }
+
+    @Test
+    void failsToStartWithAnAllowedCoordinatorUrlThatIsNotHttp() {
+        sagaService.setAllowedCoordinatorUrls("ftp://lra-coordinator/lra-coordinator");
+
+        assertThrows(IllegalArgumentException.class, sagaService::doStart);
+    }
+
+    private void assertAccepted(String lra) {
+        assertNotNull(sagaService.getSaga(lra).join(), lra + " must be accepted");
+    }
+
+    private void assertRejected(String lra) {
+        CompletionException e = assertThrows(CompletionException.class, () -> sagaService.getSaga(lra).join(),
+                lra + " must be rejected");
+        assertInstanceOf(IllegalArgumentException.class, e.getCause());
+    }
+}
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 27eb8668d216..164b7c433e10 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
@@ -93,6 +93,11 @@ Users can follow instructions present on the Narayana website to *start up a rem
 
 The URL of the LRA coordinator is a required parameter of the Camel LRA service. The Camel application and the LRA service communicate using the HTTP protocol.
 
+A saga started by another participant is joined when the message carries its LRA in the `Long-Running-Action` header.
+The LRA Saga Service only joins, completes or compensates an LRA hosted by the configured coordinator: a `Long-Running-Action` with any other URL fails the exchange.
+When the participants reach the coordinator under different URLs, for example with different host names, list the other coordinator URLs, including the context path, in the `allowedCoordinatorUrls` option (`camel.lra.allowedCoordinatorUrls` with Camel Main).
+This also applies to the LRAs the service starts itself: when the coordinator returns LRA URLs under another base URL than `coordinatorUrl`, for example behind a proxy, list that base URL as well.
+
 To use the LRA Saga service, maven users will need to add the following dependency to their `pom.xml`:
 
 [source,xml]
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 badb2b23ea60..3bdb1349e7c1 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
@@ -22,6 +22,7 @@ public class LraConfigurationPropertiesConfigurer extends org.apache.camel.suppo
     private static final Map<String, Object> ALL_OPTIONS;
     static {
         Map<String, Object> map = new CaseInsensitiveMap();
+        map.put("AllowedCoordinatorUrls", java.lang.String.class);
         map.put("CoordinatorContextPath", java.lang.String.class);
         map.put("CoordinatorUrl", java.lang.String.class);
         map.put("Enabled", boolean.class);
@@ -34,6 +35,8 @@ public class LraConfigurationPropertiesConfigurer extends org.apache.camel.suppo
     public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) {
         org.apache.camel.main.LraConfigurationProperties target = (org.apache.camel.main.LraConfigurationProperties) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": target.setAllowedCoordinatorUrls(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":
@@ -55,6 +58,8 @@ public class LraConfigurationPropertiesConfigurer extends org.apache.camel.suppo
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return java.lang.String.class;
         case "coordinatorcontextpath":
         case "coordinatorContextPath": return java.lang.String.class;
         case "coordinatorurl":
@@ -72,6 +77,8 @@ public class LraConfigurationPropertiesConfigurer extends org.apache.camel.suppo
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         org.apache.camel.main.LraConfigurationProperties target = (org.apache.camel.main.LraConfigurationProperties) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return target.getAllowedCoordinatorUrls();
         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 13ce22342152..2f8df635ce4e 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
@@ -189,6 +189,7 @@
     { "name": "camel.health.producersEnabled", "required": false, "description": "Whether producers health check is enabled", "sourceType": "org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", "javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
     { "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.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 a800a1b8acaa..0aa7f14d4836 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -674,11 +674,12 @@ The camel.resilience4j supports 21 options, which are listed below.
 
 
 === Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 5 options, which are listed below.
+The camel.lra supports 6 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.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 3c91843d31c7..6ae967f1a0a8 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
@@ -36,6 +36,8 @@ public class LraConfigurationProperties implements BootstrapCloseable {
     private String localParticipantUrl;
     @Metadata(defaultValue = "/lra-participant")
     private String localParticipantContextPath = "/lra-participant";
+    @Metadata(label = "security")
+    private String allowedCoordinatorUrls;
 
     public LraConfigurationProperties(MainConfigurationProperties parent) {
         this.parent = parent;
@@ -109,6 +111,19 @@ public class LraConfigurationProperties implements BootstrapCloseable {
         this.localParticipantContextPath = localParticipantContextPath;
     }
 
+    public String getAllowedCoordinatorUrls() {
+        return 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.
+     */
+    public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+    }
+
     /**
      * The URL for the LRA coordinator service that orchestrates the transactions
      */
@@ -149,4 +164,13 @@ public class LraConfigurationProperties implements BootstrapCloseable {
         return this;
     }
 
+    /**
+     * Comma-separated URLs, including the context path, of further LRA coordinators whose LRAs may be joined, completed
+     * or compensated.
+     */
+    public LraConfigurationProperties withAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+        return this;
+    }
+
 }