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

coheigea pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/main by this push:
     new 2d192ac869a Enforce scheme checks for decoupled destinations (#3266)
2d192ac869a is described below

commit 2d192ac869aa864b5d52e2f543d07c0d900f2b82
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Jul 2 09:35:15 2026 +0100

    Enforce scheme checks for decoupled destinations (#3266)
---
 .../org/apache/cxf/ws/addressing/ContextUtils.java | 155 ++++++--
 .../ws/addressing/impl/InternalContextUtils.java   |  11 +-
 .../impl/DecoupledDestinationProtocolTest.java     | 431 +++++++++++++++++++++
 .../org/apache/cxf/ws/rm/InternalContextUtils.java |   8 +-
 .../ws/rm/DecoupledDestinationProtocolTest.java    | 275 +++++++++++++
 5 files changed, 842 insertions(+), 38 deletions(-)

diff --git a/core/src/main/java/org/apache/cxf/ws/addressing/ContextUtils.java 
b/core/src/main/java/org/apache/cxf/ws/addressing/ContextUtils.java
index fbfc5cb791c..1eed4d474c8 100644
--- a/core/src/main/java/org/apache/cxf/ws/addressing/ContextUtils.java
+++ b/core/src/main/java/org/apache/cxf/ws/addressing/ContextUtils.java
@@ -22,6 +22,11 @@ package org.apache.cxf.ws.addressing;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
 import java.util.UUID;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -29,6 +34,7 @@ import java.util.logging.Logger;
 import org.apache.cxf.Bus;
 import org.apache.cxf.common.logging.LogUtils;
 import org.apache.cxf.common.util.StringUtils;
+import org.apache.cxf.common.util.SystemPropertyAction;
 import org.apache.cxf.endpoint.Endpoint;
 import org.apache.cxf.message.Exchange;
 import org.apache.cxf.message.Message;
@@ -52,6 +58,28 @@ public final class ContextUtils {
     public static final ObjectFactory WSA_OBJECT_FACTORY = new ObjectFactory();
     public static final String ACTION = ContextUtils.class.getName() + 
".ACTION";
 
+    /**
+     * System property whose value overrides the comma-separated list of URI 
scheme
+     * prefixes permitted in wsa:ReplyTo / wsa:FaultTo decoupled-destination 
addresses.
+     * Example: {@code 
-Dorg.apache.cxf.ws.addressing.decoupled.allowedSchemes=http://,https://,jms:}
+     */
+    public static final String ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY =
+        "org.apache.cxf.ws.addressing.decoupled.allowedSchemes";
+
+    /**
+     * Default set of URI scheme prefixes permitted in wsa:ReplyTo / 
wsa:FaultTo
+     * decoupled-destination addresses. Schemes that can open arbitrary 
filesystem or
+     * OS resources (file://, corba:, IOR:, etc.) are intentionally excluded.
+     */
+    public static final Set<String> DEFAULT_ALLOWED_DECOUPLED_DEST_SCHEMES =
+        Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
+            "http://";, "https://";,
+            "jms://", "jms:",
+            "ws://", "wss://",
+            "hc://", "hc5://",
+            "local://"
+        )));
+
     private static final EndpointReferenceType NONE_ENDPOINT_REFERENCE = new 
EndpointReferenceType();
 
     private static final Logger LOG = 
LogUtils.getL7dLogger(ContextUtils.class);
@@ -561,47 +589,106 @@ public final class ContextUtils {
         return msg;
     }
 
+    /**
+     * Returns {@code true} if the scheme of {@code uri} is permitted as a
+     * wsa:ReplyTo / wsa:FaultTo decoupled-destination address. The permitted 
set is
+     * read from the system property {@value 
#ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY}
+     * (comma-separated prefix list) and falls back to
+     * {@link #DEFAULT_ALLOWED_DECOUPLED_DEST_SCHEMES} when the property is 
absent.
+     */
+    public static boolean isDecoupledDestinationAllowed(String uri) {
+        if (uri == null) {
+            return false;
+        }
+        // Normalize the URI to lowercase for case-insensitive scheme 
comparison
+        // (RFC 3986 §3.1: scheme is case-insensitive).
+        String normalizedUri = uri.toLowerCase(Locale.ROOT);
+        String prop = 
SystemPropertyAction.getPropertyOrNull(ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY);
+        Set<String> allowed = (prop != null)
+            ? new LinkedHashSet<>(Arrays.asList(prop.split(",")))
+            : DEFAULT_ALLOWED_DECOUPLED_DEST_SCHEMES;
+        for (String prefix : allowed) {
+            String trimmedPrefix = prefix.trim().toLowerCase(Locale.ROOT);
+            // Skip empty tokens that arise from leading/trailing/consecutive 
commas in
+            // the system property (e.g. ",http://"; or "http://,,https://";).
+            // An empty prefix would make startsWith("") return true for every 
URI,
+            // effectively disabling the SSRF allowlist.
+            if (trimmedPrefix.isEmpty()) {
+                continue;
+            }
+            if (normalizedUri.startsWith(trimmedPrefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     public static Destination createDecoupledDestination(Exchange exchange,
                                                          final 
EndpointReferenceType reference) {
         final EndpointInfo ei = exchange.getEndpoint().getEndpointInfo();
-        return new Destination() {
-            public EndpointReferenceType getAddress() {
-                return reference;
-            }
-            public Conduit getBackChannel(Message inMessage) throws 
IOException {
-                Bus bus = inMessage.getExchange().getBus();
-                //this is a response targeting a decoupled endpoint.   Treat 
it as a oneway so
-                //we don't wait for a response.
-                inMessage.getExchange().setOneWay(true);
-                ConduitInitiator conduitInitiator
-                    = bus.getExtension(ConduitInitiatorManager.class)
-                        
.getConduitInitiatorForUri(reference.getAddress().getValue());
-                if (conduitInitiator != null) {
-                    Conduit c = conduitInitiator.getConduit(ei, reference, 
bus);
-                    //ensure decoupled back channel input stream is closed
-                    c.setMessageObserver(new MessageObserver() {
-                        public void onMessage(Message m) {
-                            InputStream is = m.getContent(InputStream.class);
-                            if (is != null) {
-                                try {
-                                    is.close();
-                                } catch (Exception e) {
-                                    //ignore
-                                }
-                            }
-                        }
-                    });
-                    return c;
-                }
+        return new DecoupledDestination(ei, reference);
+    }
+
+    private static final class DecoupledDestination implements Destination {
+        private final EndpointInfo ei;
+        private final EndpointReferenceType reference;
+
+        DecoupledDestination(EndpointInfo ei, EndpointReferenceType reference) 
{
+            this.ei = ei;
+            this.reference = reference;
+        }
+
+        public EndpointReferenceType getAddress() {
+            return reference;
+        }
+
+        public Conduit getBackChannel(Message inMessage) throws IOException {
+            if (isNoneAddress(reference)) {
                 return null;
             }
-            public MessageObserver getMessageObserver() {
+            final String destinationUri = reference.getAddress().getValue();
+            if (!isDecoupledDestinationAllowed(destinationUri)) {
+                LOG.log(Level.WARNING,
+                    "Rejected wsa:ReplyTo/FaultTo address with disallowed 
scheme: {0}. "
+                    + "Override permitted schemes with system property {1}",
+                    new Object[] {destinationUri, 
ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY});
                 return null;
             }
-            public void shutdown() {
-            }
-            public void setMessageObserver(MessageObserver observer) {
+            Bus bus = inMessage.getExchange().getBus();
+            //this is a response targeting a decoupled endpoint.   Treat it as 
a oneway so
+            //we don't wait for a response.
+            inMessage.getExchange().setOneWay(true);
+            ConduitInitiator conduitInitiator
+                = bus.getExtension(ConduitInitiatorManager.class)
+                    .getConduitInitiatorForUri(destinationUri);
+            if (conduitInitiator != null) {
+                Conduit c = conduitInitiator.getConduit(ei, reference, bus);
+                //ensure decoupled back channel input stream is closed
+                c.setMessageObserver(new MessageObserver() {
+                    public void onMessage(Message m) {
+                        InputStream is = m.getContent(InputStream.class);
+                        if (is != null) {
+                            try {
+                                is.close();
+                            } catch (Exception e) {
+                                //ignore
+                            }
+                        }
+                    }
+                });
+                return c;
             }
-        };
+            return null;
+        }
+
+        public MessageObserver getMessageObserver() {
+            return null;
+        }
+
+        public void shutdown() {
+        }
+
+        public void setMessageObserver(MessageObserver observer) {
+        }
     }
 }
diff --git 
a/rt/ws/addr/src/main/java/org/apache/cxf/ws/addressing/impl/InternalContextUtils.java
 
b/rt/ws/addr/src/main/java/org/apache/cxf/ws/addressing/impl/InternalContextUtils.java
index 343213bee62..1caccd8bcf0 100644
--- 
a/rt/ws/addr/src/main/java/org/apache/cxf/ws/addressing/impl/InternalContextUtils.java
+++ 
b/rt/ws/addr/src/main/java/org/apache/cxf/ws/addressing/impl/InternalContextUtils.java
@@ -94,13 +94,21 @@ final class InternalContextUtils {
             if (ContextUtils.isNoneAddress(reference)) {
                 return null;
             }
+            final String destinationUri = reference.getAddress().getValue();
+            if (!ContextUtils.isDecoupledDestinationAllowed(destinationUri)) {
+                LOG.log(Level.WARNING,
+                    "Rejected wsa:ReplyTo/FaultTo address with disallowed 
scheme: {0}. "
+                    + "Override the permitted schemes with system property 
{1}",
+                    new Object[] {destinationUri, 
ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY});
+                return null;
+            }
             Bus bus = inMessage.getExchange().getBus();
             //this is a response targeting a decoupled endpoint.   Treat it as 
a oneway so
             //we don't wait for a response.
             inMessage.getExchange().setOneWay(true);
             ConduitInitiator conduitInitiator
                 = bus.getExtension(ConduitInitiatorManager.class)
-                    
.getConduitInitiatorForUri(reference.getAddress().getValue());
+                    .getConduitInitiatorForUri(destinationUri);
             if (conduitInitiator != null) {
                 Conduit c = conduitInitiator.getConduit(ei, reference, bus);
                 // ensure decoupled back channel input stream is closed
@@ -140,7 +148,6 @@ final class InternalContextUtils {
     private InternalContextUtils() {
     }
 
-
     /**
      * Rebase response on replyTo
      *
diff --git 
a/rt/ws/addr/src/test/java/org/apache/cxf/ws/addressing/impl/DecoupledDestinationProtocolTest.java
 
b/rt/ws/addr/src/test/java/org/apache/cxf/ws/addressing/impl/DecoupledDestinationProtocolTest.java
new file mode 100644
index 00000000000..98ce7592fe8
--- /dev/null
+++ 
b/rt/ws/addr/src/test/java/org/apache/cxf/ws/addressing/impl/DecoupledDestinationProtocolTest.java
@@ -0,0 +1,431 @@
+/**
+ * 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.cxf.ws.addressing.impl;
+
+import org.apache.cxf.Bus;
+import org.apache.cxf.endpoint.Endpoint;
+import org.apache.cxf.message.Exchange;
+import org.apache.cxf.message.ExchangeImpl;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.service.model.EndpointInfo;
+import org.apache.cxf.transport.Conduit;
+import org.apache.cxf.transport.ConduitInitiator;
+import org.apache.cxf.transport.ConduitInitiatorManager;
+import org.apache.cxf.transport.Destination;
+import org.apache.cxf.ws.addressing.AttributedURIType;
+import org.apache.cxf.ws.addressing.ContextUtils;
+import org.apache.cxf.ws.addressing.EndpointReferenceType;
+
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for the WS-Addressing ReplyTo/FaultTo allowed protocols
+ *
+ * <p>The scheme allowlist in {@link ContextUtils} prevents CXF from opening 
outbound
+ * connections to attacker-controlled URIs supplied in wsa:ReplyTo or 
wsa:FaultTo
+ * headers. These tests verify that disallowed schemes (e.g. file://) are 
rejected
+ * and that allowed schemes (e.g. http://, jms:) continue to work.
+ * Both the {@link InternalContextUtils} decoupled-destination path (server 
ReplyTo)
+ * and the {@link ContextUtils} decoupled-destination path (fault forwarding 
via
+ * {@code DecoupledFaultHandler} / {@code RMInInterceptor}) are covered.
+ */
+public class DecoupledDestinationProtocolTest {
+
+    @After
+    public void clearSystemProperty() {
+        
System.clearProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Blocked by default
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * An attacker supplies wsa:ReplyTo = file:///etc/passwd.
+     * getBackChannel() must return null and must NOT call 
getConduitInitiatorForUri.
+     */
+    @Test
+    public void testFileSchemeReplyToIsRejected() throws Exception {
+        final String attackerReplyTo = "file:///etc/passwd";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(attackerReplyTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNull("file:// ReplyTo must be blocked; no conduit should be 
returned", backChannel);
+        // The ConduitInitiatorManager must never be consulted for a 
disallowed scheme.
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    /**
+     * Same attack vector via wsa:FaultTo.
+     */
+    @Test
+    public void testFileSchemeViaFaultToIsRejected() throws Exception {
+        final String attackerFaultTo = "file:///var/log/app.log";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(attackerFaultTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNull("file:// FaultTo must be blocked; no conduit should be 
returned", backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Allowed by default
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * A legitimate http:// ReplyTo must pass through and reach 
getConduitInitiatorForUri.
+     */
+    @Test
+    public void testHttpSchemeReplyToIsAllowed() throws Exception {
+        final String replyTo = "http://callback.example.com/reply";;
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator httpInitiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(httpInitiator);
+        when(httpInitiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNotNull("http:// ReplyTo must be allowed", backChannel);
+        assertSame(mockConduit, backChannel);
+
+        ArgumentCaptor<EndpointReferenceType> eprCaptor =
+            ArgumentCaptor.forClass(EndpointReferenceType.class);
+        verify(httpInitiator).getConduit(
+            any(EndpointInfo.class), eprCaptor.capture(), any(Bus.class));
+        assertEquals(replyTo, eprCaptor.getValue().getAddress().getValue());
+    }
+
+    /**
+     * A legitimate jms: ReplyTo must pass through.
+     */
+    @Test
+    public void testJmsSchemeReplyToIsAllowed() throws Exception {
+        final String replyTo = "jms:queue:replies?timeToLive=1000";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator jmsInitiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(jmsInitiator);
+        when(jmsInitiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNotNull("jms: ReplyTo must be allowed", backChannel);
+        assertSame(mockConduit, backChannel);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // System-property override
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * When the system property explicitly lists file://, an 
operator-configured
+     * deployment can permit it (opt-in). The conduit must be resolved 
normally.
+     */
+    @Test
+    public void testFileSchemeAllowedViaSystemProperty() throws Exception {
+        System.setProperty(
+            ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, 
"file://,http://";);
+
+        final String replyTo = "file:///some/allowed/path";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator fileInitiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(fileInitiator);
+        when(fileInitiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNotNull("file:// must be allowed when explicitly listed in the 
system property", backChannel);
+        assertSame(mockConduit, backChannel);
+    }
+
+    /**
+     * When the system property is set, only its schemes are allowed — the 
defaults
+     * do not apply. An http:// address must be rejected if http:// is absent 
from the
+     * property value.
+     */
+    @Test
+    public void testSystemPropertyReplacesDefaults() throws Exception {
+        System.setProperty(
+            ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, "jms:");
+
+        final String replyTo = "http://callback.example.com/reply";;
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = 
InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNull("http:// must be blocked when absent from the system 
property", backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Unit tests for the helper
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    public void testIsReplyToSchemeAllowedDefaults() {
+        // Allowed by default
+        for (String allowed : 
ContextUtils.DEFAULT_ALLOWED_DECOUPLED_DEST_SCHEMES) {
+            assertEquals("Expected allowed: " + allowed,
+                true, ContextUtils.isDecoupledDestinationAllowed(allowed + 
"host/path"));
+        }
+        // Blocked by default
+        assertEquals(false, 
ContextUtils.isDecoupledDestinationAllowed("file:///etc/passwd"));
+        assertEquals(false, 
ContextUtils.isDecoupledDestinationAllowed("corba:something"));
+        assertEquals(false, 
ContextUtils.isDecoupledDestinationAllowed("IOR:abc"));
+        assertEquals(false, 
ContextUtils.isDecoupledDestinationAllowed("udp://host"));
+        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed(null));
+    }
+
+    /**
+     * Empty tokens produced by leading/trailing/consecutive commas in the 
system
+     * property must not act as a wildcard prefix that allows every URI.
+     * "".startsWith("") is always true, so empty entries must be skipped.
+     */
+    @Test
+    public void testEmptyPropertyTokensDoNotBypassAllowlist() {
+        // Leading comma — produces an empty first token
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY,
+            ",http://";);
+        assertEquals("leading comma must not allow file://",
+            false, 
ContextUtils.isDecoupledDestinationAllowed("file:///etc/passwd"));
+        assertEquals("leading comma must still allow http://";,
+            true, 
ContextUtils.isDecoupledDestinationAllowed("http://legit.example.com/";));
+
+        // Trailing comma — produces an empty last token
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY,
+            "http://,";);
+        assertEquals("trailing comma must not allow file://",
+            false, 
ContextUtils.isDecoupledDestinationAllowed("file:///etc/passwd"));
+
+        // Consecutive commas — produces an empty middle token
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY,
+            "http://,,https://";);
+        assertEquals("consecutive commas must not allow corba:",
+            false, 
ContextUtils.isDecoupledDestinationAllowed("corba:NameService"));
+
+        // Only commas — every token is empty; nothing should be allowed
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, ",,,");
+        assertEquals("all-comma property must not allow http://";,
+            false, 
ContextUtils.isDecoupledDestinationAllowed("http://example.com/";));
+        assertEquals("all-comma property must not allow file://",
+            false, 
ContextUtils.isDecoupledDestinationAllowed("file:///etc/passwd"));
+    }
+
+    // 
-------------------------------------------------------------------------
+    // ContextUtils.createDecoupledDestination — fault-forwarding path
+    // (used by DecoupledFaultHandler and RMInInterceptor)
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * A malicious server sends a SOAP fault to the client's decoupled listener
+     * with wsa:FaultTo = file:///etc/passwd. The ContextUtils destination must
+     * block the connection and return null without consulting 
ConduitInitiatorManager.
+     */
+    @Test
+    public void testContextUtilsFaultToFileSchemeIsRejected() throws Exception 
{
+        final String attackerFaultTo = "file:///etc/passwd";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = ContextUtils.createDecoupledDestination(
+            exchange, buildEpr(attackerFaultTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNull("file:// FaultTo via ContextUtils must be blocked", 
backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    /**
+     * An http:// FaultTo via ContextUtils.createDecoupledDestination must pass
+     * the scheme check and reach getConduitInitiatorForUri normally.
+     */
+    @Test
+    public void testContextUtilsFaultToHttpSchemeIsAllowed() throws Exception {
+        final String faultTo = "http://callback.example.com/fault";;
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator httpInitiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(faultTo)).thenReturn(httpInitiator);
+        when(httpInitiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination destination = ContextUtils.createDecoupledDestination(
+            exchange, buildEpr(faultTo));
+        Conduit backChannel = destination.getBackChannel(inMessage);
+
+        assertNotNull("http:// FaultTo via ContextUtils must be allowed", 
backChannel);
+        assertSame(mockConduit, backChannel);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Helpers
+    // 
-------------------------------------------------------------------------
+
+    private static EndpointReferenceType buildEpr(String uri) {
+        AttributedURIType address = new AttributedURIType();
+        address.setValue(uri);
+        EndpointReferenceType epr = new EndpointReferenceType();
+        epr.setAddress(address);
+        return epr;
+    }
+}
diff --git 
a/rt/ws/rm/src/main/java/org/apache/cxf/ws/rm/InternalContextUtils.java 
b/rt/ws/rm/src/main/java/org/apache/cxf/ws/rm/InternalContextUtils.java
index 55e15078957..e6d2220b657 100644
--- a/rt/ws/rm/src/main/java/org/apache/cxf/ws/rm/InternalContextUtils.java
+++ b/rt/ws/rm/src/main/java/org/apache/cxf/ws/rm/InternalContextUtils.java
@@ -78,13 +78,17 @@ final class InternalContextUtils {
             if (ContextUtils.isNoneAddress(reference)) {
                 return null;
             }
+            final String destinationUri = reference.getAddress().getValue();
+            if (!ContextUtils.isDecoupledDestinationAllowed(destinationUri)) {
+                return null;
+            }
             Bus bus = inMessage.getExchange().getBus();
             //this is a response targeting a decoupled endpoint.   Treat it as 
a oneway so
             //we don't wait for a response.
             inMessage.getExchange().setOneWay(true);
             ConduitInitiator conduitInitiator
                 = bus.getExtension(ConduitInitiatorManager.class)
-                    
.getConduitInitiatorForUri(reference.getAddress().getValue());
+                    .getConduitInitiatorForUri(destinationUri);
             if (conduitInitiator != null) {
                 Conduit c = conduitInitiator.getConduit(ei, reference, bus);
                 // ensure decoupled back channel input stream is closed
@@ -216,7 +220,7 @@ final class InternalContextUtils {
     }
     //CHECKSTYLE:ON
 
-    private static Destination createDecoupledDestination(
+    static Destination createDecoupledDestination(
         Exchange exchange, final EndpointReferenceType reference) {
         final EndpointInfo ei = exchange.getEndpoint().getEndpointInfo();
         return new DecoupledDestination(ei, reference);
diff --git 
a/rt/ws/rm/src/test/java/org/apache/cxf/ws/rm/DecoupledDestinationProtocolTest.java
 
b/rt/ws/rm/src/test/java/org/apache/cxf/ws/rm/DecoupledDestinationProtocolTest.java
new file mode 100644
index 00000000000..0a242c3af42
--- /dev/null
+++ 
b/rt/ws/rm/src/test/java/org/apache/cxf/ws/rm/DecoupledDestinationProtocolTest.java
@@ -0,0 +1,275 @@
+/**
+ * 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.cxf.ws.rm;
+
+import org.apache.cxf.Bus;
+import org.apache.cxf.endpoint.Endpoint;
+import org.apache.cxf.message.Exchange;
+import org.apache.cxf.message.ExchangeImpl;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.service.model.EndpointInfo;
+import org.apache.cxf.transport.Conduit;
+import org.apache.cxf.transport.ConduitInitiator;
+import org.apache.cxf.transport.ConduitInitiatorManager;
+import org.apache.cxf.transport.Destination;
+import org.apache.cxf.ws.addressing.AttributedURIType;
+import org.apache.cxf.ws.addressing.ContextUtils;
+import org.apache.cxf.ws.addressing.EndpointReferenceType;
+
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that the WS-RM decoupled-destination scheme allowlist prevents
+ * SSRF via attacker-controlled wsa:ReplyTo / wsa:FaultTo addresses carried
+ * in WS-RM messages. The guard is {@link 
ContextUtils#isDecoupledDestinationAllowed}
+ * called from {@link InternalContextUtils}'s internal {@code 
DecoupledDestination}.
+ */
+public class DecoupledDestinationProtocolTest {
+
+    @After
+    public void clearSystemProperty() {
+        
System.clearProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Blocked by default
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    public void testFileSchemeIsRejected() throws Exception {
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr("file:///etc/passwd"));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNull("file:// must be blocked", backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    @Test
+    public void testCorbaSchemeIsRejected() throws Exception {
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr("corba:NameService"));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNull("corba: must be blocked", backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Allowed by default
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    public void testHttpSchemeIsAllowed() throws Exception {
+        final String replyTo = "http://callback.example.com/rm/reply";;
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator initiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(initiator);
+        when(initiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNotNull("http:// must be allowed", backChannel);
+        assertSame(mockConduit, backChannel);
+
+        ArgumentCaptor<EndpointReferenceType> eprCaptor =
+            ArgumentCaptor.forClass(EndpointReferenceType.class);
+        verify(initiator).getConduit(
+            any(EndpointInfo.class), eprCaptor.capture(), any(Bus.class));
+        assertEquals(replyTo, eprCaptor.getValue().getAddress().getValue());
+    }
+
+    @Test
+    public void testHttpsSchemeIsAllowed() throws Exception {
+        final String replyTo = "https://callback.example.com/rm/reply";;
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator initiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(initiator);
+        when(initiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNotNull("https:// must be allowed", backChannel);
+        assertSame(mockConduit, backChannel);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // System-property override
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    public void testSystemPropertyOverrideAllowsFile() throws Exception {
+        System.setProperty(
+            ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, 
"file://,http://";);
+
+        final String replyTo = "file:///some/permitted/path";
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        ConduitInitiator initiator = mock(ConduitInitiator.class);
+        Conduit mockConduit = mock(Conduit.class);
+
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+        when(cim.getConduitInitiatorForUri(replyTo)).thenReturn(initiator);
+        when(initiator.getConduit(
+                any(EndpointInfo.class), any(EndpointReferenceType.class), 
any(Bus.class)))
+            .thenReturn(mockConduit);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr(replyTo));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNotNull("file:// must be allowed when explicitly listed in the 
system property",
+            backChannel);
+    }
+
+    @Test
+    public void testSystemPropertyReplacesDefaults() throws Exception {
+        System.setProperty(
+            ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, "jms:");
+
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr("http://callback.example.com/rm/reply";));
+        Conduit backChannel = dest.getBackChannel(inMessage);
+
+        assertNull("http:// must be blocked when absent from the system 
property", backChannel);
+        verify(cim, never()).getConduitInitiatorForUri(any());
+    }
+
+    /**
+     * Empty tokens from leading/trailing/consecutive commas in the system 
property
+     * must not bypass the SSRF allowlist. An empty prefix makes
+     * {@code uri.startsWith("")} true for every URI.
+     */
+    @Test
+    public void testEmptyPropertyTokensDoNotBypassAllowlist() throws Exception 
{
+        Bus bus = mock(Bus.class);
+        ConduitInitiatorManager cim = mock(ConduitInitiatorManager.class);
+        when(bus.getExtension(ConduitInitiatorManager.class)).thenReturn(cim);
+
+        Exchange exchange = buildExchange(bus);
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(exchange);
+
+        // Leading comma: ",http://"; — empty first token must not allow file://
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, 
",http://";);
+        Destination dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr("file:///etc/passwd"));
+        assertNull("leading comma must not allow file:// via empty prefix 
bypass",
+            dest.getBackChannel(inMessage));
+        verify(cim, never()).getConduitInitiatorForUri(any());
+
+        // All-comma value: every token is empty; nothing should be allowed
+        
System.setProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY, ",,,");
+        dest = InternalContextUtils.createDecoupledDestination(
+            exchange, buildEpr("http://callback.example.com/";));
+        assertNull("all-comma property must not allow any URI",
+            dest.getBackChannel(inMessage));
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Helpers
+    // 
-------------------------------------------------------------------------
+
+    private static Exchange buildExchange(Bus bus) {
+        Endpoint endpoint = mock(Endpoint.class);
+        when(endpoint.getEndpointInfo()).thenReturn(new EndpointInfo());
+
+        Exchange exchange = new ExchangeImpl();
+        exchange.put(Bus.class, bus);
+        exchange.put(Endpoint.class, endpoint);
+        return exchange;
+    }
+
+    private static EndpointReferenceType buildEpr(String uri) {
+        AttributedURIType address = new AttributedURIType();
+        address.setValue(uri);
+        EndpointReferenceType epr = new EndpointReferenceType();
+        epr.setAddress(address);
+        return epr;
+    }
+}


Reply via email to