diff --git a/src/Http/Headers/src/CacheControlHeaderValue.cs b/src/Http/Headers/src/CacheControlHeaderValue.cs index e807a82b323c12590a01119d8f853ba208236a54..3aedf5c176806dbf4f749ea7a39dcb4cdc86abf6 100644 --- a/src/Http/Headers/src/CacheControlHeaderValue.cs +++ b/src/Http/Headers/src/CacheControlHeaderValue.cs @@ -502,7 +502,7 @@ public class CacheControlHeaderValue /// </summary> /// <param name="input">The value to parse.</param> /// <param name="parsedValue">The parsed value.</param> - /// <returns><see langword="true"/> if input is a valid <see cref="SetCookieHeaderValue"/>, otherwise <see langword="false"/>.</returns> + /// <returns><see langword="true"/> if input is a valid <see cref="CacheControlHeaderValue"/>, otherwise <see langword="false"/>.</returns> public static bool TryParse(StringSegment input, [NotNullWhen(true)] out CacheControlHeaderValue? parsedValue) { var index = 0; diff --git a/src/Http/Headers/src/SetCookieHeaderValue.cs b/src/Http/Headers/src/SetCookieHeaderValue.cs index 34460aa805847795d23e066c1b155367c7da39cd..719e814179e255f25a1ba7fbbc6a3b0d228df8f0 100644 --- a/src/Http/Headers/src/SetCookieHeaderValue.cs +++ b/src/Http/Headers/src/SetCookieHeaderValue.cs @@ -41,6 +41,7 @@ public class SetCookieHeaderValue private StringSegment _name; private StringSegment _value; + private List<StringSegment>? _extensions; private SetCookieHeaderValue() { @@ -177,7 +178,10 @@ public class SetCookieHeaderValue /// <summary> /// Gets a collection of additional values to append to the cookie. /// </summary> - public IList<StringSegment> Extensions { get; } = new List<StringSegment>(); + public IList<StringSegment> Extensions + { + get => _extensions ??= new List<StringSegment>(); + } // name="value"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite={strict|lax|none}; httponly /// <inheritdoc /> @@ -236,9 +240,12 @@ public class SetCookieHeaderValue length += SeparatorToken.Length + HttpOnlyToken.Length; } - foreach (var extension in Extensions) + if (_extensions?.Count > 0) { - length += SeparatorToken.Length + extension.Length; + foreach (var extension in _extensions) + { + length += SeparatorToken.Length + extension.Length; + } } return string.Create(length, (this, maxAge, sameSite), (span, tuple) => @@ -291,9 +298,12 @@ public class SetCookieHeaderValue AppendSegment(ref span, HttpOnlyToken, null); } - foreach (var extension in Extensions) + if (_extensions?.Count > 0) { - AppendSegment(ref span, extension, null); + foreach (var extension in _extensions) + { + AppendSegment(ref span, extension, null); + } } }); } @@ -373,9 +383,12 @@ public class SetCookieHeaderValue AppendSegment(builder, HttpOnlyToken, null); } - foreach (var extension in Extensions) + if (_extensions?.Count > 0) { - AppendSegment(builder, extension, null); + foreach (var extension in _extensions) + { + AppendSegment(builder, extension, null); + } } } @@ -701,7 +714,7 @@ public class SetCookieHeaderValue && Secure == other.Secure && SameSite == other.SameSite && HttpOnly == other.HttpOnly - && HeaderUtilities.AreEqualCollections(Extensions, other.Extensions, StringSegmentComparer.OrdinalIgnoreCase); + && HeaderUtilities.AreEqualCollections(_extensions, other._extensions, StringSegmentComparer.OrdinalIgnoreCase); } /// <inheritdoc /> @@ -717,9 +730,12 @@ public class SetCookieHeaderValue ^ SameSite.GetHashCode() ^ HttpOnly.GetHashCode(); - foreach (var extension in Extensions) + if (_extensions?.Count > 0) { - hash ^= extension.GetHashCode(); + foreach (var extension in _extensions) + { + hash ^= extension.GetHashCode(); + } } return hash; diff --git a/src/Http/Http.Abstractions/src/CookieBuilder.cs b/src/Http/Http.Abstractions/src/CookieBuilder.cs index e4c1584852876a84fcb31e458b2865a3228326a9..21ce68649c9e1317ae827756072f4e9f1a527308 100644 --- a/src/Http/Http.Abstractions/src/CookieBuilder.cs +++ b/src/Http/Http.Abstractions/src/CookieBuilder.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Http; public class CookieBuilder { private string? _name; + private List<string>? _extensions; /// <summary> /// The name of the cookie. @@ -77,6 +78,14 @@ public class CookieBuilder /// </summary> public virtual bool IsEssential { get; set; } + /// <summary> + /// Gets a collection of additional values to append to the cookie. + /// </summary> + public IList<string> Extensions + { + get => _extensions ??= new List<string>(); + } + /// <summary> /// Creates the cookie options from the given <paramref name="context"/>. /// </summary> @@ -97,7 +106,7 @@ public class CookieBuilder throw new ArgumentNullException(nameof(context)); } - return new CookieOptions + var options = new CookieOptions { Path = Path ?? "/", SameSite = SameSite, @@ -108,5 +117,14 @@ public class CookieBuilder Secure = SecurePolicy == CookieSecurePolicy.Always || (SecurePolicy == CookieSecurePolicy.SameAsRequest && context.Request.IsHttps), Expires = Expiration.HasValue ? expiresFrom.Add(Expiration.GetValueOrDefault()) : default(DateTimeOffset?) }; + + if (_extensions?.Count > 0) + { + foreach (var extension in _extensions) + { + options.Extensions.Add(extension); + } + } + return options; } } diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index a7a9ac6b273c83ff8664b39c089c4fd987ada5a4..cf864b4ca5f6170884fbc3359e133359e7aa71f2 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ Microsoft.AspNetCore.Builder.EndpointBuilder.ApplicationServices.get -> System.I Microsoft.AspNetCore.Builder.EndpointBuilder.ApplicationServices.set -> void Microsoft.AspNetCore.Http.AsParametersAttribute Microsoft.AspNetCore.Http.AsParametersAttribute.AsParametersAttribute() -> void +Microsoft.AspNetCore.Http.CookieBuilder.Extensions.get -> System.Collections.Generic.IList<string!>! Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext.DefaultRouteHandlerInvocationContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! arguments) -> void Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! diff --git a/src/Http/Http.Abstractions/test/CookieBuilderTests.cs b/src/Http/Http.Abstractions/test/CookieBuilderTests.cs index 536830e71e808448bd1364c047ce32b7285c2b3c..ce171a6ec5272707b6bd6108209a56e81c43a4eb 100644 --- a/src/Http/Http.Abstractions/test/CookieBuilderTests.cs +++ b/src/Http/Http.Abstractions/test/CookieBuilderTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Microsoft.AspNetCore.Http.Abstractions.Tests; @@ -50,4 +50,26 @@ public class CookieBuilderTests { Assert.Equal(new CookieOptions().Path, new CookieBuilder().Build(new DefaultHttpContext()).Path); } + + [Fact] + public void CookieBuilder_Extensions_Added() + { + var builder = new CookieBuilder(); + builder.Extensions.Add("simple"); + builder.Extensions.Add("key=value"); + + var options = builder.Build(new DefaultHttpContext()); + Assert.Equal(2, options.Extensions.Count); + Assert.Contains("simple", options.Extensions); + Assert.Contains("key=value", options.Extensions); + + var cookie = options.CreateCookieHeader("name", "value"); + Assert.Equal("name", cookie.Name); + Assert.Equal("value", cookie.Value); + Assert.Equal(2, cookie.Extensions.Count); + Assert.Contains("simple", cookie.Extensions); + Assert.Contains("key=value", cookie.Extensions); + + Assert.Equal("name=value; path=/; simple; key=value", cookie.ToString()); + } } diff --git a/src/Http/Http.Features/src/CookieOptions.cs b/src/Http/Http.Features/src/CookieOptions.cs index fb5e80f26d92849b1fcd1716bd7505bd7b36c8ab..8314f349ecba0adf2d449136c028818b2389c10b 100644 --- a/src/Http/Http.Features/src/CookieOptions.cs +++ b/src/Http/Http.Features/src/CookieOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Net.Http.Headers; + namespace Microsoft.AspNetCore.Http; /// <summary> @@ -8,6 +10,8 @@ namespace Microsoft.AspNetCore.Http; /// </summary> public class CookieOptions { + private List<string>? _extensions; + /// <summary> /// Creates a default cookie with a path of '/'. /// </summary> @@ -16,6 +20,28 @@ public class CookieOptions Path = "/"; } + /// <summary> + /// Creates a copy of the given <see cref="CookieOptions"/>. + /// </summary> + public CookieOptions(CookieOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + Domain = options.Domain; + Path = options.Path; + Expires = options.Expires; + Secure = options.Secure; + SameSite = options.SameSite; + HttpOnly = options.HttpOnly; + MaxAge = options.MaxAge; + IsEssential = options.IsEssential; + + if (options._extensions?.Count > 0) + { + _extensions = new List<string>(options._extensions); + } + } + /// <summary> /// Gets or sets the domain to associate the cookie with. /// </summary> @@ -63,4 +89,39 @@ public class CookieOptions /// consent policy checks may be bypassed. The default value is false. /// </summary> public bool IsEssential { get; set; } + + /// <summary> + /// Gets a collection of additional values to append to the cookie. + /// </summary> + public IList<string> Extensions + { + get => _extensions ??= new List<string>(); + } + + /// <summary> + /// Creates a <see cref="SetCookieHeaderValue"/> using the current options. + /// </summary> + public SetCookieHeaderValue CreateCookieHeader(string name, string value) + { + var cookie = new SetCookieHeaderValue(name, value) + { + Domain = Domain, + Path = Path, + Expires = Expires, + Secure = Secure, + HttpOnly = HttpOnly, + MaxAge = MaxAge, + SameSite = (Net.Http.Headers.SameSiteMode)SameSite, + }; + + if (_extensions?.Count > 0) + { + foreach (var extension in _extensions) + { + cookie.Extensions.Add(extension); + } + } + + return cookie; + } } diff --git a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt index a7ea06751b1979b91961f183ab7b2c897a846e94..1ace2c6534a1f8a190c5d1962a88970cebe47820 100644 --- a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt @@ -1,4 +1,7 @@ #nullable enable +Microsoft.AspNetCore.Http.CookieOptions.CookieOptions(Microsoft.AspNetCore.Http.CookieOptions! options) -> void +Microsoft.AspNetCore.Http.CookieOptions.CreateCookieHeader(string! name, string! value) -> Microsoft.Net.Http.Headers.SetCookieHeaderValue! +Microsoft.AspNetCore.Http.CookieOptions.Extensions.get -> System.Collections.Generic.IList<string!>! Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.AcceptAsync() -> System.Threading.Tasks.ValueTask<System.IO.Stream!> Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.IsExtendedConnect.get -> bool diff --git a/src/Http/Http/src/Internal/ResponseCookies.cs b/src/Http/Http/src/Internal/ResponseCookies.cs index 4c3ba5faa38b28f84df85668d80312ef735aea15..d92d6afdd2561afbe1d62cb3ae1dacc54dfa1779 100644 --- a/src/Http/Http/src/Internal/ResponseCookies.cs +++ b/src/Http/Http/src/Internal/ResponseCookies.cs @@ -68,22 +68,11 @@ internal sealed partial class ResponseCookies : IResponseCookies } } - var setCookieHeaderValue = new SetCookieHeaderValue( + var cookie = options.CreateCookieHeader( _enableCookieNameEncoding ? Uri.EscapeDataString(key) : key, - Uri.EscapeDataString(value)) - { - Domain = options.Domain, - Path = options.Path, - Expires = options.Expires, - MaxAge = options.MaxAge, - Secure = options.Secure, - SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite, - HttpOnly = options.HttpOnly - }; - - var cookieValue = setCookieHeaderValue.ToString(); + Uri.EscapeDataString(value)).ToString(); - Headers.SetCookie = StringValues.Concat(Headers.SetCookie, cookieValue); + Headers.SetCookie = StringValues.Concat(Headers.SetCookie, cookie); } /// <inheritdoc /> @@ -112,25 +101,14 @@ internal sealed partial class ResponseCookies : IResponseCookies } } - var setCookieHeaderValue = new SetCookieHeaderValue(string.Empty) - { - Domain = options.Domain, - Path = options.Path, - Expires = options.Expires, - MaxAge = options.MaxAge, - Secure = options.Secure, - SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite, - HttpOnly = options.HttpOnly - }; - - var cookierHeaderValue = setCookieHeaderValue.ToString()[1..]; + var cookieSuffix = options.CreateCookieHeader(string.Empty, string.Empty).ToString()[1..]; var cookies = new string[keyValuePairs.Length]; var position = 0; foreach (var keyValuePair in keyValuePairs) { var key = _enableCookieNameEncoding ? Uri.EscapeDataString(keyValuePair.Key) : keyValuePair.Key; - cookies[position] = string.Concat(key, "=", Uri.EscapeDataString(keyValuePair.Value), cookierHeaderValue); + cookies[position] = string.Concat(key, "=", Uri.EscapeDataString(keyValuePair.Value), cookieSuffix); position++; } @@ -200,14 +178,9 @@ internal sealed partial class ResponseCookies : IResponseCookies Headers.SetCookie = new StringValues(newValues.ToArray()); } - Append(key, string.Empty, new CookieOptions + Append(key, string.Empty, new CookieOptions(options) { - Path = options.Path, - Domain = options.Domain, Expires = DateTimeOffset.UnixEpoch, - Secure = options.Secure, - HttpOnly = options.HttpOnly, - SameSite = options.SameSite }); } diff --git a/src/Http/Http/test/CookieOptionsTests.cs b/src/Http/Http/test/CookieOptionsTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..59c9db46fef02933ff801b3d70a474c0df635c81 --- /dev/null +++ b/src/Http/Http/test/CookieOptionsTests.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http.Tests; + +public class CookieOptionsTests +{ + [Fact] + public void CopyCtor_AllPropertiesCopied() + { + var original = new CookieOptions() + { + Domain = "domain", + Expires = DateTime.UtcNow, + Extensions = { "ext1", "ext2=v2" }, + HttpOnly = true, + IsEssential = true, + MaxAge = TimeSpan.FromSeconds(10), + Path = "/foo", + Secure = true, + SameSite = SameSiteMode.Strict, + }; + var copy = new CookieOptions(original); + + var properties = typeof(CookieOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + switch (property.Name) + { + case "Domain": + case "Expires": + case "HttpOnly": + case "IsEssential": + case "MaxAge": + case "Path": + case "Secure": + case "SameSite": + Assert.Equal(property.GetValue(original), property.GetValue(copy)); + break; + case "Extensions": + Assert.NotSame(property.GetValue(original), property.GetValue(copy)); + break; + default: + Assert.True(false, "Not implemented: " + property.Name); + break; + } + } + + Assert.Equal(original.Extensions.Count, copy.Extensions.Count); + foreach (var value in original.Extensions) + { + Assert.Contains(value, copy.Extensions); + } + } +} diff --git a/src/Http/Http/test/ResponseCookiesTest.cs b/src/Http/Http/test/ResponseCookiesTest.cs index 44a0222d791bff8da0432996cf3efb9d78c088f9..6b8d7dddb6a10c1e88886679d86ec7817f15683a 100644 --- a/src/Http/Http/test/ResponseCookiesTest.cs +++ b/src/Http/Http/test/ResponseCookiesTest.cs @@ -54,6 +54,49 @@ public class ResponseCookiesTest Assert.Equal("The cookie 'TestCookie' has set 'SameSite=None' and must also set 'Secure'.", writeContext.Message); } + [Fact] + public void AppendWithExtensions() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var cookies = new ResponseCookies(features); + var testCookie = "TestCookie"; + + cookies.Append(testCookie, "value", new CookieOptions() + { + Extensions = { "simple", "key=value" } + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.Single(cookieHeaderValues); + Assert.StartsWith(testCookie, cookieHeaderValues[0]); + Assert.Contains("path=/", cookieHeaderValues[0]); + Assert.Contains("simple;", cookieHeaderValues[0]); + Assert.EndsWith("key=value", cookieHeaderValues[0]); + } + + [Fact] + public void DeleteWithExtensions() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var cookies = new ResponseCookies(features); + var testCookie = "TestCookie"; + + cookies.Delete(testCookie, new CookieOptions() + { + Extensions = { "simple", "key=value" } + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.Single(cookieHeaderValues); + Assert.StartsWith(testCookie, cookieHeaderValues[0]); + Assert.Contains("path=/", cookieHeaderValues[0]); + Assert.Contains("expires=Thu, 01 Jan 1970 00:00:00 GMT", cookieHeaderValues[0]); + Assert.Contains("simple;", cookieHeaderValues[0]); + Assert.EndsWith("key=value", cookieHeaderValues[0]); + } + [Fact] public void DeleteCookieShouldSetDefaultPath() { @@ -144,7 +187,8 @@ public class ResponseCookiesTest Path = "/", Expires = time, Domain = "example.com", - SameSite = SameSiteMode.Lax + SameSite = SameSiteMode.Lax, + Extensions = { "extension" } }; cookies.Delete(testCookie, options); @@ -157,6 +201,7 @@ public class ResponseCookiesTest Assert.Contains("secure", cookieHeaderValues[0]); Assert.Contains("httponly", cookieHeaderValues[0]); Assert.Contains("samesite", cookieHeaderValues[0]); + Assert.Contains("extension", cookieHeaderValues[0]); } [Fact] diff --git a/src/Security/Authentication/test/CookieTests.cs b/src/Security/Authentication/test/CookieTests.cs index 27c225208eb3eb23dc3f17a62e866b8c24bfca49..e18a57daaf56264e39ca077bdc408450b9ce9748 100644 --- a/src/Security/Authentication/test/CookieTests.cs +++ b/src/Security/Authentication/test/CookieTests.cs @@ -261,6 +261,8 @@ public class CookieTests : SharedAuthenticationTests<CookieAuthenticationOptions o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.SameSite = SameSiteMode.None; o.Cookie.HttpOnly = true; + o.Cookie.Extensions.Add("extension0"); + o.Cookie.Extensions.Add("extension1=value1"); }, SignInAsAlice, baseAddress: new Uri("http://example.com/base")); using var server1 = host.GetTestServer(); @@ -274,6 +276,8 @@ public class CookieTests : SharedAuthenticationTests<CookieAuthenticationOptions Assert.Contains(" secure", setCookie1); Assert.Contains(" samesite=none", setCookie1); Assert.Contains(" httponly", setCookie1); + Assert.Contains(" extension0", setCookie1); + Assert.Contains(" extension1=value1", setCookie1); using var host2 = await CreateHost(o => { @@ -294,6 +298,7 @@ public class CookieTests : SharedAuthenticationTests<CookieAuthenticationOptions Assert.DoesNotContain(" domain=", setCookie2); Assert.DoesNotContain(" secure", setCookie2); Assert.DoesNotContain(" httponly", setCookie2); + Assert.DoesNotContain(" extension", setCookie2); } [Fact] diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs index 99ac5bb819ce77bf813efc8af5160181d46abec0..52a6f4af69b5f937fdf6edc8cc4aa729bb1db375 100644 --- a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs @@ -88,6 +88,7 @@ public class OpenIdConnectTests AuthorizationEndpoint = "https://example.com/provider/login" }; opt.NonceCookie.Path = "/"; + opt.NonceCookie.Extensions.Add("ExtN"); }); var server = setting.CreateTestServer(); @@ -100,6 +101,7 @@ public class OpenIdConnectTests var setCookie = Assert.Single(res.Headers, h => h.Key == "Set-Cookie"); var nonce = Assert.Single(setCookie.Value, v => v.StartsWith(OpenIdConnectDefaults.CookieNoncePrefix, StringComparison.Ordinal)); Assert.Contains("path=/", nonce); + Assert.Contains("ExtN", nonce); } [Fact] @@ -139,6 +141,7 @@ public class OpenIdConnectTests AuthorizationEndpoint = "https://example.com/provider/login" }; opt.CorrelationCookie.Path = "/"; + opt.CorrelationCookie.Extensions.Add("ExtC"); }); var server = setting.CreateTestServer(); @@ -151,6 +154,7 @@ public class OpenIdConnectTests var setCookie = Assert.Single(res.Headers, h => h.Key == "Set-Cookie"); var correlation = Assert.Single(setCookie.Value, v => v.StartsWith(".AspNetCore.Correlation.", StringComparison.Ordinal)); Assert.Contains("path=/", correlation); + Assert.EndsWith("ExtC", correlation); } [Fact] diff --git a/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs b/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs index cbe6273060b860b33bb368f25e631f76753023dc..6fc2f1141d7e6b2eaea97b8d10bcf29b4fc8b4db 100644 --- a/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs +++ b/src/Security/CookiePolicy/src/ResponseCookiesWrapper.cs @@ -98,20 +98,7 @@ internal sealed class ResponseCookiesWrapper : IResponseCookies, ITrackingConsen Debug.Assert(key != null); ApplyAppendPolicy(ref key, ref value, options); - var setCookieHeaderValue = new Net.Http.Headers.SetCookieHeaderValue( - Uri.EscapeDataString(key), - Uri.EscapeDataString(value)) - { - Domain = options.Domain, - Path = options.Path, - Expires = options.Expires, - MaxAge = options.MaxAge, - Secure = options.Secure, - SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite, - HttpOnly = options.HttpOnly - }; - - return setCookieHeaderValue.ToString(); + return options.CreateCookieHeader(Uri.EscapeDataString(key), Uri.EscapeDataString(value)).ToString(); } private bool CheckPolicyRequired() diff --git a/src/Security/CookiePolicy/test/CookieChunkingTests.cs b/src/Security/CookiePolicy/test/CookieChunkingTests.cs index 21746f11acee4c4ea0c57d4ec9facf9507e68198..c02fef621f88827591b4242994eb4b0eda66bfaa 100644 --- a/src/Security/CookiePolicy/test/CookieChunkingTests.cs +++ b/src/Security/CookiePolicy/test/CookieChunkingTests.cs @@ -61,6 +61,25 @@ public class CookieChunkingTests }, values); } + [Fact] + public void AppendLargeCookieWithExtensions_Chunked() + { + HttpContext context = new DefaultHttpContext(); + + string testString = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + new ChunkingCookieManager() { ChunkSize = 63 }.AppendResponseCookie(context, "TestCookie", testString, + new CookieOptions() { Extensions = { "simple", "key=value" } }); + var values = context.Response.Headers["Set-Cookie"]; + Assert.Equal(4, values.Count); + Assert.Equal<string[]>(new[] + { + "TestCookie=chunks-3; path=/; simple; key=value", + "TestCookieC1=abcdefghijklmnopqrstuv; path=/; simple; key=value", + "TestCookieC2=wxyz0123456789ABCDEFGH; path=/; simple; key=value", + "TestCookieC3=IJKLMNOPQRSTUVWXYZ; path=/; simple; key=value", + }, values); + } + [Fact] public void GetLargeChunkedCookie_Reassembled() { @@ -129,19 +148,19 @@ public class CookieChunkingTests HttpContext context = new DefaultHttpContext(); context.Request.Headers.Append("Cookie", "TestCookie=chunks-7;TestCookieC1=1;TestCookieC2=2;TestCookieC3=3;TestCookieC4=4;TestCookieC5=5;TestCookieC6=6;TestCookieC7=7"); - new ChunkingCookieManager().DeleteCookie(context, "TestCookie", new CookieOptions() { Domain = "foo.com", Secure = true }); + new ChunkingCookieManager().DeleteCookie(context, "TestCookie", new CookieOptions() { Domain = "foo.com", Secure = true, Extensions = { "extension" } }); var cookies = context.Response.Headers["Set-Cookie"]; Assert.Equal(8, cookies.Count); Assert.Equal(new[] { - "TestCookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC4=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC5=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC6=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC7=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", + "TestCookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC4=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC5=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC6=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC7=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", }, cookies); } @@ -202,12 +221,13 @@ public class CookieChunkingTests { Domain = "foo.com", Path = "/", - Secure = true + Secure = true, + Extensions = { "extension" } }; httpContext.Response.Headers[HeaderNames.SetCookie] = new[] { - "TestCookie=chunks-7; domain=foo.com; path=/; secure", + "TestCookie=chunks-7; domain=foo.com; path=/; secure; other=extension", "TestCookieC1=STUVWXYZ; domain=foo.com; path=/; secure", "TestCookieC2=123456789; domain=foo.com; path=/; secure", "TestCookieC3=stuvwxyz0; domain=foo.com; path=/; secure", @@ -221,14 +241,14 @@ public class CookieChunkingTests Assert.Equal(8, httpContext.Response.Headers[HeaderNames.SetCookie].Count); Assert.Equal(new[] { - "TestCookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC4=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC5=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC6=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure", - "TestCookieC7=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure" + "TestCookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC4=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC5=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC6=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension", + "TestCookieC7=; expires=Thu, 01 Jan 1970 00:00:00 GMT; domain=foo.com; path=/; secure; extension" }, httpContext.Response.Headers[HeaderNames.SetCookie]); } } diff --git a/src/Security/CookiePolicy/test/CookieConsentTests.cs b/src/Security/CookiePolicy/test/CookieConsentTests.cs index e217f18da91061b2e3a159c6d41b2f735513949e..73bfd004860967a0dd9ea86cc74a35d20ac00064 100644 --- a/src/Security/CookiePolicy/test/CookieConsentTests.cs +++ b/src/Security/CookiePolicy/test/CookieConsentTests.cs @@ -244,6 +244,7 @@ public class CookieConsentTests Assert.Equal(Http.SameSiteMode.Strict, context.CookieOptions.SameSite); context.CookieName += "1"; context.CookieValue += "1"; + context.CookieOptions.Extensions.Add("extension"); }; }, requestContext => { }, @@ -270,6 +271,7 @@ public class CookieConsentTests Assert.Equal("yes1", consentCookie.Value); Assert.Equal(Net.Http.Headers.SameSiteMode.Strict, consentCookie.SameSite); Assert.NotNull(consentCookie.Expires); + Assert.Contains("extension", consentCookie.Extensions); } [Fact] diff --git a/src/Security/CookiePolicy/test/CookiePolicyTests.cs b/src/Security/CookiePolicy/test/CookiePolicyTests.cs index a78b7b28185dfdcd470f04f15b41ee6f4eae4b46..bdee31a542688bed0dde3c078b37fb61a450fbf2 100644 --- a/src/Security/CookiePolicy/test/CookiePolicyTests.cs +++ b/src/Security/CookiePolicy/test/CookiePolicyTests.cs @@ -367,6 +367,7 @@ public class CookiePolicyTests { HttpOnly = HttpOnlyPolicy.Always, Secure = CookieSecurePolicy.Always, + OnAppendCookie = c => c.CookieOptions.Extensions.Add("extension") }); app.UseAuthentication(); app.Run(context => @@ -401,6 +402,7 @@ public class CookiePolicyTests Assert.True(cookie.HttpOnly); Assert.True(cookie.Secure); Assert.Equal("/", cookie.Path); + Assert.Contains("extension", cookie.Extensions); } [Fact] @@ -416,6 +418,7 @@ public class CookiePolicyTests { HttpOnly = HttpOnlyPolicy.Always, Secure = CookieSecurePolicy.Always, + OnAppendCookie = c => c.CookieOptions.Extensions.Add("ext") }); app.UseAuthentication(); app.Run(context => @@ -452,18 +455,21 @@ public class CookiePolicyTests Assert.True(cookie.HttpOnly); Assert.True(cookie.Secure); Assert.Equal("/", cookie.Path); + Assert.Contains("ext", cookie.Extensions); cookie = SetCookieHeaderValue.Parse(transaction.SetCookie[1]); Assert.Equal("TestCookieC1", cookie.Name); Assert.True(cookie.HttpOnly); Assert.True(cookie.Secure); Assert.Equal("/", cookie.Path); + Assert.Contains("ext", cookie.Extensions); cookie = SetCookieHeaderValue.Parse(transaction.SetCookie[2]); Assert.Equal("TestCookieC2", cookie.Name); Assert.True(cookie.HttpOnly); Assert.True(cookie.Secure); Assert.Equal("/", cookie.Path); + Assert.Contains("ext", cookie.Extensions); } private class TestCookieFeature : IResponseCookiesFeature diff --git a/src/Shared/ChunkingCookieManager/ChunkingCookieManager.cs b/src/Shared/ChunkingCookieManager/ChunkingCookieManager.cs index 6493c526eb660429d90db29fa192da49cb53fda5..20dd051c46f93b58a18b07ff68c06571c618d60c 100644 --- a/src/Shared/ChunkingCookieManager/ChunkingCookieManager.cs +++ b/src/Shared/ChunkingCookieManager/ChunkingCookieManager.cs @@ -173,18 +173,7 @@ internal sealed class ChunkingCookieManager return; } - var template = new SetCookieHeaderValue(key) - { - Domain = options.Domain, - Expires = options.Expires, - SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite, - HttpOnly = options.HttpOnly, - Path = options.Path, - Secure = options.Secure, - MaxAge = options.MaxAge, - }; - - var templateLength = template.ToString().Length; + var templateLength = options.CreateCookieHeader(key, string.Empty).ToString().Length; // Normal cookie if (!ChunkSize.HasValue || ChunkSize.Value > templateLength + value.Length) @@ -324,15 +313,9 @@ internal sealed class ChunkingCookieManager keyValuePairs[i] = KeyValuePair.Create(string.Concat(key, "C", i.ToString(CultureInfo.InvariantCulture)), string.Empty); } - responseCookies.Append(keyValuePairs, new CookieOptions() + responseCookies.Append(keyValuePairs, new CookieOptions(options) { - Path = options.Path, - Domain = options.Domain, - SameSite = options.SameSite, - Secure = options.Secure, - IsEssential = options.IsEssential, Expires = DateTimeOffset.UnixEpoch, - HttpOnly = options.HttpOnly, }); } }