Cookie.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.parser;

import java.nio.charset.StandardCharsets;

import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.http.CookiesWithoutEquals;
import org.apache.tomcat.util.http.ServerCookie;
import org.apache.tomcat.util.http.ServerCookies;
import org.apache.tomcat.util.log.UserDataHelper;
import org.apache.tomcat.util.res.StringManager;


/**
 * <p>
 * Cookie header parser based on RFC6265 and RFC2109.
 * </p>
 * <p>
 * The parsing of cookies using RFC6265 is more relaxed that the specification in the following ways:
 * </p>
 * <ul>
 * <li>Values 0x80 to 0xFF are permitted in cookie-octet to support the use of UTF-8 in cookie values as used by HTML
 * 5.</li>
 * <li>For cookies without a value, the '=' is not required after the name as some browsers do not sent it.</li>
 * </ul>
 * <p>
 * The parsing of cookies using RFC2109 is more relaxed that the specification in the following ways:
 * </p>
 * <ul>
 * <li>Values for the path attribute that contain a / character do not have to be quoted even though / is not permitted
 * in a token.</li>
 * </ul>
 * <p>
 * Implementation note:<br>
 * This class has been carefully tuned to ensure that it has equal or better performance than the original
 * Netscape/RFC2109 cookie parser. Before committing and changes, ensure that the TesterCookiePerformance unit test
 * continues to give results within 1% for the old and new parsers.
 * </p>
 */
public class Cookie {

    private static final Log log = LogFactory.getLog(Cookie.class);
    private static final UserDataHelper invalidCookieVersionLog = new UserDataHelper(log);
    private static final UserDataHelper invalidCookieLog = new UserDataHelper(log);
    private static final StringManager sm = StringManager.getManager("org.apache.tomcat.util.http.parser");

    private static final boolean isCookieOctet[] = new boolean[256];
    private static final boolean isText[] = new boolean[256];
    private static final byte[] VERSION_BYTES = "$Version".getBytes(StandardCharsets.ISO_8859_1);
    private static final byte[] PATH_BYTES = "$Path".getBytes(StandardCharsets.ISO_8859_1);
    private static final byte[] DOMAIN_BYTES = "$Domain".getBytes(StandardCharsets.ISO_8859_1);
    private static final byte[] EMPTY_BYTES = new byte[0];
    private static final byte TAB_BYTE = (byte) 0x09;
    private static final byte SPACE_BYTE = (byte) 0x20;
    private static final byte QUOTE_BYTE = (byte) 0x22;
    private static final byte COMMA_BYTE = (byte) 0x2C;
    private static final byte FORWARDSLASH_BYTE = (byte) 0x2F;
    private static final byte SEMICOLON_BYTE = (byte) 0x3B;
    private static final byte EQUALS_BYTE = (byte) 0x3D;
    private static final byte SLASH_BYTE = (byte) 0x5C;
    private static final byte DEL_BYTE = (byte) 0x7F;


    static {
        // %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E (RFC6265)
        // %x80 to %xFF (UTF-8)
        for (int i = 0; i < 256; i++) {
            if (i < 0x21 || i == QUOTE_BYTE || i == COMMA_BYTE || i == SEMICOLON_BYTE || i == SLASH_BYTE ||
                    i == DEL_BYTE) {
                isCookieOctet[i] = false;
            } else {
                isCookieOctet[i] = true;
            }
        }
        for (int i = 0; i < 256; i++) {
            if (i < TAB_BYTE || (i > TAB_BYTE && i < SPACE_BYTE) || i == DEL_BYTE) {
                isText[i] = false;
            } else {
                isText[i] = true;
            }
        }
    }


    private Cookie() {
        // Hide default constructor
    }


    /**
     * Parse byte array as cookie header.
     *
     * @param bytes         Source
     * @param offset        Start point in array
     * @param len           Number of bytes to read
     * @param serverCookies Structure to store results
     *
     * @deprecated Unused. This method will be removed in Tomcat 11 onwards.
     */
    @Deprecated
    public static void parseCookie(byte[] bytes, int offset, int len, ServerCookies serverCookies) {
        parseCookie(bytes, offset, len, serverCookies, CookiesWithoutEquals.NAME);
    }


    /**
     * Parse byte array as cookie header.
     *
     * @param bytes                Source
     * @param offset               Start point in array
     * @param len                  Number of bytes to read
     * @param serverCookies        Structure to store results
     * @param cookiesWithoutEquals How to handle a cookie name-value-pair that does not contain an equals character
     */
    public static void parseCookie(byte[] bytes, int offset, int len, ServerCookies serverCookies,
            CookiesWithoutEquals cookiesWithoutEquals) {

        // ByteBuffer is used throughout this parser as it allows the byte[]
        // and position information to be easily passed between parsing methods
        ByteBuffer bb = new ByteBuffer(bytes, offset, len);

        // Using RFC6265 parsing rules, check to see if the header starts with a
        // version marker. An RFC2109 version marker may be read using RFC6265
        // parsing rules. If version 1, use RFC2109. Else use RFC6265.

        skipLWS(bb);

        // Record position in case we need to return.
        int mark = bb.position();

        SkipResult skipResult = skipBytes(bb, VERSION_BYTES);
        if (skipResult != SkipResult.FOUND) {
            // No need to reset position since skipBytes() will have done it
            parseCookieRfc6265(bb, serverCookies, cookiesWithoutEquals);
            return;
        }

        skipLWS(bb);

        skipResult = skipByte(bb, EQUALS_BYTE);
        if (skipResult != SkipResult.FOUND) {
            // Need to reset position as skipByte() will only have reset to
            // position before it was called
            bb.position(mark);
            parseCookieRfc6265(bb, serverCookies, cookiesWithoutEquals);
            return;
        }

        skipLWS(bb);

        ByteBuffer value = readCookieValue(bb);
        if (value != null && value.remaining() == 1) {
            int version = value.get() - '0';
            if (version == 1 || version == 0) {
                // $Version=1 -> RFC2109
                // $Version=0 -> RFC2109
                skipLWS(bb);
                byte b = bb.get();
                if (b == SEMICOLON_BYTE || b == COMMA_BYTE) {
                    parseCookieRfc2109(bb, serverCookies, version);
                }
            } else {
                // Unrecognised version.
                // Ignore this header.
                value.rewind();
                logInvalidVersion(value);
            }
        } else {
            // Unrecognised version.
            // Ignore this header.
            logInvalidVersion(value);
        }
    }


    public static String unescapeCookieValueRfc2109(String input) {
        if (input == null || input.length() < 2) {
            return input;
        }
        if (input.charAt(0) != '"' && input.charAt(input.length() - 1) != '"') {
            return input;
        }

        StringBuilder sb = new StringBuilder(input.length());
        char[] chars = input.toCharArray();
        boolean escaped = false;

        for (int i = 1; i < input.length() - 1; i++) {
            if (chars[i] == '\\') {
                escaped = true;
            } else if (escaped) {
                escaped = false;
                if (chars[i] < 128) {
                    sb.append(chars[i]);
                } else {
                    sb.append('\\');
                    sb.append(chars[i]);
                }
            } else {
                sb.append(chars[i]);
            }
        }
        return sb.toString();
    }


    private static void parseCookieRfc6265(ByteBuffer bb, ServerCookies serverCookies,
            CookiesWithoutEquals cookiesWithoutEquals) {

        boolean moreToProcess = true;

        while (moreToProcess) {
            skipLWS(bb);

            int start = bb.position();
            ByteBuffer name = readToken(bb);
            ByteBuffer value = null;

            skipLWS(bb);

            SkipResult skipResult = skipByte(bb, EQUALS_BYTE);
            if (skipResult == SkipResult.FOUND) {
                skipLWS(bb);
                value = readCookieValueRfc6265(bb);
                if (value == null) {
                    // Invalid cookie value. Skip to the next semi-colon
                    skipUntilSemiColon(bb);
                    logInvalidHeader(start, bb);
                    continue;
                }
                skipLWS(bb);
            }

            skipResult = skipByte(bb, SEMICOLON_BYTE);
            if (skipResult == SkipResult.FOUND) {
                // NO-OP
            } else if (skipResult == SkipResult.NOT_FOUND) {
                // Invalid cookie. Ignore it and skip to the next semi-colon
                skipUntilSemiColon(bb);
                logInvalidHeader(start, bb);
                continue;
            } else {
                // SkipResult.EOF
                moreToProcess = false;
            }

            if (name.hasRemaining()) {
                if (value == null) {
                    switch (cookiesWithoutEquals) {
                        case IGNORE: {
                            // This name-value-pair is a NO-OP
                            break;
                        }
                        case NAME: {
                            ServerCookie sc = serverCookies.addCookie();
                            sc.getName().setBytes(name.array(), name.position(), name.remaining());
                            sc.getValue().setBytes(EMPTY_BYTES, 0, EMPTY_BYTES.length);
                            break;
                        }
                    }
                } else {
                    ServerCookie sc = serverCookies.addCookie();
                    sc.getName().setBytes(name.array(), name.position(), name.remaining());
                    sc.getValue().setBytes(value.array(), value.position(), value.remaining());
                }
            }
        }
    }


    private static void parseCookieRfc2109(ByteBuffer bb, ServerCookies serverCookies, int version) {

        boolean moreToProcess = true;

        while (moreToProcess) {
            skipLWS(bb);

            boolean parseAttributes = true;
            int start = bb.position();

            ByteBuffer name = readToken(bb);
            ByteBuffer value = null;
            ByteBuffer path = null;
            ByteBuffer domain = null;

            skipLWS(bb);

            SkipResult skipResult = skipByte(bb, EQUALS_BYTE);
            if (skipResult == SkipResult.FOUND) {
                skipLWS(bb);
                value = readCookieValueRfc2109(bb, false);
                if (value == null) {
                    skipInvalidCookie(start, bb);
                    continue;
                }
                skipLWS(bb);
            }

            skipResult = skipByte(bb, COMMA_BYTE);
            if (skipResult == SkipResult.FOUND) {
                parseAttributes = false;
            } else {
                skipResult = skipByte(bb, SEMICOLON_BYTE);
            }
            if (skipResult == SkipResult.EOF) {
                parseAttributes = false;
                moreToProcess = false;
            } else if (skipResult == SkipResult.NOT_FOUND) {
                skipInvalidCookie(start, bb);
                continue;
            }

            if (parseAttributes) {
                skipLWS(bb);
                skipResult = skipBytes(bb, PATH_BYTES);
                if (skipResult == SkipResult.FOUND) {
                    skipLWS(bb);
                    skipResult = skipByte(bb, EQUALS_BYTE);
                    if (skipResult != SkipResult.FOUND) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                    skipLWS(bb);
                    path = readCookieValueRfc2109(bb, true);
                    if (path == null) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                    skipLWS(bb);

                    skipResult = skipByte(bb, COMMA_BYTE);
                    if (skipResult == SkipResult.FOUND) {
                        parseAttributes = false;
                    } else {
                        skipResult = skipByte(bb, SEMICOLON_BYTE);
                    }
                    if (skipResult == SkipResult.EOF) {
                        parseAttributes = false;
                        moreToProcess = false;
                    } else if (skipResult == SkipResult.NOT_FOUND) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                }
            }

            if (parseAttributes) {
                skipLWS(bb);
                skipResult = skipBytes(bb, DOMAIN_BYTES);
                if (skipResult == SkipResult.FOUND) {
                    skipLWS(bb);
                    skipResult = skipByte(bb, EQUALS_BYTE);
                    if (skipResult != SkipResult.FOUND) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                    skipLWS(bb);
                    domain = readCookieValueRfc2109(bb, false);
                    if (domain == null) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                    skipLWS(bb);

                    skipResult = skipByte(bb, COMMA_BYTE);
                    if (skipResult == SkipResult.FOUND) {
                        parseAttributes = false;
                    } else {
                        skipResult = skipByte(bb, SEMICOLON_BYTE);
                    }
                    if (skipResult == SkipResult.EOF) {
                        parseAttributes = false;
                        moreToProcess = false;
                    } else if (skipResult == SkipResult.NOT_FOUND) {
                        skipInvalidCookie(start, bb);
                        continue;
                    }
                }
            }

            if (name.hasRemaining() && value != null && value.hasRemaining()) {
                ServerCookie sc = serverCookies.addCookie();
                sc.setVersion(version);
                sc.getName().setBytes(name.array(), name.position(), name.remaining());
                sc.getValue().setBytes(value.array(), value.position(), value.remaining());
                if (domain != null) {
                    sc.getDomain().setBytes(domain.array(), domain.position(), domain.remaining());
                }
                if (path != null) {
                    sc.getPath().setBytes(path.array(), path.position(), path.remaining());
                }
            }
        }
    }


    private static void skipInvalidCookie(int start, ByteBuffer bb) {
        // Invalid cookie value. Skip to the next semi-colon
        skipUntilSemiColonOrComma(bb);
        logInvalidHeader(start, bb);
    }


    private static void skipLWS(ByteBuffer bb) {
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (b != TAB_BYTE && b != SPACE_BYTE) {
                bb.rewind();
                break;
            }
        }
    }


    private static void skipUntilSemiColon(ByteBuffer bb) {
        while (bb.hasRemaining()) {
            if (bb.get() == SEMICOLON_BYTE) {
                break;
            }
        }
    }


    private static void skipUntilSemiColonOrComma(ByteBuffer bb) {
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (b == SEMICOLON_BYTE || b == COMMA_BYTE) {
                break;
            }
        }
    }


    private static SkipResult skipByte(ByteBuffer bb, byte target) {

        if (!bb.hasRemaining()) {
            return SkipResult.EOF;
        }
        if (bb.get() == target) {
            return SkipResult.FOUND;
        }

        bb.rewind();
        return SkipResult.NOT_FOUND;
    }


    private static SkipResult skipBytes(ByteBuffer bb, byte[] target) {
        int mark = bb.position();

        for (byte b : target) {
            if (!bb.hasRemaining()) {
                bb.position(mark);
                return SkipResult.EOF;
            }
            if (bb.get() != b) {
                bb.position(mark);
                return SkipResult.NOT_FOUND;
            }
        }
        return SkipResult.FOUND;
    }


    /**
     * Similar to readCookieValueRfc6265() but also allows a comma to terminate the value (as permitted by RFC2109).
     */
    private static ByteBuffer readCookieValue(ByteBuffer bb) {
        boolean quoted = false;
        if (bb.hasRemaining()) {
            if (bb.get() == QUOTE_BYTE) {
                quoted = true;
            } else {
                bb.rewind();
            }
        }
        int start = bb.position();
        int end = bb.limit();
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (isCookieOctet[(b & 0xFF)]) {
                // NO-OP
            } else if (b == SEMICOLON_BYTE || b == COMMA_BYTE || b == SPACE_BYTE || b == TAB_BYTE) {
                end = bb.position() - 1;
                bb.position(end);
                break;
            } else if (quoted && b == QUOTE_BYTE) {
                end = bb.position() - 1;
                break;
            } else {
                // Invalid cookie
                return null;
            }
        }

        return new ByteBuffer(bb.bytes, start, end - start);
    }


    /**
     * Similar to readCookieValue() but treats a comma as part of an invalid value.
     */
    private static ByteBuffer readCookieValueRfc6265(ByteBuffer bb) {
        boolean quoted = false;
        if (bb.hasRemaining()) {
            if (bb.get() == QUOTE_BYTE) {
                quoted = true;
            } else {
                bb.rewind();
            }
        }
        int start = bb.position();
        int end = bb.limit();
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (isCookieOctet[(b & 0xFF)]) {
                // NO-OP
            } else if (b == SEMICOLON_BYTE || b == SPACE_BYTE || b == TAB_BYTE) {
                end = bb.position() - 1;
                bb.position(end);
                break;
            } else if (quoted && b == QUOTE_BYTE) {
                end = bb.position() - 1;
                break;
            } else {
                // Invalid cookie
                return null;
            }
        }

        return new ByteBuffer(bb.bytes, start, end - start);
    }


    private static ByteBuffer readCookieValueRfc2109(ByteBuffer bb, boolean allowForwardSlash) {
        if (!bb.hasRemaining()) {
            return null;
        }

        if (bb.peek() == QUOTE_BYTE) {
            return readQuotedString(bb);
        } else {
            if (allowForwardSlash) {
                return readTokenAllowForwardSlash(bb);
            } else {
                return readToken(bb);
            }
        }
    }


    private static ByteBuffer readToken(ByteBuffer bb) {
        final int start = bb.position();
        int end = bb.limit();
        while (bb.hasRemaining()) {
            if (!HttpParser.isToken(bb.get())) {
                end = bb.position() - 1;
                bb.position(end);
                break;
            }
        }

        return new ByteBuffer(bb.bytes, start, end - start);
    }


    private static ByteBuffer readTokenAllowForwardSlash(ByteBuffer bb) {
        final int start = bb.position();
        int end = bb.limit();
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (b != FORWARDSLASH_BYTE && !HttpParser.isToken(b)) {
                end = bb.position() - 1;
                bb.position(end);
                break;
            }
        }

        return new ByteBuffer(bb.bytes, start, end - start);
    }


    private static ByteBuffer readQuotedString(ByteBuffer bb) {
        int start = bb.position();

        // Read the opening quote
        bb.get();
        boolean escaped = false;
        while (bb.hasRemaining()) {
            byte b = bb.get();
            if (b == SLASH_BYTE) {
                // Escaping another character
                escaped = true;
            } else if (escaped && b > (byte) -1) {
                escaped = false;
            } else if (b == QUOTE_BYTE) {
                return new ByteBuffer(bb.bytes, start, bb.position() - start);
            } else if (isText[b & 0xFF]) {
                escaped = false;
            } else {
                return null;
            }
        }

        return null;
    }


    private static void logInvalidHeader(int start, ByteBuffer bb) {
        UserDataHelper.Mode logMode = invalidCookieLog.getNextMode();
        if (logMode != null) {
            String headerValue = new String(bb.array(), start, bb.position() - start, StandardCharsets.UTF_8);
            String message = sm.getString("cookie.invalidCookieValue", headerValue);
            switch (logMode) {
                case INFO_THEN_DEBUG:
                    message += sm.getString("cookie.fallToDebug");
                    //$FALL-THROUGH$
                case INFO:
                    log.info(message);
                    break;
                case DEBUG:
                    log.debug(message);
            }
        }
    }


    private static void logInvalidVersion(ByteBuffer value) {
        UserDataHelper.Mode logMode = invalidCookieVersionLog.getNextMode();
        if (logMode != null) {
            String version;
            if (value == null) {
                version = sm.getString("cookie.valueNotPresent");
            } else {
                version = new String(value.bytes, value.position(), value.limit() - value.position(),
                        StandardCharsets.UTF_8);
            }
            String message = sm.getString("cookie.invalidCookieVersion", version);
            switch (logMode) {
                case INFO_THEN_DEBUG:
                    message += sm.getString("cookie.fallToDebug");
                    //$FALL-THROUGH$
                case INFO:
                    log.info(message);
                    break;
                case DEBUG:
                    log.debug(message);
            }
        }
    }


    /**
     * Custom implementation that skips many of the safety checks in {@link java.nio.ByteBuffer}.
     */
    private static class ByteBuffer {

        private final byte[] bytes;
        private int limit;
        private int position = 0;

        ByteBuffer(byte[] bytes, int offset, int len) {
            this.bytes = bytes;
            this.position = offset;
            this.limit = offset + len;
        }

        public int position() {
            return position;
        }

        public void position(int position) {
            this.position = position;
        }

        public int limit() {
            return limit;
        }

        public int remaining() {
            return limit - position;
        }

        public boolean hasRemaining() {
            return position < limit;
        }

        public byte get() {
            return bytes[position++];
        }

        public byte peek() {
            return bytes[position];
        }

        public void rewind() {
            position--;
        }

        public byte[] array() {
            return bytes;
        }

        // For debug purposes
        @Override
        public String toString() {
            return "position [" + position + "], limit [" + limit + "]";
        }
    }
}