Reputation: 1797
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?
Upvotes: 9
Views: 25854
Reputation: 11
Using Microsoft.Net.Http.Headers 2.2.8 fixed the problem for me. Currently using target framework: .Net Core 2.2 for the project.
Upvotes: 1
Reputation: 381
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 :)
Upvotes: 3
Reputation: 1565
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.
Upvotes: 2
Reputation: 476
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.
Upvotes: 3
Reputation: 3050
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)
Upvotes: 3