LegacyCookieProcessor.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.tomcat.util.http;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.FieldPosition;
import java.util.BitSet;
import java.util.Date;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;

import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.buf.ByteChunk;
import org.apache.tomcat.util.buf.MessageBytes;
import org.apache.tomcat.util.log.UserDataHelper;
import org.apache.tomcat.util.res.StringManager;

/**
 * The legacy (up to early Tomcat 8 releases) cookie parser based on RFC6265, RFC2109 and RFC2616. This class is not
 * thread-safe.
 *
 * @author Costin Manolache
 * @author kevin seguin
 */
public final class LegacyCookieProcessor extends CookieProcessorBase {

    private static final Log log = LogFactory.getLog(LegacyCookieProcessor.class);

    private static final UserDataHelper userDataLog = new UserDataHelper(log);

    private static final StringManager sm = StringManager.getManager("org.apache.tomcat.util.http");

    private static final char[] V0_SEPARATORS = { ',', ';', ' ', '\t' };
    private static final BitSet V0_SEPARATOR_FLAGS = new BitSet(128);

    // Excludes '/' since configuration controls whether or not to treat '/' as
    // a separator
    private static final char[] HTTP_SEPARATORS =
            new char[] { '\t', ' ', '\"', '(', ')', ',', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '{', '}' };

    static {
        for (char c : V0_SEPARATORS) {
            V0_SEPARATOR_FLAGS.set(c);
        }
    }

    private final boolean STRICT_SERVLET_COMPLIANCE =
            Boolean.getBoolean("org.apache.catalina.STRICT_SERVLET_COMPLIANCE");

    private boolean allowEqualsInValue = false;

    private boolean allowNameOnly = false;

    private boolean allowHttpSepsInV0 = false;

    private boolean alwaysAddExpires = !STRICT_SERVLET_COMPLIANCE;

    private final BitSet httpSeparatorFlags = new BitSet(128);

    private final BitSet allowedWithoutQuotes = new BitSet(128);

    public LegacyCookieProcessor() {
        // BitSet elements will default to false
        for (char c : HTTP_SEPARATORS) {
            httpSeparatorFlags.set(c);
        }
        boolean b = STRICT_SERVLET_COMPLIANCE;
        if (b) {
            httpSeparatorFlags.set('/');
        }

        String separators;
        if (getAllowHttpSepsInV0()) {
            // comma, semi-colon and space as defined by netscape
            separators = ",; ";
        } else {
            // separators as defined by RFC2616
            separators = "()<>@,;:\\\"/[]?={} \t";
        }

        // all CHARs except CTLs or separators are allowed without quoting
        allowedWithoutQuotes.set(0x20, 0x7f);
        for (char ch : separators.toCharArray()) {
            allowedWithoutQuotes.clear(ch);
        }

        /*
         * Some browsers (e.g. IE6 and IE7) do not handle quoted Path values even when Version is set to 1. To allow for
         * this, we support a property FWD_SLASH_IS_SEPARATOR which, when false, means a '/' character will not be
         * treated as a separator, potentially avoiding quoting and the ensuing side effect of having the cookie
         * upgraded to version 1.
         *
         * For now, we apply this rule globally rather than just to the Path attribute.
         */
        if (!getAllowHttpSepsInV0() && !getForwardSlashIsSeparator()) {
            allowedWithoutQuotes.set('/');
        }
    }


    public boolean getAllowEqualsInValue() {
        return allowEqualsInValue;
    }


    public void setAllowEqualsInValue(boolean allowEqualsInValue) {
        this.allowEqualsInValue = allowEqualsInValue;
    }


    public boolean getAllowNameOnly() {
        return allowNameOnly;
    }


    public void setAllowNameOnly(boolean allowNameOnly) {
        this.allowNameOnly = allowNameOnly;
    }


    public boolean getAllowHttpSepsInV0() {
        return allowHttpSepsInV0;
    }


    public void setAllowHttpSepsInV0(boolean allowHttpSepsInV0) {
        this.allowHttpSepsInV0 = allowHttpSepsInV0;
        // HTTP separators less comma, semicolon and space since the Netscape
        // spec defines those as separators too.
        // '/' is also treated as a special case
        char[] seps = "()<>@:\\\"[]?={}\t".toCharArray();
        for (char sep : seps) {
            if (allowHttpSepsInV0) {
                allowedWithoutQuotes.set(sep);
            } else {
                allowedWithoutQuotes.clear(sep);
            }
        }
        if (getForwardSlashIsSeparator() && !allowHttpSepsInV0) {
            allowedWithoutQuotes.clear('/');
        } else {
            allowedWithoutQuotes.set('/');
        }
    }


    public boolean getForwardSlashIsSeparator() {
        return httpSeparatorFlags.get('/');
    }


    public void setForwardSlashIsSeparator(boolean forwardSlashIsSeparator) {
        if (forwardSlashIsSeparator) {
            httpSeparatorFlags.set('/');
        } else {
            httpSeparatorFlags.clear('/');
        }
        if (forwardSlashIsSeparator && !getAllowHttpSepsInV0()) {
            allowedWithoutQuotes.clear('/');
        } else {
            allowedWithoutQuotes.set('/');
        }
    }


    public boolean getAlwaysAddExpires() {
        return alwaysAddExpires;
    }


    public void setAlwaysAddExpires(boolean alwaysAddExpires) {
        this.alwaysAddExpires = alwaysAddExpires;
    }


    @Override
    public Charset getCharset() {
        return StandardCharsets.ISO_8859_1;
    }


    @Override
    public void parseCookieHeader(MimeHeaders headers, ServerCookies serverCookies) {

        if (headers == null) {
            // nothing to process
            return;
        }
        // process each "cookie" header
        int pos = headers.findHeader("Cookie", 0);
        while (pos >= 0) {
            MessageBytes cookieValue = headers.getValue(pos);

            if (cookieValue != null && !cookieValue.isNull()) {
                if (cookieValue.getType() != MessageBytes.T_BYTES) {
                    Exception e = new Exception();
                    // TODO: Review this in light of HTTP/2
                    log.debug("Cookies: Parsing cookie as String. Expected bytes.", e);
                    cookieValue.toBytes();
                }
                if (log.isDebugEnabled()) {
                    log.debug("Cookies: Parsing b[]: " + cookieValue.toString());
                }
                ByteChunk bc = cookieValue.getByteChunk();
                processCookieHeader(bc.getBytes(), bc.getStart(), bc.getLength(), serverCookies);
            }

            // search from the next position
            pos = headers.findHeader("Cookie", ++pos);
        }
    }


    @Override
    public String generateHeader(Cookie cookie) {
        return generateHeader(cookie, null);
    }


    @Override
    public String generateHeader(Cookie cookie, HttpServletRequest request) {

        /*
         * The spec allows some latitude on when to send the version attribute with a Set-Cookie header. To be nice to
         * clients, we'll make sure the version attribute is first. That means checking the various things that can
         * cause us to switch to a v1 cookie first.
         *
         * Note that by checking for tokens we will also throw an exception if a control character is encountered.
         */
        int version = cookie.getVersion();
        String value = cookie.getValue();
        String path = cookie.getPath();
        String domain = cookie.getDomain();
        String comment = cookie.getComment();

        if (version == 0) {
            // Check for the things that require a v1 cookie
            if (needsQuotes(value, 0) || comment != null || needsQuotes(path, 0) || needsQuotes(domain, 0)) {
                version = 1;
            }
        }

        // Now build the cookie header
        StringBuffer buf = new StringBuffer(); // can't use StringBuilder due to DateFormat

        // Just use the name supplied in the Cookie
        buf.append(cookie.getName());
        buf.append('=');

        // Value
        maybeQuote(buf, value, version);

        // Add version 1 specific information
        if (version == 1) {
            // Version=1 ... required
            buf.append("; Version=1");

            // Comment=comment
            if (comment != null) {
                buf.append("; Comment=");
                maybeQuote(buf, comment, version);
            }
        }

        // Add domain information, if present
        if (domain != null) {
            buf.append("; Domain=");
            maybeQuote(buf, domain, version);
        }

        // Max-Age=secs ... or use old "Expires" format
        int maxAge = cookie.getMaxAge();
        if (maxAge >= 0) {
            if (version > 0) {
                buf.append("; Max-Age=");
                buf.append(maxAge);
            }
            // IE6, IE7 and possibly other browsers don't understand Max-Age.
            // They do understand Expires, even with V1 cookies!
            if (version == 0 || getAlwaysAddExpires()) {
                // Wdy, DD-Mon-YY HH:MM:SS GMT ( Expires Netscape format )
                buf.append("; Expires=");
                // To expire immediately we need to set the time in past
                if (maxAge == 0) {
                    buf.append(ANCIENT_DATE);
                } else {
                    COOKIE_DATE_FORMAT.get().format(new Date(System.currentTimeMillis() + maxAge * 1000L), buf,
                            new FieldPosition(0));
                }
            }
        }

        // Path=path
        if (path != null) {
            buf.append("; Path=");
            maybeQuote(buf, path, version);
        }

        // Secure
        if (cookie.getSecure()) {
            buf.append("; Secure");
        }

        // HttpOnly
        if (cookie.isHttpOnly()) {
            buf.append("; HttpOnly");
        }

        SameSiteCookies sameSiteCookiesValue = getSameSiteCookies();

        if (!sameSiteCookiesValue.equals(SameSiteCookies.UNSET)) {
            buf.append("; SameSite=");
            buf.append(sameSiteCookiesValue.getValue());
        }

        if (getPartitioned()) {
            buf.append("; Partitioned");
        }

        return buf.toString();
    }


    private void maybeQuote(StringBuffer buf, String value, int version) {
        if (value == null || value.length() == 0) {
            buf.append("\"\"");
        } else if (alreadyQuoted(value)) {
            buf.append('"');
            escapeDoubleQuotes(buf, value, 1, value.length() - 1);
            buf.append('"');
        } else if (needsQuotes(value, version)) {
            buf.append('"');
            escapeDoubleQuotes(buf, value, 0, value.length());
            buf.append('"');
        } else {
            buf.append(value);
        }
    }


    private static void escapeDoubleQuotes(StringBuffer b, String s, int beginIndex, int endIndex) {
        if (s.indexOf('"') == -1 && s.indexOf('\\') == -1) {
            b.append(s);
            return;
        }

        for (int i = beginIndex; i < endIndex; i++) {
            char c = s.charAt(i);
            if (c == '\\') {
                b.append('\\').append('\\');
            } else if (c == '"') {
                b.append('\\').append('"');
            } else {
                b.append(c);
            }
        }
    }


    private boolean needsQuotes(String value, int version) {
        if (value == null) {
            return false;
        }

        int i = 0;
        int len = value.length();

        if (alreadyQuoted(value)) {
            i++;
            len--;
        }

        for (; i < len; i++) {
            char c = value.charAt(i);
            if ((c < 0x20 && c != '\t') || c >= 0x7f) {
                throw new IllegalArgumentException("Control character in cookie value or attribute.");
            }
            if (version == 0 && !allowedWithoutQuotes.get(c) || version == 1 && isHttpSeparator(c)) {
                return true;
            }
        }
        return false;
    }


    private static boolean alreadyQuoted(String value) {
        return value.length() >= 2 && value.charAt(0) == '\"' && value.charAt(value.length() - 1) == '\"';
    }


    /**
     * Parses a cookie header after the initial "Cookie:" [WS][$]token[WS]=[WS](token|QV)[;|,] RFC 2965 / RFC 2109 JVK
     */
    private void processCookieHeader(byte bytes[], int off, int len, ServerCookies serverCookies) {

        if (len <= 0 || bytes == null) {
            return;
        }
        int end = off + len;
        int pos = off;
        int nameStart = 0;
        int nameEnd = 0;
        int valueStart = 0;
        int valueEnd = 0;
        int version = 0;
        ServerCookie sc = null;
        boolean isSpecial;
        boolean isQuoted;

        while (pos < end) {
            isSpecial = false;
            isQuoted = false;

            // Skip whitespace and non-token characters (separators)
            while (pos < end && (isHttpSeparator((char) bytes[pos]) && !getAllowHttpSepsInV0() ||
                    isV0Separator((char) bytes[pos]) || isWhiteSpace(bytes[pos]))) {
                pos++;
            }

            if (pos >= end) {
                return;
            }

            // Detect Special cookies
            if (bytes[pos] == '$') {
                isSpecial = true;
                pos++;
            }

            // Get the cookie/attribute name. This must be a token
            valueEnd = valueStart = nameStart = pos;
            pos = nameEnd = getTokenEndPosition(bytes, pos, end, version, true);

            // Skip whitespace
            while (pos < end && isWhiteSpace(bytes[pos])) {
                pos++;
            }


            // Check for an '=' -- This could also be a name-only
            // cookie at the end of the cookie header, so if we
            // are past the end of the header, but we have a name
            // skip to the name-only part.
            if (pos < (end - 1) && bytes[pos] == '=') {

                // Skip whitespace
                do {
                    pos++;
                } while (pos < end && isWhiteSpace(bytes[pos]));

                if (pos >= end) {
                    return;
                }

                // Determine what type of value this is, quoted value,
                // token, name-only with an '=', or other (bad)
                switch (bytes[pos]) {
                    case '"': // Quoted Value
                        isQuoted = true;
                        valueStart = pos + 1; // strip "
                        // getQuotedValue returns the position before
                        // at the last quote. This must be dealt with
                        // when the bytes are copied into the cookie
                        valueEnd = getQuotedValueEndPosition(bytes, valueStart, end);
                        // We need pos to advance
                        pos = valueEnd;
                        // Handles cases where the quoted value is
                        // unterminated and at the end of the header,
                        // e.g. [myname="value]
                        if (pos >= end) {
                            return;
                        }
                        break;
                    case ';':
                    case ',':
                        // Name-only cookie with an '=' after the name token
                        // This may not be RFC compliant
                        valueStart = valueEnd = -1;
                        // The position is OK (On a delimiter)
                        break;
                    default:
                        if (version == 0 && !isV0Separator((char) bytes[pos]) && getAllowHttpSepsInV0() ||
                                !isHttpSeparator((char) bytes[pos]) || bytes[pos] == '=') {
                            // Token
                            valueStart = pos;
                            // getToken returns the position at the delimiter
                            // or other non-token character
                            valueEnd = getTokenEndPosition(bytes, valueStart, end, version, false);
                            // We need pos to advance
                            pos = valueEnd;
                            // Edge case. If value starts with '=' but this is not
                            // allowed in a value make sure we treat this as no
                            // value being present
                            if (valueStart == valueEnd) {
                                valueStart = -1;
                                valueEnd = -1;
                            }
                        } else {
                            // INVALID COOKIE, advance to next delimiter
                            // The starting character of the cookie value was
                            // not valid.
                            UserDataHelper.Mode logMode = userDataLog.getNextMode();
                            if (logMode != null) {
                                String message = sm.getString("cookies.invalidCookieToken");
                                switch (logMode) {
                                    case INFO_THEN_DEBUG:
                                        message += sm.getString("cookies.fallToDebug");
                                        //$FALL-THROUGH$
                                    case INFO:
                                        log.info(message);
                                        break;
                                    case DEBUG:
                                        log.debug(message);
                                }
                            }
                            while (pos < end && bytes[pos] != ';' && bytes[pos] != ',') {
                                pos++;
                            }
                            pos++;
                            // Make sure no special avpairs can be attributed to
                            // the previous cookie by setting the current cookie
                            // to null
                            sc = null;
                            continue;
                        }
                }
            } else {
                // Name only cookie
                valueStart = valueEnd = -1;
                pos = nameEnd;

            }

            // We should have an avpair or name-only cookie at this
            // point. Perform some basic checks to make sure we are
            // in a good state.

            // Skip whitespace
            while (pos < end && isWhiteSpace(bytes[pos])) {
                pos++;
            }


            // Make sure that after the cookie we have a separator. This
            // is only important if this is not the last cookie pair
            while (pos < end && bytes[pos] != ';' && bytes[pos] != ',') {
                pos++;
            }

            pos++;

            // All checks passed. Add the cookie, start with the
            // special avpairs first
            if (isSpecial) {
                isSpecial = false;
                // $Version must be the first avpair in the cookie header
                // (sc must be null)
                if (equals("Version", bytes, nameStart, nameEnd) && sc == null) {
                    // Set version
                    if (bytes[valueStart] == '1' && valueEnd == (valueStart + 1)) {
                        version = 1;
                    } else {
                        // unknown version (Versioning is not very strict)
                    }
                    continue;
                }

                // We need an active cookie for Path/Port/etc.
                if (sc == null) {
                    continue;
                }

                // Domain is more common, so it goes first
                if (equals("Domain", bytes, nameStart, nameEnd)) {
                    sc.getDomain().setBytes(bytes, valueStart, valueEnd - valueStart);
                    continue;
                }

                if (equals("Path", bytes, nameStart, nameEnd)) {
                    sc.getPath().setBytes(bytes, valueStart, valueEnd - valueStart);
                    continue;
                }

                // v2 cookie attributes - skip them
                if (equals("Port", bytes, nameStart, nameEnd)) {
                    continue;
                }
                if (equals("CommentURL", bytes, nameStart, nameEnd)) {
                    continue;
                }

                // Unknown cookie, complain
                UserDataHelper.Mode logMode = userDataLog.getNextMode();
                if (logMode != null) {
                    String message = sm.getString("cookies.invalidSpecial");
                    switch (logMode) {
                        case INFO_THEN_DEBUG:
                            message += sm.getString("cookies.fallToDebug");
                            //$FALL-THROUGH$
                        case INFO:
                            log.info(message);
                            break;
                        case DEBUG:
                            log.debug(message);
                    }
                }
            } else { // Normal Cookie
                if (valueStart == -1 && !getAllowNameOnly()) {
                    // Skip name only cookies if not supported
                    continue;
                }

                sc = serverCookies.addCookie();
                sc.setVersion(version);
                sc.getName().setBytes(bytes, nameStart, nameEnd - nameStart);

                if (valueStart != -1) { // Normal AVPair
                    sc.getValue().setBytes(bytes, valueStart, valueEnd - valueStart);
                    if (isQuoted) {
                        // We know this is a byte value so this is safe
                        unescapeDoubleQuotes(sc.getValue().getByteChunk());
                    }
                } else {
                    // Name Only
                    sc.getValue().setString("");
                }
                continue;
            }
        }
    }


    /**
     * Given the starting position of a token, this gets the end of the token, with no separator characters in between.
     * JVK
     */
    private int getTokenEndPosition(byte bytes[], int off, int end, int version, boolean isName) {
        int pos = off;
        while (pos < end && (!isHttpSeparator((char) bytes[pos]) ||
                version == 0 && getAllowHttpSepsInV0() && bytes[pos] != '=' && !isV0Separator((char) bytes[pos]) ||
                !isName && bytes[pos] == '=' && getAllowEqualsInValue())) {
            pos++;
        }

        if (pos > end) {
            return end;
        }
        return pos;
    }


    private boolean isHttpSeparator(final char c) {
        if (c < 0x20 || c >= 0x7f) {
            if (c != 0x09) {
                throw new IllegalArgumentException("Control character in cookie value or attribute.");
            }
        }

        return httpSeparatorFlags.get(c);
    }


    /**
     * Returns true if the byte is a separator as defined by V0 of the cookie spec.
     */
    private static boolean isV0Separator(final char c) {
        if (c < 0x20 || c >= 0x7f) {
            if (c != 0x09) {
                throw new IllegalArgumentException("Control character in cookie value or attribute.");
            }
        }

        return V0_SEPARATOR_FLAGS.get(c);
    }


    /**
     * Given a starting position after an initial quote character, this gets the position of the end quote. This escapes
     * anything after a '\' char JVK RFC 2616
     */
    private static int getQuotedValueEndPosition(byte bytes[], int off, int end) {
        int pos = off;
        while (pos < end) {
            if (bytes[pos] == '"') {
                return pos;
            } else if (bytes[pos] == '\\' && pos < (end - 1)) {
                pos += 2;
            } else {
                pos++;
            }
        }
        // Error, we have reached the end of the header w/o a end quote
        return end;
    }


    private static boolean equals(String s, byte b[], int start, int end) {
        int blen = end - start;
        if (b == null || blen != s.length()) {
            return false;
        }
        int boff = start;
        for (int i = 0; i < blen; i++) {
            if (b[boff++] != s.charAt(i)) {
                return false;
            }
        }
        return true;
    }


    /**
     * Returns true if the byte is a whitespace character as defined in RFC2619 JVK
     */
    private static boolean isWhiteSpace(final byte c) {
        // This switch statement is slightly slower
        // for my vm than the if statement.
        // Java(TM) 2 Runtime Environment, Standard Edition (build 1.5.0_07-164)
        /*
         * switch (c) { case ' ':; case '\t':; case '\n':; case '\r':; case '\f':; return true; default:; return false;
         * }
         */
        if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') {
            return true;
        } else {
            return false;
        }
    }


    /**
     * Unescapes any double quotes in the given cookie value.
     *
     * @param bc The cookie value to modify
     */
    private static void unescapeDoubleQuotes(ByteChunk bc) {

        if (bc == null || bc.getLength() == 0 || bc.indexOf('"', 0) == -1) {
            return;
        }

        // Take a copy of the buffer so the original cookie header is not
        // modified by this unescaping.
        byte[] original = bc.getBuffer();
        int len = bc.getLength();

        byte[] copy = new byte[len];
        System.arraycopy(original, bc.getStart(), copy, 0, len);

        int src = 0;
        int dest = 0;

        while (src < len) {
            if (copy[src] == '\\' && src < len && copy[src + 1] == '"') {
                src++;
            }
            copy[dest] = copy[src];
            dest++;
            src++;
        }
        bc.setBytes(copy, 0, dest);
    }
}