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?
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)
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:
Path
and Expires
which can be necessary for correct functioning of sites.Cookie
header, they are added to the .NET HttpRequest.Cookies
collection, which is necessary, for example to avoid losing the ASP.NET session.Cookie
header, which would be contrary to RFC 6265 and can cause problems with applications.Some options for deployment:
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 :)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With