diff --git a/src/Microsoft.AspNetCore.Http.Features/CookieOptions.cs b/src/Microsoft.AspNetCore.Http.Features/CookieOptions.cs index 7bff4176..d9e0047d 100644 --- a/src/Microsoft.AspNetCore.Http.Features/CookieOptions.cs +++ b/src/Microsoft.AspNetCore.Http.Features/CookieOptions.cs @@ -42,6 +42,13 @@ public CookieOptions() /// true to transmit the cookie only over an SSL connection (HTTPS); otherwise, false. public bool Secure { get; set; } + + /// + /// Gets or sets the value for the SameSite attribute of the cookie. The default value is + /// + /// The representing the enforcement mode of the cookie. + public SameSiteMode SameSite { get; set; } = SameSiteMode.Lax; + /// /// Gets or sets a value that indicates whether a cookie is accessible by client-side script. /// diff --git a/src/Microsoft.AspNetCore.Http.Features/SameSiteMode.cs b/src/Microsoft.AspNetCore.Http.Features/SameSiteMode.cs new file mode 100644 index 00000000..0ae4481e --- /dev/null +++ b/src/Microsoft.AspNetCore.Http.Features/SameSiteMode.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http +{ + // RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 + // This mirrors Microsoft.Net.Http.Headers.SameSiteMode + public enum SameSiteMode + { + None = 0, + Lax, + Strict + } +} diff --git a/src/Microsoft.AspNetCore.Http/Internal/ResponseCookies.cs b/src/Microsoft.AspNetCore.Http/Internal/ResponseCookies.cs index e7f2d120..04dc5b94 100644 --- a/src/Microsoft.AspNetCore.Http/Internal/ResponseCookies.cs +++ b/src/Microsoft.AspNetCore.Http/Internal/ResponseCookies.cs @@ -62,7 +62,8 @@ public void Append(string key, string value, CookieOptions options) Path = options.Path, Expires = options.Expires, Secure = options.Secure, - HttpOnly = options.HttpOnly, + SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite, + HttpOnly = options.HttpOnly }; var cookieValue = setCookieHeaderValue.ToString(); diff --git a/src/Microsoft.Net.Http.Headers/SameSiteMode.cs b/src/Microsoft.Net.Http.Headers/SameSiteMode.cs new file mode 100644 index 00000000..1976386c --- /dev/null +++ b/src/Microsoft.Net.Http.Headers/SameSiteMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Net.Http.Headers +{ + // RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 + public enum SameSiteMode + { + None = 0, + Lax, + Strict + } +} diff --git a/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs b/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs index 8c6d9a56..10c68bb5 100644 --- a/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs +++ b/src/Microsoft.Net.Http.Headers/SetCookieHeaderValue.cs @@ -17,6 +17,10 @@ public class SetCookieHeaderValue private const string DomainToken = "domain"; private const string PathToken = "path"; private const string SecureToken = "secure"; + // RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 + private const string SameSiteToken = "samesite"; + private static readonly string SameSiteLaxToken = SameSiteMode.Lax.ToString().ToLower(); + private static readonly string SameSiteStrictToken = SameSiteMode.Strict.ToString().ToLower(); private const string HttpOnlyToken = "httponly"; private const string SeparatorToken = "; "; private const string EqualsToken = "="; @@ -87,15 +91,18 @@ public string Value public bool Secure { get; set; } + public SameSiteMode SameSite { get; set; } + public bool HttpOnly { get; set; } - // name="val ue"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly + // name="value"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite={Strict|Lax}; httponly public override string ToString() { var length = _name.Length + EqualsToken.Length + _value.Length; string expires = null; string maxAge = null; + string sameSite = null; if (Expires.HasValue) { @@ -124,6 +131,12 @@ public override string ToString() length += SeparatorToken.Length + SecureToken.Length; } + if (SameSite != SameSiteMode.None) + { + sameSite = SameSite == SameSiteMode.Lax ? SameSiteLaxToken : SameSiteStrictToken; + length += SeparatorToken.Length + SameSiteToken.Length + EqualsToken.Length + sameSite.Length; + } + if (HttpOnly) { length += SeparatorToken.Length + HttpOnlyToken.Length; @@ -160,6 +173,11 @@ public override string ToString() AppendSegment(ref sb, SecureToken, null); } + if (SameSite != SameSiteMode.None) + { + AppendSegment(ref sb, SameSiteToken, sameSite); + } + if (HttpOnly) { AppendSegment(ref sb, HttpOnlyToken, null); @@ -218,6 +236,11 @@ public void AppendToStringBuilder(StringBuilder builder) AppendSegment(builder, SecureToken, null); } + if (SameSite != SameSiteMode.None) + { + AppendSegment(builder, SameSiteToken, SameSite == SameSiteMode.Lax ? SameSiteLaxToken : SameSiteStrictToken); + } + if (HttpOnly) { AppendSegment(builder, HttpOnlyToken, null); @@ -267,7 +290,7 @@ public static bool TryParseStrictList(IList inputs, out IList= 0); @@ -322,7 +345,7 @@ private static int GetSetCookieLength(string input, int startIndex, out SetCooki offset += HttpRuleParser.GetWhitespaceLength(input, offset); - // cookie-av = expires-av / max-age-av / domain-av / path-av / secure-av / httponly-av / extension-av + // cookie-av = expires-av / max-age-av / domain-av / path-av / secure-av / samesite-av / httponly-av / extension-av itemLength = HttpRuleParser.GetTokenLength(input, offset); if (itemLength == 0) { @@ -402,6 +425,28 @@ private static int GetSetCookieLength(string input, int startIndex, out SetCooki { result.Secure = true; } + // samesite-av = "SameSite" / "SameSite=" samesite-value + // samesite-value = "Strict" / "Lax" + else if (string.Equals(token, SameSiteToken, StringComparison.OrdinalIgnoreCase)) + { + if (!ReadEqualsSign(input, ref offset)) + { + result.SameSite = SameSiteMode.Strict; + } + else + { + var enforcementMode = ReadToSemicolonOrEnd(input, ref offset); + + if (string.Equals(enforcementMode, SameSiteLaxToken, StringComparison.OrdinalIgnoreCase)) + { + result.SameSite = SameSiteMode.Lax; + } + else + { + result.SameSite = SameSiteMode.Strict; + } + } + } // httponly-av = "HttpOnly" else if (string.Equals(token, HttpOnlyToken, StringComparison.OrdinalIgnoreCase)) { @@ -459,6 +504,7 @@ public override bool Equals(object obj) && string.Equals(Domain, other.Domain, StringComparison.OrdinalIgnoreCase) && string.Equals(Path, other.Path, StringComparison.OrdinalIgnoreCase) && Secure == other.Secure + && SameSite == other.SameSite && HttpOnly == other.HttpOnly; } @@ -471,6 +517,7 @@ public override int GetHashCode() ^ (Domain != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Domain) : 0) ^ (Path != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Path) : 0) ^ Secure.GetHashCode() + ^ SameSite.GetHashCode() ^ HttpOnly.GetHashCode(); } } diff --git a/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs index a3dad09a..2a84397b 100644 --- a/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs +++ b/test/Microsoft.Net.Http.Headers.Tests/SetCookieHeaderValueTest.cs @@ -20,12 +20,13 @@ public static TheoryData SetCookieHeaderDataSet { Domain = "domain1", Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + SameSite = SameSiteMode.Strict, HttpOnly = true, MaxAge = TimeSpan.FromDays(1), Path = "path1", Secure = true }; - dataset.Add(header1, "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly"); + dataset.Add(header1, "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite=strict; httponly"); var header2 = new SetCookieHeaderValue("name2", ""); dataset.Add(header2, "name2="); @@ -46,6 +47,19 @@ public static TheoryData SetCookieHeaderDataSet }; dataset.Add(header5, "name5=value5; expires=Sun, 06 Nov 1994 08:49:37 GMT; domain=domain1"); + var header6 = new SetCookieHeaderValue("name6", "value6") + { + SameSite = SameSiteMode.Lax, + }; + dataset.Add(header6, "name6=value6; samesite=lax"); + + var header7 = new SetCookieHeaderValue("name7", "value7") + { + SameSite = SameSiteMode.None, + }; + dataset.Add(header7, "name7=value7"); + + return dataset; } } @@ -106,12 +120,13 @@ public static TheoryData, string[]> ListOfSetCookieH { Domain = "domain1", Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + SameSite = SameSiteMode.Strict, HttpOnly = true, MaxAge = TimeSpan.FromDays(1), Path = "path1", Secure = true }; - var string1 = "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly"; + var string1 = "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite=strict; httponly"; var header2 = new SetCookieHeaderValue("name2", "value2"); var string2 = "name2=value2"; @@ -129,6 +144,21 @@ public static TheoryData, string[]> ListOfSetCookieH }; var string4 = "name4=value4; expires=Sun, 06 Nov 1994 08:49:37 GMT; domain=domain1"; + var header5 = new SetCookieHeaderValue("name5", "value5") + { + SameSite = SameSiteMode.Lax + }; + var string5a = "name5=value5; samesite=lax"; + var string5b = "name5=value5; samesite=Lax"; + + var header6 = new SetCookieHeaderValue("name6", "value6") + { + SameSite = SameSiteMode.Strict + }; + var string6a = "name6=value6; samesite"; + var string6b = "name6=value6; samesite=Strict"; + var string6c = "name6=value6; samesite=invalid"; + dataset.Add(new[] { header1 }.ToList(), new[] { string1 }); dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, string1 }); dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, null, "", " ", ",", " , ", string1 }); @@ -138,6 +168,11 @@ public static TheoryData, string[]> ListOfSetCookieH dataset.Add(new[] { header2, header1 }.ToList(), new[] { string2 + ", " + string1 }); dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string1, string2, string3, string4 }); dataset.Add(new[] { header1, header2, header3, header4 }.ToList(), new[] { string.Join(",", string1, string2, string3, string4) }); + dataset.Add(new[] { header5 }.ToList(), new[] { string5a }); + dataset.Add(new[] { header5 }.ToList(), new[] { string5b }); + dataset.Add(new[] { header6 }.ToList(), new[] { string6a }); + dataset.Add(new[] { header6 }.ToList(), new[] { string6b }); + dataset.Add(new[] { header6 }.ToList(), new[] { string6c }); return dataset; } @@ -152,12 +187,13 @@ public static TheoryData, string[]> ListWithInvalidS { Domain = "domain1", Expires = new DateTimeOffset(1994, 11, 6, 8, 49, 37, TimeSpan.Zero), + SameSite = SameSiteMode.Strict, HttpOnly = true, MaxAge = TimeSpan.FromDays(1), Path = "path1", Secure = true }; - var string1 = "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; httponly"; + var string1 = "name1=n1=v1&n2=v2&n3=v3; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite=Strict; httponly"; var header2 = new SetCookieHeaderValue("name2", "value2"); var string2 = "name2=value2";