Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the Partitioned cookie attribute #55371

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions 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
35 changes: 34 additions & 1 deletion src/Http/Headers/src/SetCookieHeaderValue.cs
Expand Up @@ -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;
Expand Down Expand Up @@ -176,6 +177,16 @@ public StringSegment Value
/// <remarks>See <see href="https://tools.ietf.org/html/rfc6265#section-4.1.2.6"/>.</remarks>
public bool HttpOnly { get; set; }

/// <summary>
/// Gets or sets a value for the <c>Partitioned</c> cookie attribute.
/// <para>
/// 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).
/// </para>
/// </summary>
public bool Partitioned { get; set; }

/// <summary>
/// Gets a collection of additional values to append to the cookie.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = <any CHAR except CTLs or ";">
else
{
Expand Down Expand Up @@ -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);
}

Expand All @@ -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)
{
Expand Down
10 changes: 10 additions & 0 deletions src/Http/Http.Abstractions/src/CookieBuilder.cs
Expand Up @@ -49,6 +49,15 @@ public class CookieBuilder
/// </remarks>
public virtual bool HttpOnly { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether a cookie is partitioned across different sites.
/// Opts in to CHIPS (Cookies Having Independent Partitioned State).
/// </summary>
/// <remarks>
/// Determines the value that will be set on <see cref="CookieOptions.Partitioned"/>.
/// </remarks>
public virtual bool Partitioned { get; set; }

/// <summary>
/// The SameSite attribute of the cookie. The default value is <see cref="SameSiteMode.Unspecified"/>
/// but specific components may use a different value.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Expand Up @@ -3,3 +3,5 @@
Microsoft.AspNetCore.Http.HostString.HostString(string? value) -> void
*REMOVED*Microsoft.AspNetCore.Http.HostString.Value.get -> string!
Microsoft.AspNetCore.Http.HostString.Value.get -> string?
virtual Microsoft.AspNetCore.Http.CookieBuilder.Partitioned.get -> bool
virtual Microsoft.AspNetCore.Http.CookieBuilder.Partitioned.set -> void
9 changes: 9 additions & 0 deletions src/Http/Http.Features/src/CookieOptions.cs
Expand Up @@ -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;

Expand Down Expand Up @@ -85,6 +86,13 @@ public CookieOptions(CookieOptions options)
/// <returns>true if a cookie must not be accessible by client-side script; otherwise, false.</returns>
public bool HttpOnly { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether a cookie is partitioned across different sites.
/// Opts in to CHIPS (Cookies Having Independent Partitioned State).
/// </summary>
/// <returns>true if a cookie is partitioned; otherwise, false.</returns>
public bool Partitioned { get; set; }

/// <summary>
/// Gets or sets the max-age for the cookie.
/// </summary>
Expand Down Expand Up @@ -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,
};
Expand Down
2 changes: 2 additions & 0 deletions 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
123 changes: 97 additions & 26 deletions 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;
Expand All @@ -15,7 +16,9 @@ namespace Microsoft.AspNetCore.Http;
internal sealed partial class ResponseCookies : IResponseCookies
{
private readonly IFeatureCollection _features;

private ILogger? _logger;
private bool _retrievedLogger;

/// <summary>
/// Create a new wrapper.
Expand Down Expand Up @@ -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<IServiceProvidersFeature>()?.RequestServices;
_logger = services?.GetService<ILogger<ResponseCookies>>();
}

if (_logger != null)
{
Log.SameSiteCookieNotSecure(_logger, key);
}
LogMessages(logger, messagesToLog, key);
}

var cookie = options.CreateCookieHeader(key, Uri.EscapeDataString(value)).ToString();
Expand All @@ -69,21 +63,12 @@ public void Append(ReadOnlySpan<KeyValuePair<string, string>> 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<IServiceProvidersFeature>()?.RequestServices;
_logger = services?.GetService<ILogger<ResponseCookies>>();
}

if (_logger != null)
{
foreach (var keyValuePair in keyValuePairs)
{
Log.SameSiteCookieNotSecure(_logger, keyValuePair.Key);
}
LogMessages(logger, messagesToLog, keyValuePair.Key);
}
}

Expand Down Expand Up @@ -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<IServiceProvidersFeature>()?.RequestServices;
_logger = services?.GetService<ILogger<ResponseCookies>>();
}

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;
}
}
amcasey marked this conversation as resolved.
Show resolved Hide resolved

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);
halter73 marked this conversation as resolved.
Show resolved Hide resolved
}

if ((messages & MessagesToLog.PartitionedNotPathRoot) != 0)
{
Log.PartitionedCookieNotPathRoot(logger, cookieName);
}
}

[Flags]
private enum MessagesToLog
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it matters here, as the enum isn't embedded in a type.

Suggested change
private enum MessagesToLog
private enum MessagesToLog : byte

to make the enum smaller (1 byte vs. 4 bytes for int (default)).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I considered that, but decided against it for two reasons. First, I'm expecting there to be more flags in the future. I've already added one since the PR began. 😆 Second, I figured space saving was probably less valuable than simplified alignment on the stack (though I didn't measure).

{
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);
}
}
2 changes: 2 additions & 0 deletions src/Http/Http/test/CookieOptionsTests.cs
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ public void CopyCtor_AllPropertiesCopied()
case "HttpOnly":
case "IsEssential":
case "MaxAge":
case "Partitioned":
amcasey marked this conversation as resolved.
Show resolved Hide resolved
case "Path":
case "Secure":
case "SameSite":
Expand Down