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

coheigea pushed a commit to branch 
coheigea/decoupled-destinations-protocol-check
in repository https://gitbox.apache.org/repos/asf/cxf.git

commit 825d2973a893d18ca0b49e53cd116fa522145837
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Tue Jun 30 14:52:23 2026 +0100

    Enforce scheme checks for decoupled destinations
---
 .../org/apache/cxf/ws/addressing/ContextUtils.java | 142 ++++++--
 .../ws/addressing/impl/InternalContextUtils.java   |  11 +-
 .../impl/DecoupledDestinationProtocolTest.java     | 396 +++++++++++++++++++++
 .../org/apache/cxf/ws/rm/InternalContextUtils.java |   8 +-
 .../ws/rm/DecoupledDestinationProtocolTest.java    | 244 +++++++++++++
 5 files changed, 763 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..3d9b65a748d 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,10 @@ 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.Set;
 import java.util.UUID;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -52,6 +56,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 +587,95 @@ 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;
+        }
+        String prop = 
System.getProperty(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) {
+            if (uri.startsWith(prefix.trim())) {
+                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..8ffe4ab8cd8
--- /dev/null
+++ 
b/rt/ws/addr/src/test/java/org/apache/cxf/ws/addressing/impl/DecoupledDestinationProtocolTest.java
@@ -0,0 +1,396 @@
+/**
+ * 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));
+    }
+
+    // 
-------------------------------------------------------------------------
+    // 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..7abe0f703e1
--- /dev/null
+++ 
b/rt/ws/rm/src/test/java/org/apache/cxf/ws/rm/DecoupledDestinationProtocolTest.java
@@ -0,0 +1,244 @@
+/**
+ * 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());
+    }
+
+    // 
-------------------------------------------------------------------------
+    // 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