SameSite Cookie attribute ommited by ASP.NET Core

14,561

Solution 1

The issue is now fixed with latest release of .NET Framework and .NET Core.

As I already posted in this other post https://stackoverflow.com/a/58998232/906046, the cookie options SameSiteMode.None is now working as intended.

Solution 2

It looks like the issue is that while the SameSite Enum has a None value that's interpreted as the default value of simply not providing a SameSite attribute. You can see this in the code for SetCookieHeaderValue which only has token values for Strict and Lax.

To set a SameSite=None; Secure cookie you should send the Set-Cookie header yourself.

(Side note: I'll try to sort out a pull request for the core to add proper None support)

Solution 3

The approach outlined by Charles Chen - using a handler to make a copy of each cookie with SameSite=None and Secure set - has the advantage of being unobtrusive to implement, combined with a simple approach to compatibility with browsers which do not support SameSite=None correctly. For my situation - supporting an older .NET version - the approach is a life-saver, however when attempting to use Charles' code, I ran into a few issues which prevented it from working for me "as is".

Here is updated code, which addresses the issues I ran into:

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Web;

namespace SameSiteHttpModule
{
    public class SameSiteModule : IHttpModule
    {
        // Suffix includes a randomly generated code to minimize possibility of cookie copies colliding with original names
        private const string SuffixForCookieCopy = "-same-site-j4J6bSt0";
        private Regex _cookieNameRegex;
        private Regex _cookieSameSiteAttributeRegex;
        private Regex _cookieSecureAttributeRegex;

        /// <inheritdoc />
        /// <summary>
        ///     Set up the event handlers.
        /// </summary>
        public void Init(HttpApplication context)
        {
            // Initialize regular expressions used for making a cookie copy
            InitializeMatchExpressions();

            // This one is the OUTBOUND side; we add the extra cookies
            context.PreSendRequestHeaders += OnPreSendRequestHeaders;

            // This one is the INBOUND side; we coalesce the cookies
            context.BeginRequest += OnBeginRequest;
        }

        /// <summary>
        ///     The OUTBOUND LEG; we add the extra cookie
        /// </summary>
        private void OnPreSendRequestHeaders(object sender, EventArgs e)
        {
            var application = (HttpApplication) sender;
            var response = application.Context.Response;
            var cookieCopies = CreateCookieCopiesToSave(response);
            SaveCookieCopies(response, cookieCopies);
        }

        /// <summary>
        ///     The INBOUND LEG; we coalesce the cookies
        /// </summary>
        private void OnBeginRequest(object sender, EventArgs e)
        {
            var application = (HttpApplication) sender;
            var request = application.Context.Request;
            var cookiesToRestore = CreateCookiesToRestore(request);
            RestoreCookies(request, cookiesToRestore);
        }

        #region Supporting code for saving cookies

        private IEnumerable<string> CreateCookieCopiesToSave(HttpResponse response)
        {
            var cookieStrings = response.Headers.GetValues("set-cookie") ?? new string[0];
            var cookieCopies = new List<string>();

            foreach (var cookieString in cookieStrings)
            {
                bool createdCopy;
                var cookieStringCopy = TryMakeSameSiteCookieCopy(cookieString, out createdCopy);
                if (!createdCopy) continue;
                cookieCopies.Add(cookieStringCopy);
            }

            return cookieCopies;
        }

        private static void SaveCookieCopies(HttpResponse response, IEnumerable<string> cookieCopies)
        {
            foreach (var cookieCopy in cookieCopies)
            {
                response.Headers.Add("set-cookie", cookieCopy);
            }
        }

        private void InitializeMatchExpressions()
        {
            _cookieNameRegex = new Regex(@"
                (?'prefix'          # Group 1: Everything prior to cookie name
                    ^\s*                # Start of value followed by optional whitespace
                )
                (?'cookie_name'     # Group 2: Cookie name
                    [^\s=]+             # One or more characters that are not whitespace or equals
                )            
                (?'suffix'          # Group 3: Everything after the cookie name
                    .*$                 # Arbitrary characters followed by end of value
                )",
                RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);

            _cookieSameSiteAttributeRegex = new Regex(@"
                (?'prefix'          # Group 1: Everything prior to SameSite attribute value
                    ^.*                 # Start of value followed by 0 or more arbitrary characters
                    ;\s*                # Semicolon followed by optional whitespace
                    SameSite            # SameSite attribute name
                    \s*=\s*             # Equals sign (with optional whitespace around it)
                )
                (?'attribute_value' # Group 2: SameSite attribute value
                    [^\s;]+             # One or more characters that are not whitespace or semicolon
                )
                (?'suffix'          # Group 3: Everything after the SameSite attribute value
                    .*$                 # Arbitrary characters followed by end of value
                )",
                RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);

            _cookieSecureAttributeRegex = new Regex(@"
                ;\s*                # Semicolon followed by optional whitespace
                Secure              # Secure attribute value
                \s*                 # Optional whitespace
                (?:;|$)             # Semicolon or end of value",
                RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
        }

        private string TryMakeSameSiteCookieCopy(string cookie, out bool success)
        {
            if (!AddNameSuffix(ref cookie))
            {
                // could not add the name suffix so unable to copy cookie (generally should not happen)
                success = false;
                return null;
            }

            var addedSameSiteNone = AddSameSiteNone(ref cookie);
            var addedSecure = AddSecure(ref cookie);

            if (!addedSameSiteNone && !addedSecure)
            {
                // cookie already has SameSite and Secure attributes so don't make copy
                success = false;
                return null;
            }

            success = true;
            return cookie;
        }

        private bool AddNameSuffix(ref string cookie)
        {
            var match = _cookieNameRegex.Match(cookie);
            if (!match.Success)
            {
                // Could not find the cookie name in order to modify it
                return false;
            }

            var groups = match.Groups;
            var nameForCopy = groups["cookie_name"] + SuffixForCookieCopy;
            cookie = string.Concat(groups["prefix"].Value, nameForCopy, groups["suffix"].Value);
            return true;
        }

        private bool AddSameSiteNone(ref string cookie)
        {
            var match = _cookieSameSiteAttributeRegex.Match(cookie);
            if (!match.Success)
            {
                cookie += "; SameSite=None";
                return true;
            }

            var groups = match.Groups;

            if (groups["attribute_value"].Value.Equals("None", StringComparison.OrdinalIgnoreCase))
            {
                // SameSite=None is already present, so we will not add it
                return false;
            }

            // Replace existing SameSite value with "None"
            cookie = string.Concat(groups["prefix"].Value, "None", groups["suffix"].Value);
            return true;
        }

        private bool AddSecure(ref string cookie)
        {
            if (_cookieSecureAttributeRegex.IsMatch(cookie))
            {
                // Secure is already present so we will not add it
                return false;
            }

            cookie += "; Secure";
            return true;
        }

        #endregion

        #region Supporting code for restoring cookies

        private static IEnumerable<HttpCookie> CreateCookiesToRestore(HttpRequest request)
        {
            var cookiesToRestore = new List<HttpCookie>();

            for (var i = 0; i < request.Cookies.Count; i++)
            {
                var inboundCookie = request.Cookies[i];
                if (inboundCookie == null) continue;

                var cookieName = inboundCookie.Name;

                if (!cookieName.EndsWith(SuffixForCookieCopy, StringComparison.OrdinalIgnoreCase))
                {
                    continue; // Not interested in this cookie since it is not a copied cookie.
                }

                var originalName = cookieName.Substring(0, cookieName.Length - SuffixForCookieCopy.Length);

                if (request.Cookies[originalName] != null)
                {
                    continue; // We have the original cookie, so we are OK; just continue.
                }

                cookiesToRestore.Add(new HttpCookie(originalName, inboundCookie.Value));
            }

            return cookiesToRestore;
        }

        private static void RestoreCookies(HttpRequest request, IEnumerable<HttpCookie> cookiesToRestore)
        {
            // We need to inject cookies as if they were the original.
            foreach (var cookie in cookiesToRestore)
            {
                // Add to the cookie header for non-managed modules
                // https://support.microsoft.com/en-us/help/2666571/cookies-added-by-a-managed-httpmodule-are-not-available-to-native-ihtt
                if (request.Headers["cookie"] == null)
                {
                    request.Headers.Add("cookie", $"{cookie.Name}={cookie.Value}");
                }
                else
                {
                    request.Headers["cookie"] += $"; {cookie.Name}={cookie.Value}";
                }

                // Also add to the request cookies collection for managed modules.
                request.Cookies.Add(cookie);
            }
        }

        #endregion

        public void Dispose()
        {
        }
    }
}

Some concerns handed by this code:

  • Copied cookies preserve attributes, such as Path and Expires which can be necessary for correct functioning of sites.
  • When restoring cookies, in addition to adding to the Cookie header, they are added to the .NET HttpRequest.Cookies collection, which is necessary, for example to avoid losing the ASP.NET session.
  • When restoring cookies, avoids the possibility of creating a duplicate Cookie header, which would be contrary to RFC 6265 and can cause problems with applications.

Some options for deployment:

  • Add code for handler to an existing application
  • Compile to a DLL for deployment to an application's bin folder
  • Compile to a DLL and add to the GAC

Configuration (e.g. for web.config):

<system.webServer>
  ...
  <modules>
    <add name="SameSiteModule" type="SameSiteHttpModule.SameSiteModule, CustomSameSiteModule" />

p.s. Charles, I'm a fan of var, sorry :)

Solution 4

For anyone that may need a side-loaded option, I've written, tested, and released a simple solution which plugs into the IIS HTTP request pipeline as an IHttpModule. The solution basically adds the cookie twice: one with SameSite, once without. This provides 100% browser compatibility as the browsers that understand SameSite=None; Secure use that one while the browsers that do not understand it will use the normal cookie. This is a solution originally proposed by Google themselves and implemented by Auth0 for their product (in a different form).

The gist of the code is below:

using System;
using System.Linq;
using System.Web;

namespace SameSiteHttpModule
{
    public class SameSiteDoomsdayModule : IHttpModule
    {
        /// <summary>
        ///     Set up the event handlers.
        /// </summary>
        public void Init(HttpApplication context)
        {
            // This one is the OUTBOUND side; we add the extra cookie
            context.PreSendRequestHeaders += OnEndRequest;

            // This one is the INBOUND side; we coalesce the cookies.
            context.BeginRequest += OnBeginRequest;
        }

        /// <summary>
        ///     The OUTBOUND LEG; we add the extra cookie.
        /// </summary>
        private void OnEndRequest(object sender, EventArgs e)
        {
            HttpApplication application = (HttpApplication)sender;

            HttpContext context = application.Context;

            // IF NEEDED: Add URL filter here

            for (int i = 0; i < context.Response.Cookies.Count; i++)
            {
                HttpCookie responseCookie = context.Response.Cookies[i];

                context.Response.Headers.Add("Set-Cookie", $"{responseCookie.Name}-same-site={responseCookie.Value};SameSite=None; Secure");
            }
        }

        /// <summary>
        ///     The INBOUND LEG; we coalesce the cookies.
        /// </summary>
        private void OnBeginRequest(object sender, EventArgs e)
        {
            HttpApplication application = (HttpApplication)sender;

            HttpContext context = application.Context;

            // IF NEEDED: Add URL filter here

            string[] keys = context.Request.Cookies.AllKeys;

            for (int i = 0; i < context.Request.Cookies.Count; i++)
            {
                HttpCookie inboundCookie = context.Request.Cookies[i];

                if (!inboundCookie.Name.Contains("-same-site"))
                {
                    continue; // Not interested in this cookie.
                }

                // Check to see if we have a root cookie without the -same-site
                string actualName = inboundCookie.Name.Replace("-same-site", string.Empty);

                if (keys.Contains(actualName))
                {
                    continue; // We have the actual key, so we are OK; just continue.
                }

                // We don't have the actual name, so we need to inject it as if it were the original
                // https://support.microsoft.com/en-us/help/2666571/cookies-added-by-a-managed-httpmodule-are-not-available-to-native-ihtt
                // HttpCookie expectedCookie = new HttpCookie(actualName, inboundCookie.Value);
                context.Request.Headers.Add("Cookie", $"{actualName}={inboundCookie.Value}");
            }
        }

        public void Dispose()
        {

        }
    }
}

This gets installed like any other HTTP module:

<?xml version="1.0" encoding="utf-8"?>
<configuration>    
    <system.webServer>
        <modules>
            <add type="SameSiteHttpModule.SameSiteDoomsdayModule, SameSiteHttpModule" name="SameSiteDoomsdayModule"/>
        </modules>
        <handlers>        
            <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" />
        </handlers>
        <aspNetCore processPath=".\IC.He.IdentityServices.exe" arguments="" forwardWindowsAuthToken="false" requestTimeout="00:10:00" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" />
    </system.webServer>
</configuration>

You can find more info here: https://charliedigital.com/2020/01/22/adventures-in-single-sign-on-samesite-doomsday/

It will provide the fix for ANY .NET version, ANY .NET Core version, ANY scenario whether you own the original source code or not.

Solution 5

Using Microsoft.Net.Http.Headers 2.2.8 fixed the problem for me. Currently using target framework: .Net Core 2.2 for the project.

Share:
14,561

Related videos on Youtube

GrayCat
Author by

GrayCat

My main technology is .NET and C#, but I like learning new languages, so I know some Haskell and Rust.

Updated on July 11, 2022

Comments

  • GrayCat
    GrayCat almost 2 years

    I am trying to explicitly set SameCookie attribute of the cookie with ASP.NET Core to None.

    The way I tried to do this was to set property value of CookieOptions like this:

    var options = new CookieOptions
    {
        SameSite = SameSiteMode.None
    };
    

    (other attributes omitted for brevity)

    However when I examine server response headers (where server is supposed to set the cookie with SameSite=None) I can see SameSite is omitted. On the contrary I can see Value, Expires, Path even Secure stated explicitly.

    If I set SameSite in C# code to Lax or Strict I can see it explicitly included in Set-Cookie header. If I set it to None - I cannot.

    I did check on two browsers - Firefox and Chrome 77 (I am aware of changes that this version introduces to SameSite).

    There is a hack to include SameSite=None. You just need to add following line to Path property of CookieOptions:

    options.Path += "; samesite=None";
    

    Then it can be found in Set-Cookie header of the response.

    Is there a way to configure Kestrel (no IIS used for hosting, bare Kestrel) to include SameSite=None in headers without hacking it like this?

  • Dan Def
    Dan Def over 4 years
    The same problem is also present in .NET Framework 4.8
  • Duncan Smart
    Duncan Smart over 4 years
    Looks like there's a patch to .NET 4.7.2 or later coming in on November 19th 2019 devblogs.microsoft.com/aspnet/…
  • Phil Dennis
    Phil Dennis about 4 years
    This is a great approach, however there are some gaps with the code presented here, e.g. ASP.NET session does not work; cookies can be lost due to missing Path attribute. I posted a separate answer with updated code that works better for me.
  • tsemer
    tsemer about 4 years
    I've posted another answer next to yours with concrete links to the .Net Framework fix: stackoverflow.com/a/60090446/198797
  • ozgurozkanakdemirci
    ozgurozkanakdemirci over 3 years
    Thanks for your solution. I had some issues registering the module to GAC and running with aspnet.core, I think my module is not working ;)) . I already changed apppol property from "No managed" to ".net clr 4.0.". The asp.net core way is writing a middleware, right?
  • ozgurozkanakdemirci
    ozgurozkanakdemirci over 3 years
    I think I completed with your solution. The http module problem was namespace difference.
  • Asiful Nobel
    Asiful Nobel over 3 years
    Shouldn't the application variable be inside a using() statement as it implements an IDisposable interface?
  • Charles Chen
    Charles Chen almost 3 years
    Phil, thanks for sharing your code. I had published mine as a starting point for folks to build off of so at least they had a general, unobtrusive technique to address the issue. I am glad that you (and many other folks) were able to find it useful!
  • user3404686
    user3404686 almost 2 years
    @Phil Dennis Thanks a lot. I have worked on this issue for more than 5 days. A lot of frustration. But finally, your code helped me to resolve the issue. This code is very helpful. If anyone SSL enabled and tropic runs on HTTPS still getting this error means it May be load balancer enabled and some network running on HTTP. Which does not have direct visibility. This is the problem in our case and the above code helped to fix the issue.