Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 4 additions & 17 deletions src/Http/Http/src/Features/ResponseCookiesFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class ResponseCookiesFeature : IResponseCookiesFeature
// Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624
private readonly static Func<IFeatureCollection, IHttpResponseFeature?> _nullResponseFeature = f => null;

private FeatureReferences<IHttpResponseFeature> _features;
private readonly IFeatureCollection _features;
private IResponseCookies? _cookiesCollection;

/// <summary>
Expand All @@ -27,12 +27,7 @@ public class ResponseCookiesFeature : IResponseCookiesFeature
/// </param>
public ResponseCookiesFeature(IFeatureCollection features)
{
if (features == null)
{
throw new ArgumentNullException(nameof(features));
}

_features.Initalize(features);
_features = features ?? throw new ArgumentNullException(nameof(features));
}

/// <summary>
Expand All @@ -46,25 +41,17 @@ public ResponseCookiesFeature(IFeatureCollection features)
[Obsolete("This constructor is obsolete and will be removed in a future version.")]
public ResponseCookiesFeature(IFeatureCollection features, ObjectPool<StringBuilder>? builderPool)
{
if (features == null)
{
throw new ArgumentNullException(nameof(features));
}

_features.Initalize(features);
_features = features ?? throw new ArgumentNullException(nameof(features));
}

private IHttpResponseFeature HttpResponseFeature => _features.Fetch(ref _features.Cache, _nullResponseFeature)!;

/// <inheritdoc />
public IResponseCookies Cookies
{
get
{
if (_cookiesCollection == null)
{
var headers = HttpResponseFeature.Headers;
_cookiesCollection = new ResponseCookies(headers);
_cookiesCollection = new ResponseCookies(_features);
}

return _cookiesCollection;
Expand Down
12 changes: 12 additions & 0 deletions src/Http/Http/src/Internal/EventIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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 Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Http
{
internal static class EventIds
{
public static readonly EventId SameSiteNotSecure = new EventId(1, "SameSiteNotSecure");
}
}
45 changes: 37 additions & 8 deletions src/Http/Http/src/Internal/ResponseCookies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

Expand All @@ -16,18 +19,16 @@ internal class ResponseCookies : IResponseCookies
internal const string EnableCookieNameEncoding = "Microsoft.AspNetCore.Http.EnableCookieNameEncoding";
internal bool _enableCookieNameEncoding = AppContext.TryGetSwitch(EnableCookieNameEncoding, out var enabled) && enabled;

private readonly IFeatureCollection _features;
private ILogger? _logger;

/// <summary>
/// Create a new wrapper.
/// </summary>
/// <param name="headers">The <see cref="IHeaderDictionary"/> for the response.</param>
public ResponseCookies(IHeaderDictionary headers)
internal ResponseCookies(IFeatureCollection features)
{
if (headers == null)
{
throw new ArgumentNullException(nameof(headers));
}

Headers = headers;
_features = features;
Headers = _features.Get<IHttpResponseFeature>().Headers;
}

private IHeaderDictionary Headers { get; set; }
Expand All @@ -54,6 +55,21 @@ public void Append(string key, string value, CookieOptions options)
throw new ArgumentNullException(nameof(options));
}

// SameSite=None cookies must be marked as Secure.
if (!options.Secure && options.SameSite == SameSiteMode.None)
{
if (_logger == null)
{
var services = _features.Get<Features.IServiceProvidersFeature>()?.RequestServices;
_logger = services?.GetService<ILogger<ResponseCookies>>();
}

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

var setCookieHeaderValue = new SetCookieHeaderValue(
_enableCookieNameEncoding ? Uri.EscapeDataString(key) : key,
Uri.EscapeDataString(value))
Expand Down Expand Up @@ -135,5 +151,18 @@ public void Delete(string key, CookieOptions options)
SameSite = options.SameSite
});
}

private static class Log
{
private static readonly Action<ILogger, string, Exception?> _samesiteNotSecure = LoggerMessage.Define<string>(
LogLevel.Warning,
EventIds.SameSiteNotSecure,
"The cookie '{name}' has set 'SameSite=None' and must also set 'Secure'.");

public static void SameSiteCookieNotSecure(ILogger logger, string name)
{
_samesiteNotSecure(logger, name, null);
}
}
}
}
69 changes: 62 additions & 7 deletions src/Http/Http/test/ResponseCookiesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,67 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Net.Http.Headers;
using Xunit;

namespace Microsoft.AspNetCore.Http.Tests
{
public class ResponseCookiesTest
{
private IFeatureCollection MakeFeatures(IHeaderDictionary headers)
{
var responseFeature = new HttpResponseFeature()
{
Headers = headers
};
var features = new FeatureCollection();
features.Set<IHttpResponseFeature>(responseFeature);
return features;
}

[Fact]
public void AppendSameSiteNoneWithoutSecureLogsWarning()
{
var headers = new HeaderDictionary();
var features = MakeFeatures(headers);
var services = new ServiceCollection();

var sink = new TestSink(TestSink.EnableWithTypeName<ResponseCookies>);
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
services.AddLogging();
services.AddSingleton<ILoggerFactory>(loggerFactory);

features.Set<IServiceProvidersFeature>(new ServiceProvidersFeature() { RequestServices = services.BuildServiceProvider() });

var cookies = new ResponseCookies(features);
var testCookie = "TestCookie";

cookies.Append(testCookie, "value", new CookieOptions()
{
SameSite = SameSiteMode.None,
});

var cookieHeaderValues = headers[HeaderNames.SetCookie];
Assert.Single(cookieHeaderValues);
Assert.StartsWith(testCookie, cookieHeaderValues[0]);
Assert.Contains("path=/", cookieHeaderValues[0]);
Assert.Contains("samesite=none", cookieHeaderValues[0]);
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);
}

[Fact]
public void DeleteCookieShouldSetDefaultPath()
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);
var testCookie = "TestCookie";

cookies.Delete(testCookie);
Expand All @@ -29,7 +78,8 @@ public void DeleteCookieShouldSetDefaultPath()
public void DeleteCookieWithCookieOptionsShouldKeepPropertiesOfCookieOptions()
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);
var testCookie = "TestCookie";
var time = new DateTimeOffset(2000, 1, 1, 1, 1, 1, 1, TimeSpan.Zero);
var options = new CookieOptions
Expand Down Expand Up @@ -58,7 +108,8 @@ public void DeleteCookieWithCookieOptionsShouldKeepPropertiesOfCookieOptions()
public void NoParamsDeleteRemovesCookieCreatedByAdd()
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);
var testCookie = "TestCookie";

cookies.Append(testCookie, testCookie);
Expand All @@ -75,7 +126,8 @@ public void NoParamsDeleteRemovesCookieCreatedByAdd()
public void ProvidesMaxAgeWithCookieOptionsArgumentExpectMaxAgeToBeSet()
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);
var cookieOptions = new CookieOptions();
var maxAgeTime = TimeSpan.FromHours(1);
cookieOptions.MaxAge = TimeSpan.FromHours(1);
Expand All @@ -96,7 +148,8 @@ public void ProvidesMaxAgeWithCookieOptionsArgumentExpectMaxAgeToBeSet()
public void EscapesValuesBeforeSettingCookie(string value, string expected)
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);

cookies.Append("key", value);

Expand All @@ -111,7 +164,8 @@ public void EscapesValuesBeforeSettingCookie(string value, string expected)
public void InvalidKeysThrow(string key)
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);

Assert.Throws<ArgumentException>(() => cookies.Append(key, "1"));
}
Expand All @@ -124,7 +178,8 @@ public void InvalidKeysThrow(string key)
public void AppContextSwitchEscapesKeysAndValuesBeforeSettingCookie(string key, string value, string expected)
{
var headers = new HeaderDictionary();
var cookies = new ResponseCookies(headers);
var features = MakeFeatures(headers);
var cookies = new ResponseCookies(features);
cookies._enableCookieNameEncoding = true;

cookies.Append(key, value);
Expand Down