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