Headers.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.transport.http;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URLConnection;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.cxf.common.logging.LogUtils;
import org.apache.cxf.common.util.PropertyUtils;
import org.apache.cxf.helpers.CastUtils;
import org.apache.cxf.helpers.HttpHeaderHelper;
import org.apache.cxf.message.Message;
import org.apache.cxf.message.MessageUtils;
import org.apache.cxf.transports.http.configuration.HTTPClientPolicy;
import org.apache.cxf.transports.http.configuration.HTTPServerPolicy;
import org.apache.cxf.version.Version;

public class Headers {
    /**
     *  This constant is the Message(Map) key for the HttpURLConnection that
     *  is used to get the response.
     */
    public static final String KEY_HTTP_CONNECTION = "http.connection";
    /**
     * Each header value is added as a separate HTTP header, example, given A header with 'a' and 'b'
     * values, two A headers will be added as opposed to a single A header with the "a,b" value.
     */
    public static final String ADD_HEADERS_PROPERTY = "org.apache.cxf.http.add-headers";

    public static final String PROTOCOL_HEADERS_CONTENT_TYPE = Message.CONTENT_TYPE.toLowerCase();
    public static final String HTTP_HEADERS_SETCOOKIE = "Set-Cookie";
    public static final String HTTP_HEADERS_LINK = "Link";
    public static final String EMPTY_REQUEST_PROPERTY = "org.apache.cxf.empty.request";
    public static final String USER_AGENT = initUserAgent();
    public static final String SET_EMPTY_REQUEST_CT_PROPERTY = "set.content.type.for.empty.request";
    private static final TimeZone TIME_ZONE_GMT = TimeZone.getTimeZone("GMT");
    private static final Logger LOG = LogUtils.getL7dLogger(Headers.class);

    private static final String SENSITIVE_HEADERS_PROP_NAME = "org.apache.http.sensitive.headers";
    private static final Set<String> DEFAULT_SENSITIVE_HEADERS = Set.of("Authorization", "Proxy-Authorization");
    private static final List<Object> SENSITIVE_HEADER_MARKER = Collections.singletonList("***");
    private static final String ALLOW_LOGGING_SENSITIVE_HEADERS = "allow.logging.sensitive.headers";

    private final Message message;
    private final Map<String, List<String>> headers;

    public Headers(Message message) {
        this.message = message;
        this.headers = getSetProtocolHeaders(message);
    }
    public Headers() {
        this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        this.message = null;
    }

    public static String getUserAgent() {
        return USER_AGENT;
    }

    private static String initUserAgent() {
        String name = Version.getName();
        if ("Apache CXF".equals(name)) {
            name = "Apache-CXF";
        }
        String version = Version.getCurrentVersion();
        return name + "/" + version;
    }

    /**
     * Returns a traceable string representation of the passed-in headers map.
     * The value for any keys in the map that are in the <code>SENSITIVE_HEADERS</code>
     * array will be filtered out of the returned string.
     * Note that this method is expensive as it will copy the map (except for the
     * filtered keys), so it should be used sparingly - i.e. only when debug is
     * enabled.
     */
    static String toString(Map<String, List<Object>> headers, Set<String> sensitiveHeaders,
            boolean logSensitiveHeaders) {
        Map<String, List<Object>> filteredHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        filteredHeaders.putAll(CastUtils.cast(headers));
        if (!logSensitiveHeaders) {
            for (String filteredKey : sensitiveHeaders) {
                filteredHeaders.put(filteredKey, SENSITIVE_HEADER_MARKER);
            }
        }
        return filteredHeaders.toString();
    }

    public Map<String, List<String>> headerMap() {
        return headers;
    }


    /**
     * Write cookie header from given session cookies
     *
     * @param sessionCookies
     */
    public void writeSessionCookies(Map<String, Cookie> sessionCookies) {
        List<String> cookies = null;
        for (String s : headers.keySet()) {
            if (HttpHeaderHelper.COOKIE.equalsIgnoreCase(s)) {
                cookies = headers.remove(s);
                break;
            }
        }
        if (cookies == null) {
            cookies = new ArrayList<>();
        } else {
            cookies = new ArrayList<>(cookies);
        }
        headers.put(HttpHeaderHelper.COOKIE, cookies);
        for (Cookie c : sessionCookies.values()) {
            cookies.add(c.requestCookieHeader());
        }
    }

    /**
     * This call places HTTP Header strings into the headers that are relevant
     * to the ClientPolicy that is set on this conduit by configuration.
     *
     * REVISIT: A cookie is set statically from configuration?
     */
    void setFromClientPolicy(HTTPClientPolicy policy) {
        if (policy == null) {
            return;
        }
        if (policy.isSetCacheControl()) {
            headers.put("Cache-Control",
                    createMutableList(policy.getCacheControl()));
        }
        if (policy.isSetHost()) {
            headers.put("Host",
                    createMutableList(policy.getHost()));
        }
        if (policy.isSetConnection()) {
            headers.put("Connection",
                    createMutableList(policy.getConnection().value()));
        }
        if (policy.isSetAccept()) {
            headers.put("Accept",
                    createMutableList(policy.getAccept()));
        } else if (!headers.containsKey("Accept")) {
            headers.put("Accept", createMutableList("*/*"));
        }
        if (policy.isSetAcceptEncoding()) {
            headers.put("Accept-Encoding",
                    createMutableList(policy.getAcceptEncoding()));
        }
        if (policy.isSetAcceptLanguage()) {
            headers.put("Accept-Language",
                    createMutableList(policy.getAcceptLanguage()));
        }
        if (policy.isSetContentType()) {
            message.put(Message.CONTENT_TYPE, policy.getContentType());
        }
        if (policy.isSetCookie()) {
            headers.put("Cookie",
                    createMutableList(policy.getCookie()));
        }
        if (policy.isSetBrowserType()) {
            headers.put("User-Agent",
                    createMutableList(policy.getBrowserType()));
        }
        if (policy.isSetReferer()) {
            headers.put("Referer",
                    createMutableList(policy.getReferer()));
        }
    }

    void setFromServerPolicy(HTTPServerPolicy policy) {
        if (policy.isSetCacheControl()) {
            headers.put("Cache-Control",
                        createMutableList(policy.getCacheControl()));
        }
        if (policy.isSetContentLocation()) {
            headers.put("Content-Location",
                        createMutableList(policy.getContentLocation()));
        }
        if (policy.isSetContentEncoding()) {
            headers.put("Content-Encoding",
                        createMutableList(policy.getContentEncoding()));
        }
        if (policy.isSetContentType()) {
            headers.put(HttpHeaderHelper.CONTENT_TYPE,
                        createMutableList(policy.getContentType()));
        }
        if (policy.isSetServerType()) {
            headers.put("Server",
                        createMutableList(policy.getServerType()));
        }
        if (policy.isSetHonorKeepAlive() && !policy.isHonorKeepAlive()) {
            headers.put("Connection",
                        createMutableList("close"));
        } else if (policy.isSetKeepAliveParameters()) {
            headers.put("Keep-Alive", createMutableList(policy.getKeepAliveParameters()));
        }


    /*
     * TODO - hook up these policies
    <xs:attribute name="SuppressClientSendErrors" type="xs:boolean" use="optional" default="false">
    <xs:attribute name="SuppressClientReceiveErrors" type="xs:boolean" use="optional" default="false">
    */
    }

    public void removeAuthorizationHeaders() {
        headers.remove("Authorization");
        headers.remove("Proxy-Authorization");
    }

    public void setAuthorization(String authorization) {
        headers.put("Authorization",
                createMutableList(authorization));
    }

    public void setProxyAuthorization(String authorization) {
        headers.put("Proxy-Authorization",
                createMutableList(authorization));
    }


    /**
     * While extracting the Message.PROTOCOL_HEADERS property from the Message,
     * this call ensures that the Message.PROTOCOL_HEADERS property is
     * set on the Message. If it is not set, an empty map is placed there, and
     * then returned.
     *
     * @param message The outbound message
     * @return The PROTOCOL_HEADERS map
     */
    public static Map<String, List<String>> getSetProtocolHeaders(final Message message) {
        Map<String, List<String>> headers =
            CastUtils.cast((Map<?, ?>)message.get(Message.PROTOCOL_HEADERS));
        if (null == headers) {
            headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        } else if (headers instanceof HashMap) {
            Map<String, List<String>> headers2
                = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            headers2.putAll(headers);
            headers = headers2;
        }
        message.put(Message.PROTOCOL_HEADERS, headers);
        return headers;
    }

    public void readFromConnection(HttpURLConnection connection) {
        readFromConnection(connection.getHeaderFields());
    }
    public void readFromConnection(Map<String, List<String>> origHeaders) {
        headers.clear();
        for (Entry<String, List<String>> entry : origHeaders.entrySet()) {
            if (entry.getKey() != null) {
                String key = HttpHeaderHelper.getHeaderKey(entry.getKey());
                List<String> old = headers.get(key);
                if (old != null) {
                    List<String> nl = new ArrayList<>(old.size() + entry.getValue().size());
                    nl.addAll(old);
                    nl.addAll(entry.getValue());
                    headers.put(key, nl);
                } else {
                    headers.put(key, entry.getValue());
                }
            }
        }
    }
    private static List<String> createMutableList(String val) {
        return new ArrayList<>(Arrays.asList(val));
    }

    /**
     * This procedure logs the PROTOCOL_HEADERS from the
     * Message at the specified logging level.
     *
     * @param logger     The Logger to log to.
     * @param level   The Logging Level.
     * @param message The Message.
     * @param logSensitiveHeaders whether to log sensitive headers
     */
    static void logProtocolHeaders(Logger logger, Level level,
            Map<String, List<Object>> headersMap,
            Set<String> sensitiveHeaders,
            boolean logSensitiveHeaders) {
        if (logger.isLoggable(level)) {
            for (Map.Entry<String, List<Object>> entry : headersMap.entrySet()) {
                String key = entry.getKey();
                boolean sensitive = !logSensitiveHeaders && sensitiveHeaders.contains(key);
                List<Object> headerList = sensitive ? SENSITIVE_HEADER_MARKER : entry.getValue();
                for (Object value : headerList) {
                    logger.log(level, key + ": "
                        + (value == null ? "<null>" : value.toString()));
                }
            }
        }
    }

    /**
     * Set content type and protocol headers (Message.PROTOCOL_HEADERS) headers into the URL
     * connection.
     * Note, this does not mean they immediately get written to the output
     * stream or the wire. They just just get set on the HTTP request.
     *
     * @param connection
     * @throws IOException
     */
    public void setProtocolHeadersInConnection(HttpURLConnection connection) throws IOException {
        // If no Content-Type is set for empty requests then HttpUrlConnection:
        // - sets a form Content-Type for empty POST
        // - replaces custom Accept value with */* if HTTP proxy is used
        boolean contentTypeSet = headers.containsKey(Message.CONTENT_TYPE);
        if (!contentTypeSet) {
            // if CT is not set then assume it has to be set by default
            boolean dropContentType = false;
            boolean getRequest = "GET".equals(message.get(Message.HTTP_REQUEST_METHOD));
            boolean emptyRequest = getRequest || PropertyUtils.isTrue(message.get(EMPTY_REQUEST_PROPERTY));
            // If it is an empty request (without a request body) then check further if CT still needs be set
            if (emptyRequest) {
                Object setCtForEmptyRequestProp = message.getContextualProperty(SET_EMPTY_REQUEST_CT_PROPERTY);
                if (setCtForEmptyRequestProp != null) {
                    // If SET_EMPTY_REQUEST_CT_PROPERTY is set then do as a user prefers.
                    // CT will be dropped if setting CT for empty requests was explicitly disabled
                    dropContentType = PropertyUtils.isFalse(setCtForEmptyRequestProp);
                } else if (getRequest) {
                    // otherwise if it is GET then just drop it
                    dropContentType = true;
                }
            }
            if (!dropContentType) {
                String ct = emptyRequest && !contentTypeSet ? "*/*" : determineContentType();
                connection.setRequestProperty(HttpHeaderHelper.CONTENT_TYPE, ct);
            }
        } else {
            connection.setRequestProperty(HttpHeaderHelper.CONTENT_TYPE, determineContentType());
        }

        transferProtocolHeadersToURLConnection(connection);

        Map<String, List<Object>> theHeaders = CastUtils.cast(headers);
        logProtocolHeaders(LOG, Level.FINE, theHeaders, getSensitiveHeaders(), logSensitiveHeaders());
    }

    public String determineContentType() {
        String ct;
        List<Object> ctList = CastUtils.cast(headers.get(Message.CONTENT_TYPE));
        if (ctList != null && ctList.size() == 1 && ctList.get(0) != null) {
            ct = ctList.get(0).toString();
        } else {
            ct = (String)message.get(Message.CONTENT_TYPE);
        }

        String enc = (String)message.get(Message.ENCODING);

        if (null != ct) {
            if (enc != null
                && ct.indexOf("charset=") == -1
                && !ct.toLowerCase().contains("multipart/related")) {
                ct = ct + "; charset=" + enc;
            }
        } else if (enc != null) {
            ct = "text/xml; charset=" + enc;
        } else {
            ct = "text/xml";
        }
        return ct;
    }

    /**
     * This procedure sets the URLConnection request properties
     * from the PROTOCOL_HEADERS in the message.
     */
    private void transferProtocolHeadersToURLConnection(URLConnection connection) {
        boolean addHeaders = MessageUtils.getContextualBoolean(message, ADD_HEADERS_PROPERTY, false);
        for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
            String header = entry.getKey();
            if (HttpHeaderHelper.CONTENT_TYPE.equalsIgnoreCase(header)) {
                continue;
            }

            List<String> headerList = entry.getValue();
            if (addHeaders || HttpHeaderHelper.COOKIE.equalsIgnoreCase(header)) {
                headerList.forEach(s -> connection.addRequestProperty(header, s));
            } else {
                connection.setRequestProperty(header, String.join(",", headerList));
            }
        }
        // make sure we don't add more than one User-Agent header
        if (connection.getRequestProperty("User-Agent") == null) {
            connection.addRequestProperty("User-Agent", USER_AGENT);
        }
    }

    /**
     * Copy the request headers into the message.
     *
     * @param req the current servlet request
     */
    protected void copyFromRequest(HttpServletRequest req) {

        //TODO how to deal with the fields
        for (Enumeration<String> e = req.getHeaderNames(); e.hasMoreElements();) {
            String fname = e.nextElement();
            String mappedName = HttpHeaderHelper.getHeaderKey(fname);
            List<String> values = headers.get(mappedName);
            if (values == null) {
                values = new ArrayList<>();
                headers.put(mappedName, values);
            }
            for (Enumeration<String> e2 = req.getHeaders(fname); e2.hasMoreElements();) {
                String val = e2.nextElement();
                if ("Accept".equals(mappedName) && !values.isEmpty()) {
                    //ensure we collapse Accept into first line
                    String firstAccept = values.get(0);
                    firstAccept = firstAccept + ", " + val;
                    values.set(0, firstAccept);
                }
                values.add(val);
            }
        }
        if (!headers.containsKey(Message.CONTENT_TYPE)) {
            headers.put(Message.CONTENT_TYPE, Collections.singletonList(req.getContentType()));
        }
        if (LOG.isLoggable(Level.FINE)) {
            Map<String, List<Object>> theHeaders = CastUtils.cast(headers);
            LOG.log(Level.FINE, "Request Headers: " + toString(theHeaders, getSensitiveHeaders(), 
                logSensitiveHeaders()));
        }
    }

    private boolean logSensitiveHeaders() {
        // Not allowed by default
        return PropertyUtils.isTrue(message.getContextualProperty(ALLOW_LOGGING_SENSITIVE_HEADERS));
    }

    private String getContentTypeFromMessage() {
        final String ct = (String)message.get(Message.CONTENT_TYPE);
        final String enc = (String)message.get(Message.ENCODING);

        if (null != ct
            && null != enc
            && ct.indexOf("charset=") == -1
            && !ct.toLowerCase().contains("multipart/related")) {
            return ct + "; charset=" + enc;
        }
        return ct;
    }

    // Assumes that response body is not available only
    // if Content-Length is available and set to 0
    private boolean isResponseBodyAvailable() {
        List<String> ctLen = headers.get("Content-Length");
        if (ctLen == null || ctLen.size() != 1) {
            return true;
        }
        try {
            if (Integer.parseInt(ctLen.get(0)) == 0) {
                return false;
            }
        } catch (NumberFormatException ex) {
            // ignore
        }
        return true;
    }

    private boolean isSingleHeader(String header) {
        return HTTP_HEADERS_SETCOOKIE.equalsIgnoreCase(header) || HTTP_HEADERS_LINK.equalsIgnoreCase(header);
    }

    /**
     * Copy the response headers into the response.
     *
     * @param response the current ServletResponse
     */
    protected void copyToResponse(HttpServletResponse response) {
        String contentType = getContentTypeFromMessage();

        if (!headers.containsKey(Message.CONTENT_TYPE) && contentType != null
            && isResponseBodyAvailable()) {
            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();

            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));
                    }
                }
            } 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, sb.toString());
            }
        }
    }

    private String headerObjectToString(Object headerObject) {
        if (headerObject.getClass() == String.class) {
            // Most likely
            return headerObject.toString();
        }
        String headerString;
        if (headerObject instanceof Date) {
            headerString = toHttpDate((Date)headerObject);
        } else if (headerObject instanceof Locale) {
            headerString = toHttpLanguage((Locale)headerObject);
        } else {
            headerString = headerObject.toString();
        }
        return headerString;
    }

    void removeContentType() {
        headers.remove(PROTOCOL_HEADERS_CONTENT_TYPE);
    }

    public String getAuthorization() {
        List<String> authorizationLines = headers.get("Authorization");
        if (authorizationLines != null && !authorizationLines.isEmpty()) {
            return authorizationLines.get(0);
        }
        return null;
    }

    public static SimpleDateFormat getHttpDateFormat() {
        SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
        dateFormat.setTimeZone(TIME_ZONE_GMT);
        return dateFormat;
    }

    public static String toHttpDate(Date date) {
        SimpleDateFormat format = getHttpDateFormat();
        return format.format(date);
    }

    public static String toHttpLanguage(Locale locale) {
        return locale.toString().replace('_', '-');
    }

    private Set<String> getSensitiveHeaders() {
        return MessageUtils.getContextualStrings(message, SENSITIVE_HEADERS_PROP_NAME,
                DEFAULT_SENSITIVE_HEADERS);
    }
}