DecoupledDestinationProtocolTest.java

/**
 * 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.Before;
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 {

    @Before
    public void clearSystemPropertiesBeforeTest() {
        clearDecoupledDestinationSystemProperties();
    }

    @After
    public void clearSystemPropertiesAfterTest() {
        clearDecoupledDestinationSystemProperties();
    }

    private static void clearDecoupledDestinationSystemProperties() {
        System.clearProperty(ContextUtils.ALLOWED_DECOUPLED_DEST_SCHEMES_PROPERTY);
        System.clearProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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() {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        // 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() {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        // 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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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 {
        System.setProperty(ContextUtils.WS_ADDRESSING_DECOUPLED_ENABLED_PROPERTY, "true");
        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);
    }

    /**
     * With no system properties set, ALL decoupled destinations must be rejected
     * regardless of scheme. This is the new secure-by-default behaviour.
     */
    @Test
    public void testDecoupledAddressingDisabledByDefault() {
        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed("http://callback.example.com/"));
        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed("https://callback.example.com/"));
        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed("jms:queue:replies"));
        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed("file:///etc/passwd"));
        assertEquals(false, ContextUtils.isDecoupledDestinationAllowed(null));
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    private static EndpointReferenceType buildEpr(String uri) {
        AttributedURIType address = new AttributedURIType();
        address.setValue(uri);
        EndpointReferenceType epr = new EndpointReferenceType();
        epr.setAddress(address);
        return epr;
    }
}