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

ffang pushed a commit to branch 3.6.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/3.6.x-fixes by this push:
     new f67af2609aa CXF-8228: Log the headers added to the HttpServletResponse 
(#3620)
f67af2609aa is described below

commit f67af2609aa5bd4584149a9208ce96d8c8e2b504
Author: Freeman(Yue) Fang <[email protected]>
AuthorDate: Tue Oct 27 19:20:55 2026 -0400

    CXF-8228: Log the headers added to the HttpServletResponse (#3620)
    
    * CXF-8228: Log effective HTTP response headers through a transport-owned 
view
    
    Expose a lazy, read-only response-header map through 
Message.RESPONSE_HEADERS.
    The HTTP destination combines servlet headers with pending protocol headers
    using the same value formatting as copyToResponse, without changing the
    response or feeding servlet headers back into PROTOCOL_HEADERS. Reading the
    view after header copying returns the servlet response headers directly.
    
    Have logging copy this transport-neutral view before masking sensitive
    headers, falling back to PROTOCOL_HEADERS when no view is provided. This
    captures application and servlet headers on both normal and bodyless
    responses without a Servlet API dependency in the logging module.
    
    Keep header masking case-insensitive and fall back to protocol headers when
    servlet header inspection fails. Cover lazy evaluation, immutable snapshots,
    header precedence, appended values, transport formatting, fault masking,
    and HTTP responses with and without a body.
    
    * CXF-8228: Address review feedback
    
    (cherry picked from commit fc281cdaaabd0081aef154d7b1c62920fd2aa5f6)
    (cherry picked from commit 8a19d08e035273b43bccefe0a8d385ff0e087d3e)
---
 .../main/java/org/apache/cxf/message/Message.java  |   6 +
 .../cxf/ext/logging/MaskSensitiveHelper.java       |  13 +-
 .../ext/logging/event/DefaultLogEventMapper.java   |  11 +-
 .../cxf/ext/logging/DefaultLogEventMapperTest.java |  87 +++++++++
 .../transport/http/AbstractHTTPDestination.java    |   1 +
 .../org/apache/cxf/transport/http/Headers.java     | 105 +++++++---
 .../cxf/transport/http/ServletResponseHeaders.java |  68 +++++++
 .../transport/http/ServletResponseHeadersTest.java | 215 +++++++++++++++++++++
 .../jaxrs/logging/LoggingServletHeadersTest.java   | 169 ++++++++++++++++
 9 files changed, 644 insertions(+), 31 deletions(-)

diff --git a/core/src/main/java/org/apache/cxf/message/Message.java 
b/core/src/main/java/org/apache/cxf/message/Message.java
index 16bd59eaafa..e61a59a83e3 100644
--- a/core/src/main/java/org/apache/cxf/message/Message.java
+++ b/core/src/main/java/org/apache/cxf/message/Message.java
@@ -121,6 +121,12 @@ public interface Message extends StringMap {
     String REQUEST_URL = "org.apache.cxf.request.url";
 
     String PROTOCOL_HEADERS = Message.class.getName() + ".PROTOCOL_HEADERS";
+    /**
+     * Optional transport-provided, read-only {@code Map<String, 
List<String>>} of effective
+     * outgoing response headers. The view may be evaluated lazily to include 
headers added
+     * after transport preparation. Consumers must copy it before modifying or 
masking values.
+     */
+    String RESPONSE_HEADERS = Message.class.getName() + ".RESPONSE_HEADERS";
     String RESPONSE_CODE = Message.class.getName() + ".RESPONSE_CODE";
     String ERROR_MESSAGE = Message.class.getName() + ".ERROR_MESSAGE";
     String ENDPOINT_ADDRESS = Message.class.getName() + ".ENDPOINT_ADDRESS";
diff --git 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/MaskSensitiveHelper.java
 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/MaskSensitiveHelper.java
index 863e40479a5..33c22cee07a 100644
--- 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/MaskSensitiveHelper.java
+++ 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/MaskSensitiveHelper.java
@@ -21,6 +21,7 @@ package org.apache.cxf.ext.logging;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.regex.Pattern;
 
 import org.apache.cxf.message.Message;
@@ -110,10 +111,14 @@ public class MaskSensitiveHelper {
     public void maskHeaders(
             final Map<String, String> headerMap,
             final Set<String> sensitiveHeaderNames) {
-        sensitiveHeaderNames.stream()
-                .forEach(h -> {
-                    headerMap.computeIfPresent(h, (key, value) -> 
MASKED_HEADER_VALUE);
-                });
+        Set<String> sensitiveNames = new 
TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+        for (String sensitiveName : sensitiveHeaderNames) {
+            if (sensitiveName != null) {
+                sensitiveNames.add(sensitiveName);
+            }
+        }
+        headerMap.replaceAll((name, value) -> name != null && value != null && 
sensitiveNames.contains(name)
+            ? MASKED_HEADER_VALUE : value);
     }
 
     private String applyMasks(String originalLogString, Set<ReplacementPair> 
replacementPairs) {
diff --git 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/event/DefaultLogEventMapper.java
 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/event/DefaultLogEventMapper.java
index ee3a386bec5..fa43d885c77 100644
--- 
a/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/event/DefaultLogEventMapper.java
+++ 
b/rt/features/logging/src/main/java/org/apache/cxf/ext/logging/event/DefaultLogEventMapper.java
@@ -106,7 +106,7 @@ public class DefaultLogEventMapper {
         event.setHttpMethod(safeGet(message, Message.HTTP_REQUEST_METHOD));
         event.setContentType(safeGet(message, Message.CONTENT_TYPE));
 
-        Map<String, String> headerMap = getHeaders(message);
+        Map<String, String> headerMap = getHeaders(message, event.getType());
         if (sensitiveProtocolHeaders != null && 
!sensitiveProtocolHeaders.isEmpty()) {
             maskSensitiveHelper.maskHeaders(headerMap, 
sensitiveProtocolHeaders);
         }
@@ -176,8 +176,13 @@ public class DefaultLogEventMapper {
         }
     }
 
-    private Map<String, String> getHeaders(Message message) {
-        Map<String, List<Object>> headers = CastUtils.cast((Map<?, 
?>)message.get(Message.PROTOCOL_HEADERS));
+    private Map<String, String> getHeaders(Message message, EventType type) {
+        Map<?, ?> responseHeaders = null;
+        if (type == EventType.RESP_OUT || type == EventType.FAULT_OUT) {
+            responseHeaders = (Map<?, ?>)message.get(Message.RESPONSE_HEADERS);
+        }
+        Map<String, List<Object>> headers = CastUtils.cast(responseHeaders != 
null
+            ? responseHeaders : (Map<?, 
?>)message.get(Message.PROTOCOL_HEADERS));
         Map<String, String> result = new HashMap<>();
         if (headers == null) {
             return result;
diff --git 
a/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/DefaultLogEventMapperTest.java
 
b/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/DefaultLogEventMapperTest.java
index f502d8e8de0..c9e37ecbab1 100644
--- 
a/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/DefaultLogEventMapperTest.java
+++ 
b/rt/features/logging/src/test/java/org/apache/cxf/ext/logging/DefaultLogEventMapperTest.java
@@ -24,6 +24,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -132,6 +133,22 @@ public class DefaultLogEventMapperTest {
         assertEquals(MASKED_HEADER_VALUE, 
event.getHeaders().get(TEST_HEADER_NAME));
     }
 
+    @Test
+    public void shouldMaskHeadersIgnoringNullNames() {
+        final DefaultLogEventMapper mapper = new DefaultLogEventMapper();
+        final Message message = new MessageImpl();
+        message.setExchange(new ExchangeImpl());
+        final Map<String, Object> headers = new HashMap<>();
+        headers.put(null, Arrays.asList("HTTP/1.1 200 OK"));
+        headers.put("testheader", Arrays.asList(TEST_HEADER_VALUE));
+        message.put(Message.PROTOCOL_HEADERS, headers);
+        final Set<String> sensitiveHeaders = new HashSet<>(Arrays.asList(null, 
TEST_HEADER_NAME));
+
+        LogEvent event = mapper.map(message, sensitiveHeaders);
+        assertEquals(MASKED_HEADER_VALUE, 
event.getHeaders().get("testheader"));
+        assertEquals("HTTP/1.1 200 OK", event.getHeaders().get(null));
+    }
+
     @Test
     public void testMapNullSensitiveProtocolHeaders() {
         DefaultLogEventMapper mapper = new DefaultLogEventMapper();
@@ -206,4 +223,74 @@ public class DefaultLogEventMapperTest {
         LogEvent event = Subject.doAs(subject, (PrivilegedAction<LogEvent>) () 
-> mapper.map(message));
         assertThat(event.getPrincipal(), is(nullValue()));
     }
+
+    @Test
+    public void testResponseHeaderViewIsCopiedAndMasked() {
+        Map<String, List<String>> headers = new HashMap<>();
+        headers.put("X-Response", Collections.singletonList("effective"));
+        headers.put("set-cookie", Arrays.asList("secret=1", "secret=2"));
+        Message message = createServerOutMessage();
+        message.put(Message.RESPONSE_HEADERS, 
Collections.unmodifiableMap(headers));
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("X-Response", 
Collections.singletonList("protocol")));
+        LogEvent event = new DefaultLogEventMapper().map(message, 
Collections.singleton("Set-Cookie"));
+        assertEquals(EventType.RESP_OUT, event.getType());
+        assertEquals("effective", event.getHeaders().get("X-Response"));
+        assertEquals(MASKED_HEADER_VALUE, 
event.getHeaders().get("set-cookie"));
+        assertEquals(Arrays.asList("secret=1", "secret=2"), 
headers.get("set-cookie"));
+    }
+
+    @Test
+    public void testResponseHeadersNotUsedForRequest() {
+        Message message = createServerOutMessage();
+        Message inMessage = new MessageImpl();
+        inMessage.setExchange(message.getExchange());
+        inMessage.put(Message.RESPONSE_HEADERS,
+            Collections.singletonMap("X-Response", 
Collections.singletonList("response")));
+        inMessage.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("X-Request", 
Collections.singletonList("request")));
+        LogEvent event = new DefaultLogEventMapper().map(inMessage);
+        assertEquals(EventType.REQ_IN, event.getType());
+        assertEquals(Collections.singletonMap("X-Request", "request"), 
event.getHeaders());
+    }
+
+    @Test
+    public void testProtocolHeadersUsedWithoutResponseView() {
+        Message message = createServerOutMessage();
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("AUTHORIZATION", 
Collections.singletonList("secret")));
+        LogEvent event = new DefaultLogEventMapper().map(message, 
Collections.singleton("Authorization"));
+        assertEquals(Collections.singletonMap("AUTHORIZATION", 
MASKED_HEADER_VALUE), event.getHeaders());
+    }
+
+    @Test
+    public void testFaultUsesResponseHeaderView() {
+        Message message = createServerOutMessage();
+        message.getExchange().setOutFaultMessage(message);
+        message.put(Message.RESPONSE_HEADERS,
+            Collections.singletonMap("set-cookie", 
Collections.singletonList("secret")));
+        LogEvent event = new DefaultLogEventMapper().map(message, 
Collections.singleton("Set-Cookie"));
+        assertEquals(EventType.FAULT_OUT, event.getType());
+        assertEquals(Collections.singletonMap("set-cookie", 
MASKED_HEADER_VALUE), event.getHeaders());
+    }
+
+    @Test
+    public void testEmptyResponseViewIsAuthoritative() {
+        Message message = createServerOutMessage();
+        message.put(Message.RESPONSE_HEADERS, Collections.emptyMap());
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("X-Removed", 
Collections.singletonList("protocol")));
+        assertEquals(Collections.emptyMap(), new 
DefaultLogEventMapper().map(message).getHeaders());
+    }
+
+    private static Message createServerOutMessage() {
+        Message message = new MessageImpl();
+        Exchange exchange = new ExchangeImpl();
+        message.setExchange(exchange);
+        exchange.setOutMessage(message);
+        exchange.put("org.apache.cxf.resource.operation.name", "test");
+        message.put(Message.HTTP_REQUEST_METHOD, "GET");
+        message.put(Message.REQUEST_URI, "test");
+        return message;
+    }
 }
diff --git 
a/rt/transports/http/src/main/java/org/apache/cxf/transport/http/AbstractHTTPDestination.java
 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/AbstractHTTPDestination.java
index 18aaeedb475..06b27b129b8 100644
--- 
a/rt/transports/http/src/main/java/org/apache/cxf/transport/http/AbstractHTTPDestination.java
+++ 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/AbstractHTTPDestination.java
@@ -754,6 +754,7 @@ public abstract class AbstractHTTPDestination
          */
         public void prepare(Message message) throws IOException {
             message.put(HTTP_RESPONSE, response);
+            message.put(Message.RESPONSE_HEADERS, 
ServletResponseHeaders.of(message, response));
             OutputStream os = message.getContent(OutputStream.class);
             if (os == null) {
                 message.setContent(OutputStream.class,
diff --git 
a/rt/transports/http/src/main/java/org/apache/cxf/transport/http/Headers.java 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/Headers.java
index 070f9f3d760..034a589b2a7 100644
--- 
a/rt/transports/http/src/main/java/org/apache/cxf/transport/http/Headers.java
+++ 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/Headers.java
@@ -467,7 +467,7 @@ public class Headers {
         return 
PropertyUtils.isTrue(message.getContextualProperty(ALLOW_LOGGING_SENSITIVE_HEADERS));
     }
 
-    private String getContentTypeFromMessage() {
+    private static String getContentTypeFromMessage(Message message) {
         final String ct = (String)message.get(Message.CONTENT_TYPE);
         final String enc = (String)message.get(Message.ENCODING);
 
@@ -482,7 +482,7 @@ public class Headers {
 
     // Assumes that response body is not available only
     // if Content-Length is available and set to 0
-    private boolean isResponseBodyAvailable() {
+    private static boolean isResponseBodyAvailable(Map<String, List<String>> 
headers) {
         List<String> ctLen = headers.get("Content-Length");
         if (ctLen == null || ctLen.size() != 1) {
             return true;
@@ -497,7 +497,7 @@ public class Headers {
         return true;
     }
 
-    private boolean isSingleHeader(String header) {
+    private static boolean isSingleHeader(String header) {
         return HTTP_HEADERS_SETCOOKIE.equalsIgnoreCase(header) || 
HTTP_HEADERS_LINK.equalsIgnoreCase(header);
     }
 
@@ -507,43 +507,100 @@ public class Headers {
      * @param response the current ServletResponse
      */
     protected void copyToResponse(HttpServletResponse response) {
-        String contentType = getContentTypeFromMessage();
+        String contentType = getContentTypeFromMessage(message);
 
         if (!headers.containsKey(Message.CONTENT_TYPE) && contentType != null
-            && isResponseBodyAvailable()) {
+            && isResponseBodyAvailable(headers)) {
             response.setContentType(contentType);
         }
 
         boolean addHeaders = MessageUtils.getContextualBoolean(message, 
ADD_HEADERS_PROPERTY, false);
         for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
             String header = entry.getKey();
-            List<?> headerList = entry.getValue();
-
+            List<String> values = responseHeaderValues(header, 
entry.getValue(), addHeaders);
             if (addHeaders || isSingleHeader(header)) {
-                for (int i = 0; i < headerList.size(); i++) {
-                    Object headerObject = headerList.get(i);
-                    if (headerObject != null) {
-                        response.addHeader(header, 
headerObjectToString(headerObject));
-                    }
+                for (String value : values) {
+                    response.addHeader(header, value);
                 }
             } else {
-                StringBuilder sb = new StringBuilder();
-                for (int i = 0; i < headerList.size(); i++) {
-                    Object headerObject = headerList.get(i);
-                    if (headerObject != null) {
-                        sb.append(headerObjectToString(headerObject));
-                    }
-
-                    if (i + 1 < headerList.size()) {
-                        sb.append(',');
-                    }
+                response.setHeader(header, values.get(0));
+            }
+        }
+    }
+
+    /**
+     * Returns a snapshot of the response headers without modifying the 
servlet response or the message,
+     * unlike the Headers(Message) constructor, which may set the protocol 
headers of the message.
+     */
+    static Map<String, List<String>> getResponseHeaders(Message message, 
HttpServletResponse response) {
+        boolean addHeaders = MessageUtils.getContextualBoolean(message, 
ADD_HEADERS_PROPERTY, false);
+        Map<String, List<String>> protocol = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        Map<String, List<?>> headers = CastUtils.cast((Map<?, 
?>)message.get(Message.PROTOCOL_HEADERS));
+        if (headers != null) {
+            for (Map.Entry<String, List<?>> entry : headers.entrySet()) {
+                List<String> values = responseHeaderValues(entry.getKey(), 
entry.getValue(), addHeaders);
+                if (!values.isEmpty()) {
+                    protocol.put(entry.getKey(), values);
+                }
+            }
+        }
+        String contentType = getContentTypeFromMessage(message);
+        boolean setContentType = !protocol.containsKey(Message.CONTENT_TYPE)
+            && contentType != null && isResponseBodyAvailable(protocol);
+        if (setContentType) {
+            protocol.put(Message.CONTENT_TYPE, 
Collections.singletonList(contentType));
+        }
+
+        Map<String, List<String>> effective = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        try {
+            for (String name : response.getHeaderNames()) {
+                effective.put(name, Collections.unmodifiableList(new 
ArrayList<>(response.getHeaders(name))));
+            }
+        } catch (RuntimeException ex) {
+            // Wrappers and recycled responses may not expose headers. Discard 
any partial
+            // snapshot and retain the protocol headers so optional inspection 
cannot fail a request.
+            return Collections.unmodifiableMap(protocol);
+        }
+        if 
(!PropertyUtils.isTrue(message.get(AbstractHTTPDestination.RESPONSE_HEADERS_COPIED)))
 {
+            for (Map.Entry<String, List<String>> entry : protocol.entrySet()) {
+                String name = entry.getKey();
+                // The container replaces Content-Type, even when it is added 
with addHeader
+                if (!Message.CONTENT_TYPE.equalsIgnoreCase(name)
+                    && (addHeaders || isSingleHeader(name)) && 
effective.containsKey(name)) {
+                    List<String> combined = new 
ArrayList<>(effective.get(name));
+                    combined.addAll(entry.getValue());
+                    effective.put(name, 
Collections.unmodifiableList(combined));
+                } else {
+                    effective.put(name, entry.getValue());
+                }
+            }
+        }
+        return Collections.unmodifiableMap(effective);
+    }
+
+    private static List<String> responseHeaderValues(String header, List<?> 
values, boolean addHeaders) {
+        if (addHeaders || isSingleHeader(header)) {
+            List<String> result = new ArrayList<>();
+            for (Object value : values) {
+                if (value != null) {
+                    result.add(headerObjectToString(value));
                 }
-                response.setHeader(header, sb.toString());
+            }
+            return Collections.unmodifiableList(result);
+        }
+        StringBuilder combined = new StringBuilder();
+        for (int i = 0; i < values.size(); i++) {
+            if (values.get(i) != null) {
+                combined.append(headerObjectToString(values.get(i)));
+            }
+            if (i + 1 < values.size()) {
+                combined.append(',');
             }
         }
+        return Collections.singletonList(combined.toString());
     }
 
-    private String headerObjectToString(Object headerObject) {
+    private static String headerObjectToString(Object headerObject) {
         if (headerObject.getClass() == String.class) {
             // Most likely
             return headerObject.toString();
diff --git 
a/rt/transports/http/src/main/java/org/apache/cxf/transport/http/ServletResponseHeaders.java
 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/ServletResponseHeaders.java
new file mode 100644
index 00000000000..02b2eb289d5
--- /dev/null
+++ 
b/rt/transports/http/src/main/java/org/apache/cxf/transport/http/ServletResponseHeaders.java
@@ -0,0 +1,68 @@
+/**
+ * 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.transport.http;
+
+import java.util.AbstractMap;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.cxf.message.Message;
+
+/**
+ * A response header view evaluated when read, after application interceptors 
have added headers.
+ * Each iteration uses an immutable snapshot; the transport owns servlet 
access and merging rules.
+ */
+final class ServletResponseHeaders extends AbstractMap<String, List<String>> {
+    private final Message message;
+    private final HttpServletResponse response;
+
+    private ServletResponseHeaders(Message message, HttpServletResponse 
response) {
+        this.message = message;
+        this.response = response;
+    }
+
+    /** Returns the view, which cannot be modified. */
+    static Map<String, List<String>> of(Message message, HttpServletResponse 
response) {
+        return Collections.unmodifiableMap(new ServletResponseHeaders(message, 
response));
+    }
+
+    @Override
+    public Set<Entry<String, List<String>>> entrySet() {
+        return snapshot().entrySet();
+    }
+
+    // Look up in the case-insensitive snapshot, as AbstractMap compares the 
keys with equals
+    @Override
+    public List<String> get(Object key) {
+        return snapshot().get(key);
+    }
+
+    @Override
+    public boolean containsKey(Object key) {
+        return snapshot().containsKey(key);
+    }
+
+    private Map<String, List<String>> snapshot() {
+        return Headers.getResponseHeaders(message, response);
+    }
+}
diff --git 
a/rt/transports/http/src/test/java/org/apache/cxf/transport/http/ServletResponseHeadersTest.java
 
b/rt/transports/http/src/test/java/org/apache/cxf/transport/http/ServletResponseHeadersTest.java
new file mode 100644
index 00000000000..6d0abf8ec85
--- /dev/null
+++ 
b/rt/transports/http/src/test/java/org/apache/cxf/transport/http/ServletResponseHeadersTest.java
@@ -0,0 +1,215 @@
+/**
+ * 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.transport.http;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ServletResponseHeadersTest {
+    @Test
+    public void testLazyViewAndUnmodifiableSnapshots() {
+        Message message = new MessageImpl();
+        Map<String, List<String>> actual = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        HttpServletResponse response = response(actual);
+        Map<String, List<String>> view = view(message, response);
+        assertTrue(view.isEmpty());
+        assertFalse(message.containsKey(Message.PROTOCOL_HEADERS));
+        actual.put("X-Servlet", Collections.singletonList("late"));
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("X-Protocol", 
Collections.singletonList("late-protocol")));
+        assertEquals(Collections.singletonList("late"), view.get("X-Servlet"));
+        assertEquals(Collections.singletonList("late-protocol"), 
view.get("X-Protocol"));
+        assertEquals(Collections.singletonList("late"), view.get("x-servlet"));
+        assertTrue(view.containsKey("x-protocol"));
+        assertThrows(UnsupportedOperationException.class, () -> view.put("X", 
Collections.emptyList()));
+        assertThrows(UnsupportedOperationException.class, () -> 
view.remove("X-Servlet"));
+        assertThrows(UnsupportedOperationException.class, () -> 
view.remove("X-Missing"));
+        assertThrows(UnsupportedOperationException.class, view::clear);
+        assertThrows(UnsupportedOperationException.class, () -> 
view.get("X-Servlet").add("changed"));
+        assertThrows(UnsupportedOperationException.class, () -> 
view.entrySet().iterator().next()
+            .setValue(Collections.singletonList("changed")));
+        assertEquals(1, actual.size());
+    }
+
+    @Test
+    public void testSameHeadersBeforeAndAfterCopying() {
+        for (boolean append : new boolean[] {false, true}) {
+            Message message = new MessageImpl();
+            message.put(Headers.ADD_HEADERS_PROPERTY, append);
+            Map<String, List<String>> protocol = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            protocol.put("Set-Cookie", Arrays.asList("jaxrs=1", "jaxrs=2"));
+            protocol.put("Link", Collections.singletonList("<jaxrs>"));
+            protocol.put("X-Shared", Arrays.asList("a", "b,c"));
+            message.put(Message.PROTOCOL_HEADERS, protocol);
+            Map<String, List<String>> actual = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            actual.put("set-cookie", Collections.singletonList("servlet=1"));
+            actual.put("link", Collections.singletonList("<servlet>"));
+            actual.put("x-shared", Collections.singletonList("servlet"));
+            actual.put("X-Servlet-Only", Collections.singletonList("keep"));
+            HttpServletResponse response = response(actual);
+            Map<String, List<String>> view = view(message, response);
+            Map<String, List<String>> before = snapshot(view);
+            assertEquals(Arrays.asList("servlet=1", "jaxrs=1", "jaxrs=2"), 
before.get("Set-Cookie"));
+            assertEquals(Arrays.asList("<servlet>", "<jaxrs>"), 
before.get("Link"));
+            assertEquals(append ? Arrays.asList("servlet", "a", "b,c") : 
Collections.singletonList("a,b,c"),
+                         before.get("X-Shared"));
+            // Inspection must not feed servlet headers back into headers 
waiting to be sent.
+            assertEquals(Arrays.asList("a", "b,c"), protocol.get("X-Shared"));
+            assertFalse(protocol.containsKey("X-Servlet-Only"));
+            assertEquals(Collections.singletonList("servlet=1"), 
actual.get("Set-Cookie"));
+            new Headers(message).copyToResponse(response);
+            message.put(AbstractHTTPDestination.RESPONSE_HEADERS_COPIED, 
"true");
+            assertEquals(before, snapshot(view));
+            assertEquals(before, actual);
+        }
+    }
+
+    @Test
+    public void testTransportFormatsDatesAndLocales() {
+        Message message = new MessageImpl();
+        Map<String, List<Object>> protocol = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        Date date = new Date(0);
+        protocol.put("Expires", Collections.singletonList(date));
+        protocol.put("Content-Language", Collections.singletonList(Locale.US));
+        message.put(Message.PROTOCOL_HEADERS, protocol);
+        Map<String, List<String>> actual = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        HttpServletResponse response = response(actual);
+        Map<String, List<String>> before = snapshot(view(message, response));
+        assertEquals(Collections.singletonList(Headers.toHttpDate(date)), 
before.get("Expires"));
+        new Headers(message).copyToResponse(response);
+        assertEquals(before, actual);
+    }
+
+    @Test
+    public void testContentTypePrecedence() {
+        for (boolean append : new boolean[] {false, true}) {
+            Message message = new MessageImpl();
+            message.put(Headers.ADD_HEADERS_PROPERTY, append);
+            message.put(Message.CONTENT_TYPE, "text/plain");
+            Map<String, List<String>> actual = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            actual.put("Content-Type", 
Collections.singletonList("application/servlet"));
+            HttpServletResponse response = response(actual);
+            Map<String, List<String>> before = snapshot(view(message, 
response));
+            assertEquals(Collections.singletonList("text/plain"), 
before.get("Content-Type"));
+            new Headers(message).copyToResponse(response);
+            assertEquals(before, actual);
+        }
+    }
+
+    @Test
+    public void testProtocolContentTypeReplacesServletValue() {
+        for (boolean append : new boolean[] {false, true}) {
+            Message message = new MessageImpl();
+            message.put(Headers.ADD_HEADERS_PROPERTY, append);
+            Map<String, List<String>> protocol = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            protocol.put("Content-Type", 
Collections.singletonList("text/plain"));
+            message.put(Message.PROTOCOL_HEADERS, protocol);
+            Map<String, List<String>> actual = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            actual.put("Content-Type", 
Collections.singletonList("application/servlet"));
+            HttpServletResponse response = response(actual);
+            Map<String, List<String>> before = snapshot(view(message, 
response));
+            assertEquals(Collections.singletonList("text/plain"), 
before.get("Content-Type"));
+            new Headers(message).copyToResponse(response);
+            assertEquals(before, actual);
+        }
+    }
+
+    @Test
+    public void testUnsupportedHeaderNamesFallsBackToProtocol() {
+        Message message = new MessageImpl();
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("Authorization", 
Collections.singletonList("secret")));
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(response.getHeaderNames()).thenThrow(new 
UnsupportedOperationException());
+        assertEquals(Collections.singletonMap("Authorization", 
Collections.singletonList("secret")),
+                     snapshot(view(message, response)));
+    }
+
+    @Test
+    public void testFailedHeaderReadDiscardsPartialSnapshot() {
+        Message message = new MessageImpl();
+        message.put(AbstractHTTPDestination.RESPONSE_HEADERS_COPIED, "true");
+        message.put(Message.PROTOCOL_HEADERS,
+            Collections.singletonMap("X-Shared", 
Collections.singletonList("original")));
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(response.getHeaderNames()).thenReturn(Arrays.asList("X-Shared", 
"X-Failing"));
+        
when(response.getHeaders("X-Shared")).thenReturn(Collections.singletonList("changed"));
+        when(response.getHeaders("X-Failing")).thenThrow(new 
IllegalStateException("Response recycled"));
+        assertEquals(Collections.singletonMap("X-Shared", 
Collections.singletonList("original")),
+                     snapshot(view(message, response)));
+    }
+
+    private static Map<String, List<String>> snapshot(Map<String, 
List<String>> view) {
+        Map<String, List<String>> result = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        result.putAll(view);
+        return result;
+    }
+
+    private static Map<String, List<String>> view(Message message, 
HttpServletResponse response) {
+        Map<String, List<String>> view = ServletResponseHeaders.of(message, 
response);
+        message.put(Message.RESPONSE_HEADERS, view);
+        return view;
+    }
+
+    private static HttpServletResponse response(Map<String, List<String>> 
headers) {
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(response.getHeaderNames()).thenAnswer(invocation -> 
headers.keySet());
+        when(response.getHeaders(anyString())).thenAnswer(invocation -> 
headers.get(invocation.getArgument(0)));
+        doAnswer(invocation -> {
+            headers.put(invocation.getArgument(0), 
Collections.singletonList(invocation.getArgument(1)));
+            return null;
+        }).when(response).setHeader(anyString(), anyString());
+        doAnswer(invocation -> {
+            String name = invocation.getArgument(0);
+            // like the containers, adding a Content-Type replaces it
+            List<String> values = Message.CONTENT_TYPE.equalsIgnoreCase(name)
+                ? new ArrayList<>() : new 
ArrayList<>(headers.getOrDefault(name, Collections.emptyList()));
+            values.add(invocation.getArgument(1));
+            headers.put(name, values);
+            return null;
+        }).when(response).addHeader(anyString(), anyString());
+        doAnswer(invocation -> {
+            headers.put(Message.CONTENT_TYPE, 
Collections.singletonList(invocation.getArgument(0)));
+            return null;
+        }).when(response).setContentType(anyString());
+        return response;
+    }
+}
diff --git 
a/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/logging/LoggingServletHeadersTest.java
 
b/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/logging/LoggingServletHeadersTest.java
new file mode 100644
index 00000000000..25ce9ab85b4
--- /dev/null
+++ 
b/systests/jaxrs/src/test/java/org/apache/cxf/systest/jaxrs/logging/LoggingServletHeadersTest.java
@@ -0,0 +1,169 @@
+/**
+ * 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.systest.jaxrs.logging;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+
+import org.apache.cxf.Bus;
+import org.apache.cxf.endpoint.Server;
+import org.apache.cxf.ext.logging.LoggingFeature;
+import org.apache.cxf.ext.logging.event.EventType;
+import org.apache.cxf.ext.logging.event.LogEvent;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.apache.cxf.testutil.common.AbstractBusClientServerTestBase;
+import org.apache.cxf.testutil.common.AbstractServerTestServerBase;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * CXF-8228: headers added to the HttpServletResponse by the application must 
be logged.
+ */
+public class LoggingServletHeadersTest extends AbstractBusClientServerTestBase 
{
+    static final String PORT = allocatePort(LoggingServletHeadersTest.class);
+    static final BlockingQueue<LogEvent> RESPONSE_EVENTS = new 
LinkedBlockingQueue<>();
+
+    public static class HeadersServer extends AbstractServerTestServerBase {
+        @Override
+        protected Server createServer(Bus bus) throws Exception {
+            JAXRSServerFactoryBean sf = new JAXRSServerFactoryBean();
+            sf.setResourceClasses(HeadersResource.class);
+            sf.setResourceProvider(HeadersResource.class,
+                new SingletonResourceProvider(new HeadersResource(), false));
+            sf.setAddress("http://localhost:"; + PORT + "/");
+            LoggingFeature logging = new LoggingFeature();
+            logging.setSensitiveProtocolHeaderNames(new 
HashSet<>(Arrays.asList(
+                "sEt-CoOkIe", "X-Servlet-Secret", "x-protocol-secret")));
+            logging.setOutSender(event -> {
+                if (event.getType() == EventType.RESP_OUT) {
+                    RESPONSE_EVENTS.add(event);
+                }
+            });
+            sf.getFeatures().add(logging);
+            return sf.create();
+        }
+    }
+
+    @Path("/headers")
+    public static class HeadersResource {
+        @Context
+        private HttpServletResponse servletResponse;
+
+        @GET
+        @Path("/body")
+        @Produces("text/plain")
+        public Response body() {
+            servletResponse.setHeader("X-Servlet-Header", "servlet");
+            servletResponse.setHeader("Content-Type", "application/servlet");
+            servletResponse.addHeader("Set-Cookie", "servlet=1");
+            servletResponse.setHeader("x-servlet-secret", 
"servlet-secret-value");
+            servletResponse.addHeader("Link", "<servlet>");
+            return Response.ok("hello").type("text/plain")
+                .header("X-Response-Header", "jaxrs").header("Set-Cookie", 
"jaxrs=1")
+                .header("X-Protocol-Secret", 
"protocol-secret-value").header("Link", "<jaxrs>")
+                .header("X-Multi", "a").header("X-Multi", "b").build();
+        }
+
+        @GET
+        @Path("/empty")
+        public Response empty() {
+            servletResponse.setHeader("X-Servlet-Header", "servlet");
+            servletResponse.setHeader("Content-Type", "application/servlet");
+            servletResponse.addHeader("Set-Cookie", "servlet=1");
+            servletResponse.setHeader("x-servlet-secret", 
"servlet-secret-value");
+            servletResponse.addHeader("Link", "<servlet>");
+            return Response.noContent().header("X-Response-Header", 
"jaxrs").header("Set-Cookie", "jaxrs=1")
+                .header("X-Protocol-Secret", 
"protocol-secret-value").header("Link", "<jaxrs>")
+                .header("X-Multi", "a").header("X-Multi", "b").build();
+        }
+    }
+
+    @BeforeClass
+    public static void startServers() {
+        assertTrue("server did not launch correctly", 
launchServer(HeadersServer.class, true));
+    }
+
+    @Before
+    public void clearEvents() {
+        RESPONSE_EVENTS.clear();
+    }
+
+    @Test
+    public void testServletHeadersLogged() throws Exception {
+        assertHeadersLogged("/headers/body", 200);
+    }
+
+    @Test
+    public void testServletHeadersLoggedWithoutBody() throws Exception {
+        assertHeadersLogged("/headers/empty", 204);
+    }
+
+    private static void assertHeadersLogged(String path, int status) throws 
Exception {
+        Response response = WebClient.create("http://localhost:"; + PORT + 
path).get();
+        assertEquals(status, response.getStatus());
+        assertEquals("servlet", response.getHeaderString("X-Servlet-Header"));
+        assertEquals("jaxrs", response.getHeaderString("X-Response-Header"));
+        if (status == 200) {
+            assertTrue(response.getHeaderString("Content-Type"),
+                       
response.getHeaderString("Content-Type").startsWith("text/plain"));
+        }
+        assertEquals(Arrays.asList("servlet=1", "jaxrs=1"), 
response.getStringHeaders().get("Set-Cookie"));
+
+        assertEquals("servlet-secret-value", 
response.getHeaderString("x-servlet-secret"));
+        assertEquals("protocol-secret-value", 
response.getHeaderString("X-Protocol-Secret"));
+
+        LogEvent event = RESPONSE_EVENTS.poll(5, TimeUnit.SECONDS);
+        assertNotNull("no response was logged", event);
+        Map<String, String> headers = event.getHeaders();
+        assertEquals(headers.toString(), "jaxrs", 
headers.get("X-Response-Header"));
+        assertEquals(headers.toString(), "servlet", 
headers.get("X-Servlet-Header"));
+        assertEquals(headers.toString(), "[<servlet>, <jaxrs>]", 
headers.get("Link"));
+        assertEquals("a,b", headers.get("X-Multi"));
+        assertEquals("XXX", headers.get("Set-Cookie"));
+        assertEquals("XXX", headers.get("x-servlet-secret"));
+        assertEquals("XXX", headers.get("X-Protocol-Secret"));
+        for (String value : headers.values()) {
+            assertFalse(value.contains("servlet=1"));
+            assertFalse(value.contains("jaxrs=1"));
+            assertFalse(value.contains("servlet-secret-value"));
+            assertFalse(value.contains("protocol-secret-value"));
+        }
+    }
+}

Reply via email to