diff --git a/src/Http/Headers/ref/Microsoft.Net.Http.Headers.netcoreapp.cs b/src/Http/Headers/ref/Microsoft.Net.Http.Headers.netcoreapp.cs index 1bd100081dcf..a730eadf21e9 100644 --- a/src/Http/Headers/ref/Microsoft.Net.Http.Headers.netcoreapp.cs +++ b/src/Http/Headers/ref/Microsoft.Net.Http.Headers.netcoreapp.cs @@ -316,6 +316,7 @@ public partial class RangeItemHeaderValue } public enum SameSiteMode { + Unspecified = -1, None = 0, Lax = 1, Strict = 2, diff --git a/src/Http/Headers/src/SameSiteMode.cs b/src/Http/Headers/src/SameSiteMode.cs index 29c08a5984c5..70e5fd3b2c12 100644 --- a/src/Http/Headers/src/SameSiteMode.cs +++ b/src/Http/Headers/src/SameSiteMode.cs @@ -5,12 +5,14 @@ namespace Microsoft.Net.Http.Headers { /// /// Indicates if the client should include a cookie on "same-site" or "cross-site" requests. - /// RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 + /// RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 /// // This mirrors Microsoft.AspNetCore.Http.SameSiteMode public enum SameSiteMode { /// No SameSite field will be set, the client should follow its default cookie policy. + Unspecified = -1, + /// Indicates the client should disable same-site restrictions. None = 0, /// Indicates the client should send the cookie with "same-site" requests, and with "cross-site" top-level navigations. Lax, diff --git a/src/Http/Headers/src/SetCookieHeaderValue.cs b/src/Http/Headers/src/SetCookieHeaderValue.cs index 852959348698..3a5b217d6c31 100644 --- a/src/Http/Headers/src/SetCookieHeaderValue.cs +++ b/src/Http/Headers/src/SetCookieHeaderValue.cs @@ -20,8 +20,14 @@ public class SetCookieHeaderValue 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 SameSiteNoneToken = SameSiteMode.None.ToString().ToLower(); private static readonly string SameSiteLaxToken = SameSiteMode.Lax.ToString().ToLower(); private static readonly string SameSiteStrictToken = SameSiteMode.Strict.ToString().ToLower(); + + // True (old): https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-3.1 + // False (new): https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 + internal static bool SuppressSameSiteNone; + private const string HttpOnlyToken = "httponly"; private const string SeparatorToken = "; "; private const string EqualsToken = "="; @@ -36,6 +42,14 @@ public class SetCookieHeaderValue private StringSegment _name; private StringSegment _value; + static SetCookieHeaderValue() + { + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.SuppressSameSiteNone", out var enabled)) + { + SuppressSameSiteNone = enabled; + } + } + private SetCookieHeaderValue() { // Used by the parser to create a new instance of this type. @@ -92,16 +106,17 @@ public StringSegment Value public bool Secure { get; set; } - public SameSiteMode SameSite { get; set; } + public SameSiteMode SameSite { get; set; } = SuppressSameSiteNone ? SameSiteMode.None : SameSiteMode.Unspecified; public bool HttpOnly { get; set; } - // name="value"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite={Strict|Lax}; httponly + // name="value"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite={strict|lax|none}; httponly public override string ToString() { var length = _name.Length + EqualsToken.Length + _value.Length; string maxAge = null; + string sameSite = null; if (Expires.HasValue) { @@ -129,9 +144,20 @@ public override string ToString() length += SeparatorToken.Length + SecureToken.Length; } - if (SameSite != SameSiteMode.None) + // Allow for Unspecified (-1) to skip SameSite + if (SameSite == SameSiteMode.None && !SuppressSameSiteNone) + { + sameSite = SameSiteNoneToken; + length += SeparatorToken.Length + SameSiteToken.Length + EqualsToken.Length + sameSite.Length; + } + else if (SameSite == SameSiteMode.Lax) { - var sameSite = SameSite == SameSiteMode.Lax ? SameSiteLaxToken : SameSiteStrictToken; + sameSite = SameSiteLaxToken; + length += SeparatorToken.Length + SameSiteToken.Length + EqualsToken.Length + sameSite.Length; + } + else if (SameSite == SameSiteMode.Strict) + { + sameSite = SameSiteStrictToken; length += SeparatorToken.Length + SameSiteToken.Length + EqualsToken.Length + sameSite.Length; } @@ -140,9 +166,9 @@ public override string ToString() length += SeparatorToken.Length + HttpOnlyToken.Length; } - return string.Create(length, (this, maxAge), (span, tuple) => + return string.Create(length, (this, maxAge, sameSite), (span, tuple) => { - var (headerValue, maxAgeValue) = tuple; + var (headerValue, maxAgeValue, sameSite) = tuple; Append(ref span, headerValue._name); Append(ref span, EqualsToken); @@ -180,9 +206,9 @@ public override string ToString() AppendSegment(ref span, SecureToken, null); } - if (headerValue.SameSite != SameSiteMode.None) + if (sameSite != null) { - AppendSegment(ref span, SameSiteToken, headerValue.SameSite == SameSiteMode.Lax ? SameSiteLaxToken : SameSiteStrictToken); + AppendSegment(ref span, SameSiteToken, sameSite); } if (headerValue.HttpOnly) @@ -248,9 +274,18 @@ public void AppendToStringBuilder(StringBuilder builder) AppendSegment(builder, SecureToken, null); } - if (SameSite != SameSiteMode.None) + // Allow for Unspecified (-1) to skip SameSite + if (SameSite == SameSiteMode.None && !SuppressSameSiteNone) + { + AppendSegment(builder, SameSiteToken, SameSiteNoneToken); + } + else if (SameSite == SameSiteMode.Lax) { - AppendSegment(builder, SameSiteToken, SameSite == SameSiteMode.Lax ? SameSiteLaxToken : SameSiteStrictToken); + AppendSegment(builder, SameSiteToken, SameSiteLaxToken); + } + else if (SameSite == SameSiteMode.Strict) + { + AppendSegment(builder, SameSiteToken, SameSiteStrictToken); } if (HttpOnly) @@ -302,7 +337,7 @@ public static bool TryParseStrictList(IList inputs, out IList= 0); @@ -437,25 +472,34 @@ private static int GetSetCookieLength(StringSegment input, int startIndex, out S { result.Secure = true; } - // samesite-av = "SameSite" / "SameSite=" samesite-value - // samesite-value = "Strict" / "Lax" + // samesite-av = "SameSite=" samesite-value + // samesite-value = "Strict" / "Lax" / "None" else if (StringSegment.Equals(token, SameSiteToken, StringComparison.OrdinalIgnoreCase)) { if (!ReadEqualsSign(input, ref offset)) { - result.SameSite = SameSiteMode.Strict; + result.SameSite = SuppressSameSiteNone ? SameSiteMode.Strict : SameSiteMode.Unspecified; } else { var enforcementMode = ReadToSemicolonOrEnd(input, ref offset); - if (StringSegment.Equals(enforcementMode, SameSiteLaxToken, StringComparison.OrdinalIgnoreCase)) + if (StringSegment.Equals(enforcementMode, SameSiteStrictToken, StringComparison.OrdinalIgnoreCase)) + { + result.SameSite = SameSiteMode.Strict; + } + else if (StringSegment.Equals(enforcementMode, SameSiteLaxToken, StringComparison.OrdinalIgnoreCase)) { result.SameSite = SameSiteMode.Lax; } + else if (!SuppressSameSiteNone + && StringSegment.Equals(enforcementMode, SameSiteNoneToken, StringComparison.OrdinalIgnoreCase)) + { + result.SameSite = SameSiteMode.None; + } else { - result.SameSite = SameSiteMode.Strict; + result.SameSite = SuppressSameSiteNone ? SameSiteMode.Strict : SameSiteMode.Unspecified; } } } diff --git a/src/Http/Headers/test/SetCookieHeaderValueTest.cs b/src/Http/Headers/test/SetCookieHeaderValueTest.cs index 058f8d4bd972..11593b4f93c8 100644 --- a/src/Http/Headers/test/SetCookieHeaderValueTest.cs +++ b/src/Http/Headers/test/SetCookieHeaderValueTest.cs @@ -57,7 +57,7 @@ public class SetCookieHeaderValueTest { SameSite = SameSiteMode.None, }; - dataset.Add(header7, "name7=value7"); + dataset.Add(header7, "name7=value7; samesite=none"); return dataset; @@ -155,9 +155,20 @@ public static TheoryData InvalidCookieValues { SameSite = SameSiteMode.Strict }; - var string6a = "name6=value6; samesite"; - var string6b = "name6=value6; samesite=Strict"; - var string6c = "name6=value6; samesite=invalid"; + var string6 = "name6=value6; samesite=Strict"; + + var header7 = new SetCookieHeaderValue("name7", "value7") + { + SameSite = SameSiteMode.None + }; + var string7 = "name7=value7; samesite=None"; + + var header8 = new SetCookieHeaderValue("name8", "value8") + { + SameSite = SameSiteMode.Unspecified + }; + var string8a = "name8=value8; samesite"; + var string8b = "name8=value8; samesite=invalid"; dataset.Add(new[] { header1 }.ToList(), new[] { string1 }); dataset.Add(new[] { header1, header1 }.ToList(), new[] { string1, string1 }); @@ -170,9 +181,10 @@ public static TheoryData InvalidCookieValues 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 }); + dataset.Add(new[] { header6 }.ToList(), new[] { string6 }); + dataset.Add(new[] { header7 }.ToList(), new[] { string7 }); + dataset.Add(new[] { header8 }.ToList(), new[] { string8a }); + dataset.Add(new[] { header8 }.ToList(), new[] { string8b }); return dataset; } @@ -301,6 +313,28 @@ public void SetCookieHeaderValue_ToString(SetCookieHeaderValue input, string exp Assert.Equal(expectedValue, input.ToString()); } + [Fact] + public void SetCookieHeaderValue_ToString_SameSiteNoneCompat() + { + SetCookieHeaderValue.SuppressSameSiteNone = true; + + var input = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + + Assert.Equal("name=value", input.ToString()); + + SetCookieHeaderValue.SuppressSameSiteNone = false; + + var input2 = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + + Assert.Equal("name=value; samesite=none", input2.ToString()); + } + [Theory] [MemberData(nameof(SetCookieHeaderDataSet))] public void SetCookieHeaderValue_AppendToStringBuilder(SetCookieHeaderValue input, string expectedValue) @@ -312,6 +346,32 @@ public void SetCookieHeaderValue_AppendToStringBuilder(SetCookieHeaderValue inpu Assert.Equal(expectedValue, builder.ToString()); } + [Fact] + public void SetCookieHeaderValue_AppendToStringBuilder_SameSiteNoneCompat() + { + SetCookieHeaderValue.SuppressSameSiteNone = true; + + var builder = new StringBuilder(); + var input = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + + input.AppendToStringBuilder(builder); + Assert.Equal("name=value", builder.ToString()); + + SetCookieHeaderValue.SuppressSameSiteNone = false; + + var builder2 = new StringBuilder(); + var input2 = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + + input2.AppendToStringBuilder(builder2); + Assert.Equal("name=value; samesite=none", builder2.ToString()); + } + [Theory] [MemberData(nameof(SetCookieHeaderDataSet))] public void SetCookieHeaderValue_Parse_AcceptsValidValues(SetCookieHeaderValue cookie, string expectedValue) @@ -322,6 +382,31 @@ public void SetCookieHeaderValue_Parse_AcceptsValidValues(SetCookieHeaderValue c Assert.Equal(expectedValue, header.ToString()); } + [Fact] + public void SetCookieHeaderValue_Parse_AcceptsValidValues_SameSiteNoneCompat() + { + SetCookieHeaderValue.SuppressSameSiteNone = true; + var header = SetCookieHeaderValue.Parse("name=value; samesite=none"); + + var cookie = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.Strict, + }; + + Assert.Equal(cookie, header); + Assert.Equal("name=value; samesite=strict", header.ToString()); + SetCookieHeaderValue.SuppressSameSiteNone = false; + + var header2 = SetCookieHeaderValue.Parse("name=value; samesite=none"); + + var cookie2 = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + Assert.Equal(cookie2, header2); + Assert.Equal("name=value; samesite=none", header2.ToString()); + } + [Theory] [MemberData(nameof(SetCookieHeaderDataSet))] public void SetCookieHeaderValue_TryParse_AcceptsValidValues(SetCookieHeaderValue cookie, string expectedValue) @@ -332,6 +417,31 @@ public void SetCookieHeaderValue_TryParse_AcceptsValidValues(SetCookieHeaderValu Assert.Equal(expectedValue, header.ToString()); } + [Fact] + public void SetCookieHeaderValue_TryParse_AcceptsValidValues_SameSiteNoneCompat() + { + SetCookieHeaderValue.SuppressSameSiteNone = true; + Assert.True(SetCookieHeaderValue.TryParse("name=value; samesite=none", out var header)); + var cookie = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.Strict, + }; + + Assert.Equal(cookie, header); + Assert.Equal("name=value; samesite=strict", header.ToString()); + + SetCookieHeaderValue.SuppressSameSiteNone = false; + + Assert.True(SetCookieHeaderValue.TryParse("name=value; samesite=none", out var header2)); + var cookie2 = new SetCookieHeaderValue("name", "value") + { + SameSite = SameSiteMode.None, + }; + + Assert.Equal(cookie2, header2); + Assert.Equal("name=value; samesite=none", header2.ToString()); + } + [Theory] [MemberData(nameof(InvalidSetCookieHeaderDataSet))] public void SetCookieHeaderValue_Parse_RejectsInvalidValues(string value) diff --git a/src/Http/Http.Abstractions/src/CookieBuilder.cs b/src/Http/Http.Abstractions/src/CookieBuilder.cs index bbaaf07d1fd4..46ed33e7f820 100644 --- a/src/Http/Http.Abstractions/src/CookieBuilder.cs +++ b/src/Http/Http.Abstractions/src/CookieBuilder.cs @@ -11,8 +11,20 @@ namespace Microsoft.AspNetCore.Http /// public class CookieBuilder { + // True (old): https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-3.1 + // False (new): https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 + internal static bool SuppressSameSiteNone; + private string _name; + static CookieBuilder() + { + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.SuppressSameSiteNone", out var enabled)) + { + SuppressSameSiteNone = enabled; + } + } + /// /// The name of the cookie. /// @@ -49,12 +61,12 @@ public virtual string Name public virtual bool HttpOnly { get; set; } /// - /// The SameSite attribute of the cookie. The default value is + /// The SameSite attribute of the cookie. The default value is /// /// /// Determines the value that will set on . /// - public virtual SameSiteMode SameSite { get; set; } = SameSiteMode.None; + public virtual SameSiteMode SameSite { get; set; } = SuppressSameSiteNone ? SameSiteMode.None : SameSiteMode.Unspecified; /// /// The policy that will be used to determine . diff --git a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netcoreapp.cs b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netcoreapp.cs index a7327d08fc8f..0ba855126006 100644 --- a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netcoreapp.cs +++ b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netcoreapp.cs @@ -84,6 +84,7 @@ public partial interface ISession } public enum SameSiteMode { + Unspecified = -1, None = 0, Lax = 1, Strict = 2, diff --git a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs index a7327d08fc8f..0ba855126006 100644 --- a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs +++ b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs @@ -84,6 +84,7 @@ public partial interface ISession } public enum SameSiteMode { + Unspecified = -1, None = 0, Lax = 1, Strict = 2, diff --git a/src/Http/Http.Features/src/CookieOptions.cs b/src/Http/Http.Features/src/CookieOptions.cs index 81e883bd5615..6720ad6f0525 100644 --- a/src/Http/Http.Features/src/CookieOptions.cs +++ b/src/Http/Http.Features/src/CookieOptions.cs @@ -10,6 +10,18 @@ namespace Microsoft.AspNetCore.Http /// public class CookieOptions { + // True (old): https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-3.1 + // False (new): https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 + internal static bool SuppressSameSiteNone; + + static CookieOptions() + { + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.SuppressSameSiteNone", out var enabled)) + { + SuppressSameSiteNone = enabled; + } + } + /// /// Creates a default cookie with a path of '/'. /// @@ -43,10 +55,10 @@ public CookieOptions() public bool Secure { get; set; } /// - /// Gets or sets the value for the SameSite attribute of the cookie. The default value is + /// 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.None; + public SameSiteMode SameSite { get; set; } = SuppressSameSiteNone ? SameSiteMode.None : SameSiteMode.Unspecified; /// /// Gets or sets a value that indicates whether a cookie is accessible by client-side script. diff --git a/src/Http/Http.Features/src/SameSiteMode.cs b/src/Http/Http.Features/src/SameSiteMode.cs index d1af765a93d6..c9a11143976b 100644 --- a/src/Http/Http.Features/src/SameSiteMode.cs +++ b/src/Http/Http.Features/src/SameSiteMode.cs @@ -5,12 +5,14 @@ namespace Microsoft.AspNetCore.Http { /// /// Used to set the SameSite field on response cookies to indicate if those cookies should be included by the client on future "same-site" or "cross-site" requests. - /// RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 + /// RFC Draft: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 /// // This mirrors Microsoft.Net.Http.Headers.SameSiteMode public enum SameSiteMode { /// No SameSite field will be set, the client should follow its default cookie policy. + Unspecified = -1, + /// Indicates the client should disable same-site restrictions. None = 0, /// Indicates the client should send the cookie with "same-site" requests, and with "cross-site" top-level navigations. Lax, diff --git a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/OpenIdConnectSample.csproj b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/OpenIdConnectSample.csproj index 5f5dbaccc2c0..e416aedf54b7 100644 --- a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/OpenIdConnectSample.csproj +++ b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/OpenIdConnectSample.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -14,6 +14,7 @@ + diff --git a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs index b30f479b8cd7..f26e23a8e541 100644 --- a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs +++ b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs @@ -32,14 +32,36 @@ public Startup(IConfiguration config, IWebHostEnvironment env) public IConfiguration Configuration { get; set; } public IWebHostEnvironment Environment { get; } + private void CheckSameSite(HttpContext httpContext, CookieOptions options) + { + if (options.SameSite > SameSiteMode.Unspecified) + { + var userAgent = httpContext.Request.Headers["User-Agent"]; + // TODO: Use your User Agent library of choice here. + if (userAgent.Contains("CPU iPhone OS 12") // Also covers iPod touch + || userAgent.Contains("iPad; CPU OS 12") + // Safari 12 and 13 are both broken on Mojave + || userAgent.Contains("Macintosh; Intel Mac OS X 10_14")) + { + options.SameSite = SameSiteMode.Unspecified; + } + } + } + public void ConfigureServices(IServiceCollection services) { JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + services.Configure(options => + { + options.MinimumSameSitePolicy = SameSiteMode.Unspecified; + options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + }); + services.AddAuthentication(sharedOptions => { - sharedOptions.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; - sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; sharedOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() @@ -84,6 +106,7 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app, IOptionsMonitor optionsMonitor) { app.UseDeveloperExceptionPage(); + app.UseCookiePolicy(); // Before UseAuthentication or anything else that writes cookies. app.UseAuthentication(); app.Run(async context => diff --git a/src/Security/Authentication/WsFederation/samples/WsFedSample/WsFedSample.csproj b/src/Security/Authentication/WsFederation/samples/WsFedSample/WsFedSample.csproj index 0d6734f909cb..dbaba2adafc4 100644 --- a/src/Security/Authentication/WsFederation/samples/WsFedSample/WsFedSample.csproj +++ b/src/Security/Authentication/WsFederation/samples/WsFedSample/WsFedSample.csproj @@ -2,6 +2,7 @@ $(DefaultNetCoreTargetFramework) + OutOfProcess diff --git a/src/Security/Authentication/test/CookieTests.cs b/src/Security/Authentication/test/CookieTests.cs index 439c2cccc42c..44775187129f 100644 --- a/src/Security/Authentication/test/CookieTests.cs +++ b/src/Security/Authentication/test/CookieTests.cs @@ -229,7 +229,7 @@ public async Task CookieOptionsAlterSetCookieHeader() Assert.Contains(" path=/foo", setCookie1); Assert.Contains(" domain=another.com", setCookie1); Assert.Contains(" secure", setCookie1); - Assert.DoesNotContain(" samesite", setCookie1); + Assert.Contains(" samesite=none", setCookie1); Assert.Contains(" httponly", setCookie1); var server2 = CreateServer(o => diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectChallengeTests.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectChallengeTests.cs index d6836591182b..aeb6c91b7f56 100644 --- a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectChallengeTests.cs +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectChallengeTests.cs @@ -437,6 +437,7 @@ public async Task ChallengeSetsNonceAndStateCookies(OpenIdConnectRedirectBehavio var server = settings.CreateTestServer(); var transaction = await server.SendAsync(ChallengeEndpoint); + Assert.Contains("samesite=none", transaction.SetCookie.First()); var challengeCookies = SetCookieHeaderValue.ParseList(transaction.SetCookie); var nonceCookie = challengeCookies.Where(cookie => cookie.Name.StartsWith(OpenIdConnectDefaults.CookieNoncePrefix, StringComparison.Ordinal)).Single(); Assert.True(nonceCookie.Expires.HasValue); @@ -452,6 +453,7 @@ public async Task ChallengeSetsNonceAndStateCookies(OpenIdConnectRedirectBehavio Assert.True(correlationCookie.HttpOnly); Assert.Equal("/signin-oidc", correlationCookie.Path); Assert.False(StringSegment.IsNullOrEmpty(correlationCookie.Value)); + Assert.Equal(Net.Http.Headers.SameSiteMode.None, correlationCookie.SameSite); Assert.Equal(2, challengeCookies.Count); } diff --git a/src/Security/CookiePolicy/src/CookiePolicyOptions.cs b/src/Security/CookiePolicy/src/CookiePolicyOptions.cs index 4f0806c46c95..098bd3348395 100644 --- a/src/Security/CookiePolicy/src/CookiePolicyOptions.cs +++ b/src/Security/CookiePolicy/src/CookiePolicyOptions.cs @@ -12,10 +12,22 @@ namespace Microsoft.AspNetCore.Builder /// public class CookiePolicyOptions { + // True (old): https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-3.1 + // False (new): https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.1 + internal static bool SuppressSameSiteNone; + + static CookiePolicyOptions() + { + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.SuppressSameSiteNone", out var enabled)) + { + SuppressSameSiteNone = enabled; + } + } + /// /// Affects the cookie's same site attribute. /// - public SameSiteMode MinimumSameSitePolicy { get; set; } = SameSiteMode.None; + public SameSiteMode MinimumSameSitePolicy { get; set; } = SuppressSameSiteNone ? SameSiteMode.None : SameSiteMode.Unspecified; /// /// Affects whether cookies must be HttpOnly. diff --git a/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs b/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs index 126c4d7bd525..879df47018fb 100644 --- a/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs +++ b/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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. using System; @@ -115,7 +115,8 @@ public string CreateConsentCookie() private bool CheckPolicyRequired() { return !CanTrack - || Options.MinimumSameSitePolicy != SameSiteMode.None + || (CookiePolicyOptions.SuppressSameSiteNone && Options.MinimumSameSitePolicy != SameSiteMode.None) + || (!CookiePolicyOptions.SuppressSameSiteNone && Options.MinimumSameSitePolicy != SameSiteMode.Unspecified) || Options.HttpOnly != HttpOnlyPolicy.None || Options.Secure != CookieSecurePolicy.None; } @@ -241,26 +242,10 @@ private void ApplyPolicy(string key, CookieOptions options) default: throw new InvalidOperationException(); } - switch (Options.MinimumSameSitePolicy) + if (options.SameSite < Options.MinimumSameSitePolicy) { - case SameSiteMode.None: - break; - case SameSiteMode.Lax: - if (options.SameSite == SameSiteMode.None) - { - options.SameSite = SameSiteMode.Lax; - _logger.CookieSameSiteUpgraded(key, "lax"); - } - break; - case SameSiteMode.Strict: - if (options.SameSite != SameSiteMode.Strict) - { - options.SameSite = SameSiteMode.Strict; - _logger.CookieSameSiteUpgraded(key, "strict"); - } - break; - default: - throw new InvalidOperationException($"Unrecognized {nameof(SameSiteMode)} value {Options.MinimumSameSitePolicy.ToString()}"); + options.SameSite = Options.MinimumSameSitePolicy; + _logger.CookieSameSiteUpgraded(key, Options.MinimumSameSitePolicy.ToString()); } switch (Options.HttpOnly) { @@ -278,4 +263,4 @@ private void ApplyPolicy(string key, CookieOptions options) } } } -} \ No newline at end of file +} diff --git a/src/Security/CookiePolicy/test/CookieConsentTests.cs b/src/Security/CookiePolicy/test/CookieConsentTests.cs index ffe8c30619ea..cda7e7d93c3f 100644 --- a/src/Security/CookiePolicy/test/CookieConsentTests.cs +++ b/src/Security/CookiePolicy/test/CookieConsentTests.cs @@ -223,12 +223,12 @@ public async Task GrantConsentSetsCookie() Assert.Equal("yes", consentCookie.Value); Assert.True(consentCookie.Expires.HasValue); Assert.True(consentCookie.Expires.Value > DateTimeOffset.Now + TimeSpan.FromDays(364)); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, consentCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, consentCookie.SameSite); Assert.NotNull(consentCookie.Expires); var testCookie = cookies[1]; Assert.Equal("Test", testCookie.Name); Assert.Equal("Value", testCookie.Value); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, testCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, testCookie.SameSite); Assert.Null(testCookie.Expires); } @@ -400,12 +400,12 @@ public async Task WithdrawConsentDeletesCookie() var testCookie = cookies[0]; Assert.Equal("Test", testCookie.Name); Assert.Equal("Value1", testCookie.Value); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, testCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, testCookie.SameSite); Assert.Null(testCookie.Expires); var consentCookie = cookies[1]; Assert.Equal(".AspNet.Consent", consentCookie.Name); Assert.Equal("", consentCookie.Value); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, consentCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, consentCookie.SameSite); Assert.NotNull(consentCookie.Expires); } @@ -512,7 +512,7 @@ public async Task DeleteCookieDoesNotRequireConsent() var testCookie = cookies[0]; Assert.Equal("Test", testCookie.Name); Assert.Equal("", testCookie.Value); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, testCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, testCookie.SameSite); Assert.NotNull(testCookie.Expires); } @@ -576,7 +576,7 @@ public async Task CreateConsentCookieMatchesGrantConsentCookie() var consentCookie = cookies[0]; Assert.Equal(".AspNet.Consent", consentCookie.Name); Assert.Equal("yes", consentCookie.Value); - Assert.Equal(Net.Http.Headers.SameSiteMode.None, consentCookie.SameSite); + Assert.Equal(Net.Http.Headers.SameSiteMode.Unspecified, consentCookie.SameSite); Assert.NotNull(consentCookie.Expires); cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers["ManualCookie"]); diff --git a/src/Security/CookiePolicy/test/CookiePolicyTests.cs b/src/Security/CookiePolicy/test/CookiePolicyTests.cs index cf233360fa6d..395d328f1d6e 100644 --- a/src/Security/CookiePolicy/test/CookiePolicyTests.cs +++ b/src/Security/CookiePolicy/test/CookiePolicyTests.cs @@ -39,8 +39,8 @@ public class CookiePolicyTests private RequestDelegate SameSiteCookieAppends = context => { context.Response.Cookies.Append("A", "A"); - context.Response.Cookies.Append("B", "B", new CookieOptions { SameSite = Http.SameSiteMode.None }); - context.Response.Cookies.Append("C", "C", new CookieOptions()); + context.Response.Cookies.Append("B", "B", new CookieOptions()); + context.Response.Cookies.Append("C", "C", new CookieOptions { SameSite = Http.SameSiteMode.None }); context.Response.Cookies.Append("D", "D", new CookieOptions { SameSite = Http.SameSiteMode.Lax }); context.Response.Cookies.Append("E", "E", new CookieOptions { SameSite = Http.SameSiteMode.Strict }); return Task.FromResult(0); @@ -198,7 +198,7 @@ public async Task SameSiteLaxSetsItAlways() } [Fact] - public async Task SameSiteNoneLeavesItAlone() + public async Task SameSiteNoneSetsItAlways() { await RunTest("/sameSiteNone", new CookiePolicyOptions @@ -208,11 +208,32 @@ public async Task SameSiteNoneLeavesItAlone() SameSiteCookieAppends, new RequestTest("http://example.com/sameSiteNone", transaction => + { + Assert.NotNull(transaction.SetCookie); + Assert.Equal("A=A; path=/; samesite=none", transaction.SetCookie[0]); + Assert.Equal("B=B; path=/; samesite=none", transaction.SetCookie[1]); + Assert.Equal("C=C; path=/; samesite=none", transaction.SetCookie[2]); + Assert.Equal("D=D; path=/; samesite=lax", transaction.SetCookie[3]); + Assert.Equal("E=E; path=/; samesite=strict", transaction.SetCookie[4]); + })); + } + + [Fact] + public async Task SameSiteUnspecifiedLeavesItAlone() + { + await RunTest("/sameSiteNone", + new CookiePolicyOptions + { + MinimumSameSitePolicy = Http.SameSiteMode.Unspecified + }, + SameSiteCookieAppends, + new RequestTest("http://example.com/sameSiteNone", + transaction => { Assert.NotNull(transaction.SetCookie); Assert.Equal("A=A; path=/", transaction.SetCookie[0]); Assert.Equal("B=B; path=/", transaction.SetCookie[1]); - Assert.Equal("C=C; path=/", transaction.SetCookie[2]); + Assert.Equal("C=C; path=/; samesite=none", transaction.SetCookie[2]); Assert.Equal("D=D; path=/; samesite=lax", transaction.SetCookie[3]); Assert.Equal("E=E; path=/; samesite=strict", transaction.SetCookie[4]); }));