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;
+ }
+}