diff --git a/src/Http/Headers/src/PublicAPI.Unshipped.txt b/src/Http/Headers/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..cb25301411fe 100644 --- a/src/Http/Headers/src/PublicAPI.Unshipped.txt +++ b/src/Http/Headers/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Net.Http.Headers.SetCookieHeaderValue.Partitioned.get -> bool +Microsoft.Net.Http.Headers.SetCookieHeaderValue.Partitioned.set -> void diff --git a/src/Http/Headers/src/SetCookieHeaderValue.cs b/src/Http/Headers/src/SetCookieHeaderValue.cs index 359e6014dae2..19e6e62a768c 100644 --- a/src/Http/Headers/src/SetCookieHeaderValue.cs +++ b/src/Http/Headers/src/SetCookieHeaderValue.cs @@ -30,6 +30,7 @@ public class SetCookieHeaderValue private static readonly string SameSiteStrictToken = SameSiteMode.Strict.ToString().ToLowerInvariant(); private const string HttpOnlyToken = "httponly"; + private const string PartitionedToken = "partitioned"; private const string SeparatorToken = "; "; private const string EqualsToken = "="; private const int ExpiresDateLength = 29; @@ -176,6 +177,16 @@ public StringSegment Value /// See . public bool HttpOnly { get; set; } + /// + /// Gets or sets a value for the Partitioned cookie attribute. + /// + /// Partitioned instructs the user agent to + /// omit the cookie when providing access to cookies on a different top-level site + /// as part of CHIPS (Cookies Having Independent Partitioned State). + /// + /// + public bool Partitioned { get; set; } + /// /// Gets a collection of additional values to append to the cookie. /// @@ -241,6 +252,11 @@ public override string ToString() length += SeparatorToken.Length + HttpOnlyToken.Length; } + if (Partitioned) + { + length += SeparatorToken.Length + PartitionedToken.Length; + } + if (_extensions?.Count > 0) { foreach (var extension in _extensions) @@ -299,6 +315,11 @@ public override string ToString() AppendSegment(ref span, HttpOnlyToken, null); } + if (headerValue.Partitioned) + { + AppendSegment(ref span, PartitionedToken, null); + } + if (_extensions?.Count > 0) { foreach (var extension in _extensions) @@ -384,6 +405,11 @@ public void AppendToStringBuilder(StringBuilder builder) AppendSegment(builder, HttpOnlyToken, null); } + if (Partitioned) + { + AppendSegment(builder, PartitionedToken, null); + } + if (_extensions?.Count > 0) { foreach (var extension in _extensions) @@ -654,6 +680,11 @@ private static int GetSetCookieLength(StringSegment input, int startIndex, out S { result.HttpOnly = true; } + // partitioned-av = "Partitioned" + else if (StringSegment.Equals(token, PartitionedToken, StringComparison.OrdinalIgnoreCase)) + { + result.Partitioned = true; + } // extension-av = else { @@ -729,6 +760,7 @@ public override bool Equals(object? obj) && Secure == other.Secure && SameSite == other.SameSite && HttpOnly == other.HttpOnly + && Partitioned == other.Partitioned && HeaderUtilities.AreEqualCollections(_extensions, other._extensions, StringSegmentComparer.OrdinalIgnoreCase); } @@ -743,7 +775,8 @@ public override int GetHashCode() ^ (Path != null ? StringSegmentComparer.OrdinalIgnoreCase.GetHashCode(Path) : 0) ^ Secure.GetHashCode() ^ SameSite.GetHashCode() - ^ HttpOnly.GetHashCode(); + ^ HttpOnly.GetHashCode() + ^ Partitioned.GetHashCode(); if (_extensions?.Count > 0) { diff --git a/src/Http/Http.Abstractions/src/CookieBuilder.cs b/src/Http/Http.Abstractions/src/CookieBuilder.cs index f846008d5444..85335ed219fb 100644 --- a/src/Http/Http.Abstractions/src/CookieBuilder.cs +++ b/src/Http/Http.Abstractions/src/CookieBuilder.cs @@ -49,6 +49,15 @@ public virtual string? Name /// public virtual bool HttpOnly { get; set; } + /// + /// Gets or sets a value that indicates whether a cookie is partitioned across different sites. + /// Opts in to CHIPS (Cookies Having Independent Partitioned State). + /// + /// + /// Determines the value that will be set on . + /// + public virtual bool Partitioned { get; set; } + /// /// The SameSite attribute of the cookie. The default value is /// but specific components may use a different value. @@ -111,6 +120,7 @@ public virtual CookieOptions Build(HttpContext context, DateTimeOffset expiresFr Path = Path ?? "/", SameSite = SameSite, HttpOnly = HttpOnly, + Partitioned = Partitioned, MaxAge = MaxAge, Domain = Domain, IsEssential = IsEssential, diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index b08a98e0390c..57e483ecb3bf 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,3 +4,5 @@ Microsoft.AspNetCore.Http.HostString.HostString(string? value) -> void *REMOVED*Microsoft.AspNetCore.Http.HostString.Value.get -> string! Microsoft.AspNetCore.Http.HostString.Value.get -> string? Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IEnumerable>! errors) -> void +virtual Microsoft.AspNetCore.Http.CookieBuilder.Partitioned.get -> bool +virtual Microsoft.AspNetCore.Http.CookieBuilder.Partitioned.set -> void diff --git a/src/Http/Http.Features/src/CookieOptions.cs b/src/Http/Http.Features/src/CookieOptions.cs index e652276eee5f..4cff5a0f074e 100644 --- a/src/Http/Http.Features/src/CookieOptions.cs +++ b/src/Http/Http.Features/src/CookieOptions.cs @@ -40,6 +40,7 @@ public CookieOptions(CookieOptions options) Secure = options.Secure; SameSite = options.SameSite; HttpOnly = options.HttpOnly; + Partitioned = options.Partitioned; MaxAge = options.MaxAge; IsEssential = options.IsEssential; @@ -85,6 +86,13 @@ public CookieOptions(CookieOptions options) /// true if a cookie must not be accessible by client-side script; otherwise, false. public bool HttpOnly { get; set; } + /// + /// Gets or sets a value that indicates whether a cookie is partitioned across different sites. + /// Opts in to CHIPS (Cookies Having Independent Partitioned State). + /// + /// true if a cookie is partitioned; otherwise, false. + public bool Partitioned { get; set; } + /// /// Gets or sets the max-age for the cookie. /// @@ -117,6 +125,7 @@ public SetCookieHeaderValue CreateCookieHeader(string name, string value) Expires = Expires, Secure = Secure, HttpOnly = HttpOnly, + Partitioned = Partitioned, MaxAge = MaxAge, SameSite = (Net.Http.Headers.SameSiteMode)SameSite, }; diff --git a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..5a814dd666a5 100644 --- a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Http.CookieOptions.Partitioned.get -> bool +Microsoft.AspNetCore.Http.CookieOptions.Partitioned.set -> void diff --git a/src/Http/Http/src/Internal/ResponseCookies.cs b/src/Http/Http/src/Internal/ResponseCookies.cs index 6b55d15c4a8c..7d739c476562 100644 --- a/src/Http/Http/src/Internal/ResponseCookies.cs +++ b/src/Http/Http/src/Internal/ResponseCookies.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,7 +16,9 @@ namespace Microsoft.AspNetCore.Http; internal sealed partial class ResponseCookies : IResponseCookies { private readonly IFeatureCollection _features; + private ILogger? _logger; + private bool _retrievedLogger; /// /// Create a new wrapper. @@ -45,19 +48,10 @@ public void Append(string key, string value, CookieOptions options) { ArgumentNullException.ThrowIfNull(options); - // SameSite=None cookies must be marked as Secure. - if (!options.Secure && options.SameSite == SameSiteMode.None) + var messagesToLog = GetMessagesToLog(options); + if (messagesToLog != MessagesToLog.None && TryGetLogger(out var logger)) { - if (_logger == null) - { - var services = _features.Get()?.RequestServices; - _logger = services?.GetService>(); - } - - if (_logger != null) - { - Log.SameSiteCookieNotSecure(_logger, key); - } + LogMessages(logger, messagesToLog, key); } var cookie = options.CreateCookieHeader(key, Uri.EscapeDataString(value)).ToString(); @@ -69,21 +63,12 @@ public void Append(ReadOnlySpan> keyValuePairs, Coo { ArgumentNullException.ThrowIfNull(options); - // SameSite=None cookies must be marked as Secure. - if (!options.Secure && options.SameSite == SameSiteMode.None) + var messagesToLog = GetMessagesToLog(options); + if (messagesToLog != MessagesToLog.None && TryGetLogger(out var logger)) { - if (_logger == null) + foreach (var keyValuePair in keyValuePairs) { - var services = _features.Get()?.RequestServices; - _logger = services?.GetService>(); - } - - if (_logger != null) - { - foreach (var keyValuePair in keyValuePairs) - { - Log.SameSiteCookieNotSecure(_logger, keyValuePair.Key); - } + LogMessages(logger, messagesToLog, keyValuePair.Key); } } @@ -167,9 +152,95 @@ public void Delete(string key, CookieOptions options) }); } + private bool TryGetLogger([NotNullWhen(true)] out ILogger? logger) + { + if (!_retrievedLogger) + { + _retrievedLogger = true; + var services = _features.Get()?.RequestServices; + _logger = services?.GetService>(); + } + + logger = _logger; + return logger is not null; + } + + private static MessagesToLog GetMessagesToLog(CookieOptions options) + { + var toLog = MessagesToLog.None; + + if (!options.Secure && options.SameSite == SameSiteMode.None) + { + toLog |= MessagesToLog.SameSiteNotSecure; + } + + if (options.Partitioned) + { + if (!options.Secure) + { + toLog |= MessagesToLog.PartitionedNotSecure; + } + + if (options.SameSite != SameSiteMode.None) + { + toLog |= MessagesToLog.PartitionedNotSameSiteNone; + } + + // Chromium checks this + if (options.Path != "/") + { + toLog |= MessagesToLog.PartitionedNotPathRoot; + } + } + + return toLog; + } + + private static void LogMessages(ILogger logger, MessagesToLog messages, string cookieName) + { + if ((messages & MessagesToLog.SameSiteNotSecure) != 0) + { + Log.SameSiteCookieNotSecure(logger, cookieName); + } + + if ((messages & MessagesToLog.PartitionedNotSecure) != 0) + { + Log.PartitionedCookieNotSecure(logger, cookieName); + } + + if ((messages & MessagesToLog.PartitionedNotSameSiteNone) != 0) + { + Log.PartitionedCookieNotSameSiteNone(logger, cookieName); + } + + if ((messages & MessagesToLog.PartitionedNotPathRoot) != 0) + { + Log.PartitionedCookieNotPathRoot(logger, cookieName); + } + } + + [Flags] + private enum MessagesToLog + { + None, + SameSiteNotSecure = 1 << 0, + PartitionedNotSecure = 1 << 1, + PartitionedNotSameSiteNone = 1 << 2, + PartitionedNotPathRoot = 1 << 3, + } + private static partial class Log { - [LoggerMessage(1, LogLevel.Warning, "The cookie '{name}' has set 'SameSite=None' and must also set 'Secure'.", EventName = "SameSiteNotSecure")] + [LoggerMessage(1, LogLevel.Warning, "The cookie '{name}' has set 'SameSite=None' and must also set 'Secure'. This cookie will likely be rejected by the client.", EventName = "SameSiteNotSecure")] public static partial void SameSiteCookieNotSecure(ILogger logger, string name); + + [LoggerMessage(2, LogLevel.Warning, "The cookie '{name}' has set 'Partitioned' and must also set 'Secure'. This cookie will likely be rejected by the client.", EventName = "PartitionedNotSecure")] + public static partial void PartitionedCookieNotSecure(ILogger logger, string name); + + [LoggerMessage(3, LogLevel.Debug, "The cookie '{name}' has set 'Partitioned' and should also set 'SameSite=None'. This cookie will likely be rejected by the client.", EventName = "PartitionedNotSameSiteNone")] + public static partial void PartitionedCookieNotSameSiteNone(ILogger logger, string name); + + [LoggerMessage(4, LogLevel.Debug, "The cookie '{name}' has set 'Partitioned' and should also set 'Path=/'. This cookie may be rejected by the client.", EventName = "PartitionedNotPathRoot")] + public static partial void PartitionedCookieNotPathRoot(ILogger logger, string name); } } diff --git a/src/Http/Http/test/CookieOptionsTests.cs b/src/Http/Http/test/CookieOptionsTests.cs index 59c9db46fef0..83e6ec0b0fa0 100644 --- a/src/Http/Http/test/CookieOptionsTests.cs +++ b/src/Http/Http/test/CookieOptionsTests.cs @@ -23,6 +23,7 @@ public void CopyCtor_AllPropertiesCopied() HttpOnly = true, IsEssential = true, MaxAge = TimeSpan.FromSeconds(10), + Partitioned = true, Path = "/foo", Secure = true, SameSite = SameSiteMode.Strict, @@ -40,6 +41,7 @@ public void CopyCtor_AllPropertiesCopied() case "HttpOnly": case "IsEssential": case "MaxAge": + case "Partitioned": case "Path": case "Secure": case "SameSite": diff --git a/src/Http/Http/test/ResponseCookiesTest.cs b/src/Http/Http/test/ResponseCookiesTest.cs index de5232eace68..a473d6787900 100644 --- a/src/Http/Http/test/ResponseCookiesTest.cs +++ b/src/Http/Http/test/ResponseCookiesTest.cs @@ -51,7 +51,176 @@ public void AppendSameSiteNoneWithoutSecureLogsWarning() Assert.DoesNotContain("secure", cookieHeaderValues[0]); var writeContext = Assert.Single(sink.Writes); - Assert.Equal("The cookie 'TestCookie' has set 'SameSite=None' and must also set 'Secure'.", writeContext.Message); + Assert.Equal("The cookie 'TestCookie' has set 'SameSite=None' and must also set 'Secure'. This cookie will likely be rejected by the client.", writeContext.Message); + } + + [Fact] + public void AppendSameSiteNoneWithoutSecureLogsWarningForEachCookie() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var services = new ServiceCollection(); + + var sink = new TestSink(TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + services.AddLogging(); + services.AddSingleton(loggerFactory); + + features.Set(new ServiceProvidersFeature() { RequestServices = services.BuildServiceProvider() }); + + var cookies = new ResponseCookies(features); + var testCookie1 = "TestCookie1"; + var testCookie2 = "TestCookie2"; + + var cookieDict = new[] + { + new KeyValuePair(testCookie1, "value1"), + new KeyValuePair(testCookie2, "value2"), + }; + + cookies.Append(cookieDict, new CookieOptions() + { + SameSite = SameSiteMode.None, + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.All(headers.SetCookie, cookieHeaderValue => + { + Assert.Contains("path=/", cookieHeaderValue); + Assert.Contains("samesite=none", cookieHeaderValue); + Assert.DoesNotContain("secure", cookieHeaderValue); + }); + + Assert.Collection(sink.Writes, + [ + entry => Assert.Equal($"The cookie '{testCookie1}' has set 'SameSite=None' and must also set 'Secure'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie2}' has set 'SameSite=None' and must also set 'Secure'. This cookie will likely be rejected by the client.", entry.Message), + ]); + } + + [Fact] + public void AppendPartitionedLogsWarnings() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var services = new ServiceCollection(); + + var sink = new TestSink(TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + services.AddLogging(); + services.AddSingleton(loggerFactory); + + features.Set(new ServiceProvidersFeature() { RequestServices = services.BuildServiceProvider() }); + + var cookies = new ResponseCookies(features); + var testCookie = "TestCookie"; + + cookies.Append(testCookie, "value", new CookieOptions() + { + Partitioned = true, + // Missing SameSite = SameSiteMode.None, + // Missing Secure = true, + Path = "/a", // Should be Path = "/", + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.Single(cookieHeaderValues); + Assert.Contains("partitioned", cookieHeaderValues[0]); + Assert.DoesNotContain("secure", cookieHeaderValues[0]); + Assert.DoesNotContain("samesite", cookieHeaderValues[0]); + + Assert.Collection(sink.Writes, + [ + entry => Assert.Equal($"The cookie '{testCookie}' has set 'Partitioned' and must also set 'Secure'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie}' has set 'Partitioned' and should also set 'SameSite=None'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie}' has set 'Partitioned' and should also set 'Path=/'. This cookie may be rejected by the client.", entry.Message), + ]); + } + + [Fact] + public void AppendPartitionedLogsWarningsForEachCookie() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var services = new ServiceCollection(); + + var sink = new TestSink(TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + services.AddLogging(); + services.AddSingleton(loggerFactory); + + features.Set(new ServiceProvidersFeature() { RequestServices = services.BuildServiceProvider() }); + + var cookies = new ResponseCookies(features); + var testCookie1 = "TestCookie1"; + var testCookie2 = "TestCookie2"; + + var cookieDict = new[] + { + new KeyValuePair(testCookie1, "value1"), + new KeyValuePair(testCookie2, "value2"), + }; + + cookies.Append(cookieDict, new CookieOptions() + { + Partitioned = true, + // Missing SameSite = SameSiteMode.None, + // Missing Secure = true, + Path = "/a", // Should be Path = "/", + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.All(headers.SetCookie, cookieHeaderValue => + { + Assert.Contains("partitioned", cookieHeaderValue); + Assert.DoesNotContain("secure", cookieHeaderValue); + Assert.DoesNotContain("samesite", cookieHeaderValue); + }); + + Assert.Collection(sink.Writes, + [ + entry => Assert.Equal($"The cookie '{testCookie1}' has set 'Partitioned' and must also set 'Secure'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie1}' has set 'Partitioned' and should also set 'SameSite=None'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie1}' has set 'Partitioned' and should also set 'Path=/'. This cookie may be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie2}' has set 'Partitioned' and must also set 'Secure'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie2}' has set 'Partitioned' and should also set 'SameSite=None'. This cookie will likely be rejected by the client.", entry.Message), + entry => Assert.Equal($"The cookie '{testCookie2}' has set 'Partitioned' and should also set 'Path=/'. This cookie may be rejected by the client.", entry.Message), + ]); + } + + [Fact] + public void AppendPartitionedCorrectlyDoesNotLog() + { + var headers = (IHeaderDictionary)new HeaderDictionary(); + var features = MakeFeatures(headers); + var services = new ServiceCollection(); + + var sink = new TestSink(TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + services.AddLogging(); + services.AddSingleton(loggerFactory); + + features.Set(new ServiceProvidersFeature() { RequestServices = services.BuildServiceProvider() }); + + var cookies = new ResponseCookies(features); + var testCookie = "TestCookie"; + + cookies.Append(testCookie, "value", new CookieOptions() + { + Partitioned = true, + SameSite = SameSiteMode.None, + Secure = true, + // Path = "/", // implied + }); + + var cookieHeaderValues = headers.SetCookie; + Assert.Single(cookieHeaderValues); + Assert.Contains("partitioned", cookieHeaderValues[0]); + Assert.Contains("secure", cookieHeaderValues[0]); + Assert.Contains("samesite=none", cookieHeaderValues[0]); + Assert.Contains("path=/", cookieHeaderValues[0]); + + Assert.Empty(sink.Writes); } [Fact] diff --git a/src/Security/Authentication/test/CookieTests.cs b/src/Security/Authentication/test/CookieTests.cs index bc9ff451415d..5d2646dc8559 100644 --- a/src/Security/Authentication/test/CookieTests.cs +++ b/src/Security/Authentication/test/CookieTests.cs @@ -363,6 +363,7 @@ public async Task CookieOptionsAlterSetCookieHeader() o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.SameSite = SameSiteMode.None; o.Cookie.HttpOnly = true; + o.Cookie.Partitioned = true; o.Cookie.Extensions.Add("extension0"); o.Cookie.Extensions.Add("extension1=value1"); }, SignInAsAlice, baseAddress: new Uri("http://example.com/base")); @@ -378,6 +379,7 @@ public async Task CookieOptionsAlterSetCookieHeader() Assert.Contains(" secure", setCookie1); Assert.Contains(" samesite=none", setCookie1); Assert.Contains(" httponly", setCookie1); + Assert.Contains(" partitioned", setCookie1); Assert.Contains(" extension0", setCookie1); Assert.Contains(" extension1=value1", setCookie1); @@ -400,6 +402,7 @@ public async Task CookieOptionsAlterSetCookieHeader() Assert.DoesNotContain(" domain=", setCookie2); Assert.DoesNotContain(" secure", setCookie2); Assert.DoesNotContain(" httponly", setCookie2); + Assert.DoesNotContain(" partitioned", setCookie2); Assert.DoesNotContain(" extension", setCookie2); }