File: src\libraries\Common\src\System\Net\CookieParser.cs
Web Access
Project: src\src\libraries\System.Net.HttpListener\src\System.Net.HttpListener.csproj (System.Net.HttpListener)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
 
namespace System.Net
{
    internal enum CookieToken
    {
        // State types
        Nothing,
        NameValuePair,  // X=Y
        Attribute,      // X
        EndToken,       // ';'
        EndCookie,      // ','
        End,            // EOLN
        Equals,
 
        // Value types
        Comment,
        CommentUrl,
        CookieName,
        Discard,
        Domain,
        Expires,
        MaxAge,
        Path,
        Port,
        Secure,
        HttpOnly,
        Unknown,
        Version
    }
 
    // CookieTokenizer
    //
    // Used to split a single or multi-cookie (header) string into individual
    // tokens.
    internal struct CookieTokenizer
    {
        private bool _eofCookie;
        private int _index;
        private readonly int _length;
        private string? _name;
        private bool _quoted;
        private int _start;
        private CookieToken _token;
        private int _tokenLength;
        private readonly string _tokenStream;
        private string _value;
        private int _cookieStartIndex;
        private int _cookieLength;
 
        internal CookieTokenizer(string tokenStream) : this()
        {
            _length = tokenStream.Length;
            _tokenStream = tokenStream;
            _value = string.Empty;
        }
 
        internal bool EndOfCookie
        {
            get
            {
                return _eofCookie;
            }
            set
            {
                _eofCookie = value;
            }
        }
 
        internal bool Eof
        {
            get
            {
                return _index >= _length;
            }
        }
 
        internal string? Name
        {
            get
            {
                return _name;
            }
            set
            {
                _name = value;
            }
        }
 
        internal bool Quoted
        {
            get
            {
                return _quoted;
            }
            set
            {
                _quoted = value;
            }
        }
 
        internal CookieToken Token
        {
            get
            {
                return _token;
            }
            set
            {
                _token = value;
            }
        }
 
        internal string Value
        {
            get
            {
                return _value;
            }
            set
            {
                _value = value;
            }
        }
 
        // Extract
        //
        // Extracts the current token
        internal string Extract()
        {
            string tokenString = string.Empty;
 
            if (_tokenLength != 0)
            {
                tokenString = Quoted ?
                    _tokenStream.Substring(_start, _tokenLength) :
                    _tokenStream.AsSpan(_start, _tokenLength).Trim().ToString();
            }
            return tokenString;
        }
 
        // FindNext
        //
        // Find the start and length of the next token. The token is terminated
        // by one of:
        //     - end-of-line
        //     - end-of-cookie: unquoted comma separates multiple cookies
        //     - end-of-token: unquoted semi-colon
        //     - end-of-name: unquoted equals
        //
        // Inputs:
        // <argument>  ignoreComma
        //     true if parsing doesn't stop at a comma. This is only true when
        //     we know we're parsing an original cookie that has an expires=
        //     attribute, because the format of the time/date used in expires
        //     is:
        //         Wdy, dd-mmm-yyyy HH:MM:SS GMT
        //
        // <argument>  ignoreEquals
        //     true if parsing doesn't stop at an equals sign. The LHS of the
        //     first equals sign is an attribute name. The next token may
        //     include one or more equals signs. For example:
        //          SESSIONID=ID=MSNx45&q=33
        //
        // Outputs:
        // <member>    _index
        //     incremented to the last position in _tokenStream contained by
        //     the current token
        //
        // <member>    _start
        //     incremented to the start of the current token
        //
        // <member>    _tokenLength
        //     set to the length of the current token
        //
        // Assumes: Nothing
        //
        // Returns:
        // type of CookieToken found:
        //
        //     End         - end of the cookie string
        //     EndCookie   - end of current cookie in (potentially) a
        //                   multi-cookie string
        //     EndToken    - end of name=value pair, or end of an attribute
        //     Equals      - end of name=
        //
        // Throws: Nothing
        internal CookieToken FindNext(bool ignoreComma, bool ignoreEquals)
        {
            _tokenLength = 0;
            _start = _index;
            while ((_index < _length) && char.IsWhiteSpace(_tokenStream[_index]))
            {
                ++_index;
                ++_start;
            }
 
            CookieToken token = CookieToken.End;
            int increment = 1;
 
            if (!Eof)
            {
                if (_tokenStream[_index] == '"')
                {
                    Quoted = true;
                    ++_index;
                    bool quoteOn = false;
                    while (_index < _length)
                    {
                        char currChar = _tokenStream[_index];
                        if (!quoteOn && currChar == '"')
                        {
                            break;
                        }
 
                        if (quoteOn)
                        {
                            quoteOn = false;
                        }
                        else if (currChar == '\\')
                        {
                            quoteOn = true;
                        }
                        ++_index;
                    }
                    if (_index < _length)
                    {
                        ++_index;
                    }
                    _tokenLength = _index - _start;
                    increment = 0;
                    // If we are here, reset ignoreComma.
                    // In effect, we ignore everything after quoted string until the next delimiter.
                    ignoreComma = false;
                }
                while ((_index < _length)
                       && (_tokenStream[_index] != ';')
                       && (ignoreEquals || (_tokenStream[_index] != '='))
                       && (ignoreComma || (_tokenStream[_index] != ',')))
                {
                    // Fixing 2 things:
                    // 1) ignore day of week in cookie string
                    // 2) revert ignoreComma once meet it, so won't miss the next cookie)
                    if (_tokenStream[_index] == ',')
                    {
                        _start = _index + 1;
                        _tokenLength = -1;
                        ignoreComma = false;
                    }
                    ++_index;
                    _tokenLength += increment;
                }
                if (!Eof)
                {
                    switch (_tokenStream[_index])
                    {
                        case ';':
                            token = CookieToken.EndToken;
                            break;
 
                        case '=':
                            token = CookieToken.Equals;
                            break;
 
                        default:
                            _cookieLength = _index - _cookieStartIndex;
                            token = CookieToken.EndCookie;
                            break;
                    }
                    ++_index;
                }
 
                if (Eof)
                {
                    _cookieLength = _index - _cookieStartIndex;
                }
            }
            return token;
        }
 
        // Next
        //
        // Get the next cookie name/value or attribute
        //
        // Cookies come in the following formats:
        //
        //     1. Version0
        //         Set-Cookie: [<name>][=][<value>]
        //                     [; expires=<date>]
        //                     [; path=<path>]
        //                     [; domain=<domain>]
        //                     [; secure]
        //         Cookie: <name>=<value>
        //
        //         Notes: <name> and/or <value> may be blank
        //                <date> is the RFC 822/1123 date format that
        //                incorporates commas, e.g.
        //                "Wednesday, 09-Nov-99 23:12:40 GMT"
        //
        //     2. RFC 2109
        //         Set-Cookie: 1#{
        //                         <name>=<value>
        //                         [; comment=<comment>]
        //                         [; domain=<domain>]
        //                         [; max-age=<seconds>]
        //                         [; path=<path>]
        //                         [; secure]
        //                         ; Version=<version>
        //                     }
        //         Cookie: $Version=<version>
        //                 1#{
        //                     ; <name>=<value>
        //                     [; path=<path>]
        //                     [; domain=<domain>]
        //                 }
        //
        //     3. RFC 2965
        //         Set-Cookie2: 1#{
        //                         <name>=<value>
        //                         [; comment=<comment>]
        //                         [; commentURL=<comment>]
        //                         [; discard]
        //                         [; domain=<domain>]
        //                         [; max-age=<seconds>]
        //                         [; path=<path>]
        //                         [; ports=<portlist>]
        //                         [; secure]
        //                         ; Version=<version>
        //                      }
        //         Cookie: $Version=<version>
        //                 1#{
        //                     ; <name>=<value>
        //                     [; path=<path>]
        //                     [; domain=<domain>]
        //                     [; port="<port>"]
        //                 }
        //         [Cookie2: $Version=<version>]
        //
        // Inputs:
        // <argument>  first
        //     true if this is the first name/attribute that we have looked for
        //     in the cookie stream
        //
        // Outputs:
        //
        // Assumes:
        // Nothing
        //
        // Returns:
        // type of CookieToken found:
        //
        //     - Attribute
        //         - token was single-value. May be empty. Caller should check
        //           Eof or EndCookie to determine if any more action needs to
        //           be taken
        //
        //     - NameValuePair
        //         - Name and Value are meaningful. Either may be empty
        //
        // Throws:
        // Nothing
        internal CookieToken Next(bool first, bool parseResponseCookies)
        {
            Reset();
 
            if (first)
            {
                _cookieStartIndex = _index;
                _cookieLength = 0;
            }
 
            CookieToken terminator = FindNext(false, false);
            if (terminator == CookieToken.EndCookie)
            {
                EndOfCookie = true;
            }
 
            if ((terminator == CookieToken.End) || (terminator == CookieToken.EndCookie))
            {
                if ((Name = Extract()).Length != 0)
                {
                    Token = TokenFromName(parseResponseCookies);
                    return CookieToken.Attribute;
                }
                return terminator;
            }
            Name = Extract();
            if (first)
            {
                Token = CookieToken.CookieName;
            }
            else
            {
                Token = TokenFromName(parseResponseCookies);
            }
            if (terminator == CookieToken.Equals)
            {
                terminator = FindNext(!first && (Token == CookieToken.Expires), true);
                if (terminator == CookieToken.EndCookie)
                {
                    EndOfCookie = true;
                }
                Value = Extract();
                return CookieToken.NameValuePair;
            }
            else
            {
                return CookieToken.Attribute;
            }
        }
 
        // Reset
        //
        // Sets this tokenizer up for finding the next name/value pair,
        // attribute, or end-of-{token,cookie,line}.
        internal void Reset()
        {
            _eofCookie = false;
            _name = string.Empty;
            _quoted = false;
            _start = _index;
            _token = CookieToken.Nothing;
            _tokenLength = 0;
            _value = string.Empty;
        }
 
        private struct RecognizedAttribute
        {
            private readonly string _name;
            private readonly CookieToken _token;
 
            internal RecognizedAttribute(string name, CookieToken token)
            {
                _name = name;
                _token = token;
            }
 
            internal CookieToken Token
            {
                get
                {
                    return _token;
                }
            }
 
            internal bool IsEqualTo(string? value)
            {
                return string.Equals(_name, value, StringComparison.OrdinalIgnoreCase);
            }
        }
 
        // Recognized attributes in order of expected frequency.
        private static readonly RecognizedAttribute[] s_recognizedAttributes = {
            new RecognizedAttribute(CookieFields.PathAttributeName, CookieToken.Path),
            new RecognizedAttribute(CookieFields.MaxAgeAttributeName, CookieToken.MaxAge),
            new RecognizedAttribute(CookieFields.ExpiresAttributeName, CookieToken.Expires),
            new RecognizedAttribute(CookieFields.VersionAttributeName, CookieToken.Version),
            new RecognizedAttribute(CookieFields.DomainAttributeName, CookieToken.Domain),
            new RecognizedAttribute(CookieFields.SecureAttributeName, CookieToken.Secure),
            new RecognizedAttribute(CookieFields.DiscardAttributeName, CookieToken.Discard),
            new RecognizedAttribute(CookieFields.PortAttributeName, CookieToken.Port),
            new RecognizedAttribute(CookieFields.CommentAttributeName, CookieToken.Comment),
            new RecognizedAttribute(CookieFields.CommentUrlAttributeName, CookieToken.CommentUrl),
            new RecognizedAttribute(CookieFields.HttpOnlyAttributeName, CookieToken.HttpOnly),
        };
 
        private static readonly RecognizedAttribute[] s_recognizedServerAttributes = {
            new RecognizedAttribute('$' + CookieFields.PathAttributeName, CookieToken.Path),
            new RecognizedAttribute('$' + CookieFields.VersionAttributeName, CookieToken.Version),
            new RecognizedAttribute('$' + CookieFields.DomainAttributeName, CookieToken.Domain),
            new RecognizedAttribute('$' + CookieFields.PortAttributeName, CookieToken.Port),
            new RecognizedAttribute('$' + CookieFields.HttpOnlyAttributeName, CookieToken.HttpOnly),
        };
 
        internal CookieToken TokenFromName(bool parseResponseCookies)
        {
            if (!parseResponseCookies)
            {
                for (int i = 0; i < s_recognizedServerAttributes.Length; ++i)
                {
                    if (s_recognizedServerAttributes[i].IsEqualTo(Name))
                    {
                        return s_recognizedServerAttributes[i].Token;
                    }
                }
            }
            else
            {
                for (int i = 0; i < s_recognizedAttributes.Length; ++i)
                {
                    if (s_recognizedAttributes[i].IsEqualTo(Name))
                    {
                        return s_recognizedAttributes[i].Token;
                    }
                }
            }
            return CookieToken.Unknown;
        }
    }
 
    // CookieParser
    //
    // Takes a cookie header, makes cookies.
    internal struct CookieParser
    {
        private CookieTokenizer _tokenizer;
        private Cookie? _savedCookie;
 
        internal CookieParser(string cookieString)
        {
            _tokenizer = new CookieTokenizer(cookieString);
            _savedCookie = null;
        }
 
#if SYSTEM_NET_PRIMITIVES_DLL
        private static bool InternalSetNameMethod(Cookie cookie, string? value)
        {
            return cookie.InternalSetName(value);
        }
#else
        private static Func<Cookie, string?, bool>? s_internalSetNameMethod;
        private static Func<Cookie, string?, bool> InternalSetNameMethod
        {
            get
            {
                if (s_internalSetNameMethod == null)
                {
                    // TODO https://github.com/dotnet/runtime/issues/19348:
                    // We need to use Cookie.InternalSetName instead of the Cookie.set_Name wrapped in a try catch block, as
                    // Cookie.set_Name keeps the original name if the string is empty or null.
                    // Unfortunately this API is internal so we use reflection to access it. The method is cached for performance reasons.
                    MethodInfo? method = typeof(Cookie).GetMethod("InternalSetName", BindingFlags.Instance | BindingFlags.NonPublic);
                    Debug.Assert(method != null, "We need to use an internal method named InternalSetName that is declared on Cookie.");
                    s_internalSetNameMethod = (Func<Cookie, string?, bool>)Delegate.CreateDelegate(typeof(Func<Cookie, string?, bool>), method);
                }
 
                return s_internalSetNameMethod;
            }
        }
#endif
 
        private static FieldInfo? s_isQuotedDomainField;
        private static FieldInfo IsQuotedDomainField
        {
            get
            {
                if (s_isQuotedDomainField == null)
                {
                    // TODO https://github.com/dotnet/runtime/issues/19348:
                    FieldInfo? field = typeof(Cookie).GetField("IsQuotedDomain", BindingFlags.Instance | BindingFlags.NonPublic);
                    Debug.Assert(field != null, "We need to use an internal field named IsQuotedDomain that is declared on Cookie.");
                    s_isQuotedDomainField = field;
                }
 
                return s_isQuotedDomainField;
            }
        }
 
        private static FieldInfo? s_isQuotedVersionField;
        private static FieldInfo IsQuotedVersionField
        {
            get
            {
                if (s_isQuotedVersionField == null)
                {
                    // TODO https://github.com/dotnet/runtime/issues/19348:
                    FieldInfo? field = typeof(Cookie).GetField("IsQuotedVersion", BindingFlags.Instance | BindingFlags.NonPublic);
                    Debug.Assert(field != null, "We need to use an internal field named IsQuotedVersion that is declared on Cookie.");
                    s_isQuotedVersionField = field;
                }
 
                return s_isQuotedVersionField;
            }
        }
 
        // Get
        //
        // Gets the next cookie or null if there are no more cookies.
        internal Cookie? Get()
        {
            Cookie? cookie = null;
 
            // Only the first occurrence of an attribute value must be counted.
            bool commentSet = false;
            bool commentUriSet = false;
            bool domainSet = false;
            bool expiresSet = false;
            bool pathSet = false;
            bool portSet = false; // Special case: may have no value in header.
            bool versionSet = false;
            bool secureSet = false;
            bool discardSet = false;
 
            do
            {
                CookieToken token = _tokenizer.Next(cookie == null, true);
                if (cookie == null && (token == CookieToken.NameValuePair || token == CookieToken.Attribute))
                {
                    cookie = new Cookie();
                    InternalSetNameMethod(cookie, _tokenizer.Name);
                    cookie.Value = _tokenizer.Value;
                }
                else
                {
                    switch (token)
                    {
                        case CookieToken.NameValuePair:
                            switch (_tokenizer.Token)
                            {
                                case CookieToken.Comment:
                                    if (!commentSet)
                                    {
                                        commentSet = true;
                                        cookie!.Comment = _tokenizer.Value;
                                    }
                                    break;
 
                                case CookieToken.CommentUrl:
                                    if (!commentUriSet)
                                    {
                                        commentUriSet = true;
                                        if (Uri.TryCreate(CheckQuoted(_tokenizer.Value), UriKind.Absolute, out Uri? parsed))
                                        {
                                            cookie!.CommentUri = parsed;
                                        }
                                    }
                                    break;
 
                                case CookieToken.Domain:
                                    if (!domainSet)
                                    {
                                        domainSet = true;
                                        cookie!.Domain = CheckQuoted(_tokenizer.Value);
                                        IsQuotedDomainField.SetValue(cookie, _tokenizer.Quoted);
                                    }
                                    break;
 
                                case CookieToken.Expires:
                                    if (!expiresSet)
                                    {
                                        expiresSet = true;
 
                                        if (DateTime.TryParse(CheckQuoted(_tokenizer.Value),
                                            CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AdjustToUniversal, out DateTime expires))
                                        {
                                            cookie!.Expires = expires;
                                        }
                                        else
                                        {
                                            // This cookie will be rejected
                                            InternalSetNameMethod(cookie!, string.Empty);
                                        }
                                    }
                                    break;
 
                                case CookieToken.MaxAge:
                                    if (!expiresSet)
                                    {
                                        expiresSet = true;
                                        if (int.TryParse(CheckQuoted(_tokenizer.Value), out int parsed))
                                        {
                                            cookie!.Expires = DateTime.UtcNow.AddSeconds(parsed);
                                        }
                                        else
                                        {
                                            // This cookie will be rejected
                                            InternalSetNameMethod(cookie!, string.Empty);
                                        }
                                    }
                                    break;
 
                                case CookieToken.Path:
                                    if (!pathSet)
                                    {
                                        pathSet = true;
                                        cookie!.Path = _tokenizer.Value;
                                    }
                                    break;
 
                                case CookieToken.Port:
                                    if (!portSet)
                                    {
                                        portSet = true;
                                        try
                                        {
                                            cookie!.Port = _tokenizer.Value;
                                        }
                                        catch
                                        {
                                            // This cookie will be rejected
                                            InternalSetNameMethod(cookie!, string.Empty);
                                        }
                                    }
                                    break;
 
                                case CookieToken.Version:
                                    if (!versionSet)
                                    {
                                        versionSet = true;
                                        int parsed;
                                        if (int.TryParse(CheckQuoted(_tokenizer.Value), out parsed))
                                        {
                                            cookie!.Version = parsed;
                                            IsQuotedVersionField.SetValue(cookie, _tokenizer.Quoted);
                                        }
                                        else
                                        {
                                            // This cookie will be rejected
                                            InternalSetNameMethod(cookie!, string.Empty);
                                        }
                                    }
                                    break;
                            }
                            break;
 
                        case CookieToken.Attribute:
                            switch (_tokenizer.Token)
                            {
                                case CookieToken.Discard:
                                    if (!discardSet)
                                    {
                                        discardSet = true;
                                        cookie!.Discard = true;
                                    }
                                    break;
 
                                case CookieToken.Secure:
                                    if (!secureSet)
                                    {
                                        secureSet = true;
                                        cookie!.Secure = true;
                                    }
                                    break;
 
                                case CookieToken.HttpOnly:
                                    cookie!.HttpOnly = true;
                                    break;
 
                                case CookieToken.Port:
                                    if (!portSet)
                                    {
                                        portSet = true;
                                        cookie!.Port = string.Empty;
                                    }
                                    break;
                            }
                            break;
                    }
                }
            } while (!_tokenizer.Eof && !_tokenizer.EndOfCookie);
 
            return cookie;
        }
 
        internal Cookie? GetServer()
        {
            Cookie? cookie = _savedCookie;
            _savedCookie = null;
 
            // Only the first occurrence of an attribute value must be counted.
            bool domainSet = false;
            bool pathSet = false;
            bool portSet = false; // Special case: may have no value in header.
 
            do
            {
                bool first = cookie == null || string.IsNullOrEmpty(cookie.Name);
                CookieToken token = _tokenizer.Next(first, false);
 
                if (first && (token == CookieToken.NameValuePair || token == CookieToken.Attribute))
                {
                    cookie ??= new Cookie();
                    InternalSetNameMethod(cookie, _tokenizer.Name);
                    cookie.Value = _tokenizer.Value;
                }
                else
                {
                    switch (token)
                    {
                        case CookieToken.NameValuePair:
                            switch (_tokenizer.Token)
                            {
                                case CookieToken.Domain:
                                    if (!domainSet)
                                    {
                                        domainSet = true;
                                        cookie!.Domain = CheckQuoted(_tokenizer.Value);
                                        IsQuotedDomainField.SetValue(cookie, _tokenizer.Quoted);
                                    }
                                    break;
 
                                case CookieToken.Path:
                                    if (!pathSet)
                                    {
                                        pathSet = true;
                                        cookie!.Path = _tokenizer.Value;
                                    }
                                    break;
 
                                case CookieToken.Port:
                                    if (!portSet)
                                    {
                                        portSet = true;
                                        try
                                        {
                                            cookie!.Port = _tokenizer.Value;
                                        }
                                        catch (CookieException)
                                        {
                                            // This cookie will be rejected
                                            InternalSetNameMethod(cookie!, string.Empty);
                                        }
                                    }
                                    break;
 
                                case CookieToken.Version:
                                    // this is a new cookie, this token is for the next cookie.
                                    _savedCookie = new Cookie();
                                    if (int.TryParse(_tokenizer.Value, out int parsed))
                                    {
                                        _savedCookie.Version = parsed;
                                    }
                                    return cookie;
 
                                case CookieToken.Unknown:
                                    // this is a new cookie, the token is for the next cookie.
                                    _savedCookie = new Cookie();
                                    InternalSetNameMethod(_savedCookie, _tokenizer.Name);
                                    _savedCookie.Value = _tokenizer.Value;
                                    return cookie;
                            }
                            break;
 
                        case CookieToken.Attribute:
                            if (_tokenizer.Token == CookieToken.Port && !portSet)
                            {
                                portSet = true;
                                cookie!.Port = string.Empty;
                            }
                            break;
                    }
                }
            } while (!_tokenizer.Eof && !_tokenizer.EndOfCookie);
            return cookie;
        }
 
        internal static string CheckQuoted(string value)
        {
            return (value.Length >= 2 && value.StartsWith('\"') && value.EndsWith('\"'))
                ? value.Substring(1, value.Length - 2)
                : value;
        }
 
        internal bool EndofHeader()
        {
            return _tokenizer.Eof;
        }
    }
}