From f8b4f4c620e2faaeec262d302df143068c8d0b33 Mon Sep 17 00:00:00 2001 From: "Chris Ross (ASP.NET)" Date: Fri, 17 Nov 2017 15:08:30 -0800 Subject: [PATCH] Add consent to CookiePolicy #1561 --- Security.sln | 21 +- .../CookiePolicySample.csproj | 18 + samples/CookiePolicySample/Program.cs | 26 + .../Properties/launchSettings.json | 27 + samples/CookiePolicySample/Startup.cs | 118 ++++ .../ChunkingCookieManager.cs | 2 + .../CookieAuthenticationOptions.cs | 1 + .../OpenIdConnectOptions.cs | 1 + .../TwitterOptions.cs | 1 + .../RemoteAuthenticationOptions.cs | 1 + .../AppendCookieContext.cs | 3 + .../CookiePolicyMiddleware.cs | 151 +---- .../CookiePolicyOptions.cs | 12 + .../DeleteCookieContext.cs | 3 + .../ResponseCookiesWrapper.cs | 220 +++++++ .../CookieConsentTests.cs | 561 ++++++++++++++++++ 16 files changed, 1021 insertions(+), 145 deletions(-) create mode 100644 samples/CookiePolicySample/CookiePolicySample.csproj create mode 100644 samples/CookiePolicySample/Program.cs create mode 100644 samples/CookiePolicySample/Properties/launchSettings.json create mode 100644 samples/CookiePolicySample/Startup.cs create mode 100644 src/Microsoft.AspNetCore.CookiePolicy/ResponseCookiesWrapper.cs create mode 100644 test/Microsoft.AspNetCore.CookiePolicy.Test/CookieConsentTests.cs diff --git a/Security.sln b/Security.sln index f88d8576b3..543b3be264 100644 --- a/Security.sln +++ b/Security.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.10 +VisualStudioVersion = 15.0.27004.2002 MinimumVisualStudioVersion = 15.0.26730.03 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4D2B6A51-2F9F-44F5-8131-EA5CAC053652}" ProjectSection(SolutionItems) = preProject @@ -72,6 +72,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authorization.Policy", "src\Microsoft.AspNetCore.Authorization.Policy\Microsoft.AspNetCore.Authorization.Policy.csproj", "{58194599-F07D-47A3-9DF2-E21A22C5EF9E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CookiePolicySample", "samples\CookiePolicySample\CookiePolicySample.csproj", "{24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -462,6 +464,22 @@ Global {58194599-F07D-47A3-9DF2-E21A22C5EF9E}.Release|x64.Build.0 = Release|Any CPU {58194599-F07D-47A3-9DF2-E21A22C5EF9E}.Release|x86.ActiveCfg = Release|Any CPU {58194599-F07D-47A3-9DF2-E21A22C5EF9E}.Release|x86.Build.0 = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|x64.ActiveCfg = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|x64.Build.0 = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|x86.ActiveCfg = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Debug|x86.Build.0 = Debug|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|Any CPU.Build.0 = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|x64.ActiveCfg = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|x64.Build.0 = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|x86.ActiveCfg = Release|Any CPU + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -491,6 +509,7 @@ Global {3A7AD414-EBDE-4F92-B307-4E8F19B6117E} = {F8C0AA27-F3FB-4286-8E4C-47EF86B539FF} {51563775-C659-4907-9BAF-9995BAB87D01} = {7BF11F3A-60B6-4796-B504-579C67FFBA34} {58194599-F07D-47A3-9DF2-E21A22C5EF9E} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} + {24A28F5D-E5A9-4CA8-B0D2-924A1F8BE14E} = {F8C0AA27-F3FB-4286-8E4C-47EF86B539FF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ABF8089E-43D0-4010-84A7-7A9DCFE49357} diff --git a/samples/CookiePolicySample/CookiePolicySample.csproj b/samples/CookiePolicySample/CookiePolicySample.csproj new file mode 100644 index 0000000000..fb2e7d9172 --- /dev/null +++ b/samples/CookiePolicySample/CookiePolicySample.csproj @@ -0,0 +1,18 @@ + + + + net461;netcoreapp2.1 + + + + + + + + + + + + + + diff --git a/samples/CookiePolicySample/Program.cs b/samples/CookiePolicySample/Program.cs new file mode 100644 index 0000000000..12fc8ff287 --- /dev/null +++ b/samples/CookiePolicySample/Program.cs @@ -0,0 +1,26 @@ +using System.IO; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace CookiePolicySample +{ + public static class Program + { + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .ConfigureLogging(factory => + { + factory.AddConsole(); + factory.AddFilter("Console", level => level >= LogLevel.Information); + }) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/samples/CookiePolicySample/Properties/launchSettings.json b/samples/CookiePolicySample/Properties/launchSettings.json new file mode 100644 index 0000000000..38ca6fc37f --- /dev/null +++ b/samples/CookiePolicySample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:1788/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "CookieSample": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:12345", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/samples/CookiePolicySample/Startup.cs b/samples/CookiePolicySample/Startup.cs new file mode 100644 index 0000000000..7ce9c2d2d2 --- /dev/null +++ b/samples/CookiePolicySample/Startup.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; + +namespace CookiePolicySample +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(); + services.Configure(options => + { + options.CheckConsentNeeded = context => context.Request.PathBase.Equals("/NeedsConsent"); + + options.OnAppendCookie = context => { }; + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseCookiePolicy(); + app.UseAuthentication(); + + app.Map("/NeedsConsent", NestedApp); + app.Map("/NeedsNoConsent", NestedApp); + NestedApp(app); + } + + private void NestedApp(IApplicationBuilder app) + { + app.Run(async context => + { + var path = context.Request.Path; + switch (path) + { + case "/Login": + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "bob") }, + CookieAuthenticationDefaults.AuthenticationScheme)); + await context.SignInAsync(user); + break; + case "/Logout": + await context.SignOutAsync(); + break; + case "/CreateTempCookie": + context.Response.Cookies.Append("Temp", "1"); + break; + case "/RemoveTempCookie": + context.Response.Cookies.Delete("Temp"); + break; + case "/GrantConsent": + context.Features.Get().GrantConsent(); + break; + case "/WithdrawConsent": + context.Features.Get().WithdrawConsent(); + break; + } + + // TODO: Debug log when cookie is suppressed + + await HomePage(context); + }); + } + + private async Task HomePage(HttpContext context) + { + var response = context.Response; + var cookies = context.Request.Cookies; + response.ContentType = "text/html"; + await response.WriteAsync("\r\n"); + + await response.WriteAsync($"Home
\r\n"); + await response.WriteAsync($"Login
\r\n"); + await response.WriteAsync($"Logout
\r\n"); + await response.WriteAsync($"Create Temp Cookie
\r\n"); + await response.WriteAsync($"Remove Temp Cookie
\r\n"); + await response.WriteAsync($"Grant Consent
\r\n"); + await response.WriteAsync($"Withdraw Consent
\r\n"); + await response.WriteAsync("
\r\n"); + await response.WriteAsync($"Needs Consent
\r\n"); + await response.WriteAsync($"Needs No Consent
\r\n"); + await response.WriteAsync("
\r\n"); + + var feature = context.Features.Get(); + await response.WriteAsync($"Consent:
\r\n"); + await response.WriteAsync($" - IsNeeded: {feature.IsConsentNeeded}
\r\n"); + await response.WriteAsync($" - Has: {feature.HasConsent}
\r\n"); + await response.WriteAsync($" - Can Track: {feature.CanTrack}
\r\n"); + await response.WriteAsync("
\r\n"); + + await response.WriteAsync($"{cookies.Count} Request Cookies:
\r\n"); + foreach (var cookie in cookies) + { + await response.WriteAsync($" - {cookie.Key} = {cookie.Value}
\r\n"); + } + await response.WriteAsync("
\r\n"); + + var responseCookies = response.Headers[HeaderNames.SetCookie]; + await response.WriteAsync($"{responseCookies.Count} Response Cookies:
\r\n"); + foreach (var cookie in responseCookies) + { + await response.WriteAsync($" - {cookie}
\r\n"); + } + + await response.WriteAsync(""); + } + } +} diff --git a/shared/Microsoft.AspNetCore.ChunkingCookieManager.Sources/ChunkingCookieManager.cs b/shared/Microsoft.AspNetCore.ChunkingCookieManager.Sources/ChunkingCookieManager.cs index 7217e70d4f..42cc4e2f0f 100644 --- a/shared/Microsoft.AspNetCore.ChunkingCookieManager.Sources/ChunkingCookieManager.cs +++ b/shared/Microsoft.AspNetCore.ChunkingCookieManager.Sources/ChunkingCookieManager.cs @@ -285,6 +285,7 @@ namespace Microsoft.AspNetCore.Internal Path = options.Path, Domain = options.Domain, SameSite = options.SameSite, + IsEssential = options.IsEssential, Expires = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), }); @@ -299,6 +300,7 @@ namespace Microsoft.AspNetCore.Internal Path = options.Path, Domain = options.Domain, SameSite = options.SameSite, + IsEssential = options.IsEssential, Expires = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), }); } diff --git a/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationOptions.cs b/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationOptions.cs index 04c71ed1ef..35017f9c4d 100644 --- a/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationOptions.cs @@ -21,6 +21,7 @@ namespace Microsoft.AspNetCore.Authentication.Cookies SameSite = SameSiteMode.Lax, HttpOnly = true, SecurePolicy = CookieSecurePolicy.SameAsRequest, + IsEssential = true, }; /// diff --git a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs index a40d374356..cbf6e8eab6 100644 --- a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs @@ -74,6 +74,7 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect HttpOnly = true, SameSite = SameSiteMode.None, SecurePolicy = CookieSecurePolicy.SameAsRequest, + IsEssential = true, }; } diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs index 86919d0925..03396807ee 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs @@ -35,6 +35,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter SecurePolicy = CookieSecurePolicy.SameAsRequest, HttpOnly = true, SameSite = SameSiteMode.Lax, + IsEssential = true, }; } diff --git a/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationOptions.cs b/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationOptions.cs index daba1890fb..1bd3b210e5 100644 --- a/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationOptions.cs @@ -29,6 +29,7 @@ namespace Microsoft.AspNetCore.Authentication HttpOnly = true, SameSite = SameSiteMode.None, SecurePolicy = CookieSecurePolicy.SameAsRequest, + IsEssential = true, }; } diff --git a/src/Microsoft.AspNetCore.CookiePolicy/AppendCookieContext.cs b/src/Microsoft.AspNetCore.CookiePolicy/AppendCookieContext.cs index 1b13251f73..bbb4899c04 100644 --- a/src/Microsoft.AspNetCore.CookiePolicy/AppendCookieContext.cs +++ b/src/Microsoft.AspNetCore.CookiePolicy/AppendCookieContext.cs @@ -19,5 +19,8 @@ namespace Microsoft.AspNetCore.CookiePolicy public CookieOptions CookieOptions { get; } public string CookieName { get; set; } public string CookieValue { get; set; } + public bool IsConsentNeeded { get; internal set; } + public bool HasConsent { get; internal set; } + public bool IssueCookie { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyMiddleware.cs b/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyMiddleware.cs index 92adac9677..b99fed2c3d 100644 --- a/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyMiddleware.cs +++ b/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyMiddleware.cs @@ -1,7 +1,6 @@ // 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; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -27,157 +26,21 @@ namespace Microsoft.AspNetCore.CookiePolicy public Task Invoke(HttpContext context) { var feature = context.Features.Get() ?? new ResponseCookiesFeature(context.Features); - context.Features.Set(new CookiesWrapperFeature(context, Options, feature)); + var wrapper = new ResponseCookiesWrapper(context, Options, feature); + context.Features.Set(new CookiesWrapperFeature(wrapper)); + context.Features.Set(wrapper); + return _next(context); } private class CookiesWrapperFeature : IResponseCookiesFeature { - public CookiesWrapperFeature(HttpContext context, CookiePolicyOptions options, IResponseCookiesFeature feature) + public CookiesWrapperFeature(ResponseCookiesWrapper wrapper) { - Wrapper = new CookiesWrapper(context, options, feature); + Cookies = wrapper; } - public IResponseCookies Wrapper { get; } - - public IResponseCookies Cookies - { - get - { - return Wrapper; - } - } - } - - private class CookiesWrapper : IResponseCookies - { - public CookiesWrapper(HttpContext context, CookiePolicyOptions options, IResponseCookiesFeature feature) - { - Context = context; - Feature = feature; - Policy = options; - } - - public HttpContext Context { get; } - - public IResponseCookiesFeature Feature { get; } - - public IResponseCookies Cookies - { - get - { - return Feature.Cookies; - } - } - - public CookiePolicyOptions Policy { get; } - - private bool PolicyRequiresCookieOptions() - { - return Policy.MinimumSameSitePolicy != SameSiteMode.None || Policy.HttpOnly != HttpOnlyPolicy.None || Policy.Secure != CookieSecurePolicy.None; - } - - public void Append(string key, string value) - { - if (PolicyRequiresCookieOptions() || Policy.OnAppendCookie != null) - { - Append(key, value, new CookieOptions()); - } - else - { - Cookies.Append(key, value); - } - } - - public void Append(string key, string value, CookieOptions options) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - ApplyPolicy(options); - if (Policy.OnAppendCookie != null) - { - var context = new AppendCookieContext(Context, options, key, value); - Policy.OnAppendCookie(context); - key = context.CookieName; - value = context.CookieValue; - } - Cookies.Append(key, value, options); - } - - public void Delete(string key) - { - if (PolicyRequiresCookieOptions() || Policy.OnDeleteCookie != null) - { - Delete(key, new CookieOptions()); - } - else - { - Cookies.Delete(key); - } - } - - public void Delete(string key, CookieOptions options) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - ApplyPolicy(options); - if (Policy.OnDeleteCookie != null) - { - var context = new DeleteCookieContext(Context, options, key); - Policy.OnDeleteCookie(context); - key = context.CookieName; - } - Cookies.Delete(key, options); - } - - private void ApplyPolicy(CookieOptions options) - { - switch (Policy.Secure) - { - case CookieSecurePolicy.Always: - options.Secure = true; - break; - case CookieSecurePolicy.SameAsRequest: - options.Secure = Context.Request.IsHttps; - break; - case CookieSecurePolicy.None: - break; - default: - throw new InvalidOperationException(); - } - switch (Policy.MinimumSameSitePolicy) - { - case SameSiteMode.None: - break; - case SameSiteMode.Lax: - if (options.SameSite == SameSiteMode.None) - { - options.SameSite = SameSiteMode.Lax; - } - break; - case SameSiteMode.Strict: - options.SameSite = SameSiteMode.Strict; - break; - default: - throw new InvalidOperationException($"Unrecognized {nameof(SameSiteMode)} value {Policy.MinimumSameSitePolicy.ToString()}"); - } - switch (Policy.HttpOnly) - { - case HttpOnlyPolicy.Always: - options.HttpOnly = true; - break; - case HttpOnlyPolicy.None: - break; - default: - throw new InvalidOperationException($"Unrecognized {nameof(HttpOnlyPolicy)} value {Policy.HttpOnly.ToString()}"); - } - } + public IResponseCookies Cookies { get; } } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyOptions.cs b/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyOptions.cs index 1e474bfe22..cc2deaa3aa 100644 --- a/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyOptions.cs +++ b/src/Microsoft.AspNetCore.CookiePolicy/CookiePolicyOptions.cs @@ -27,6 +27,18 @@ namespace Microsoft.AspNetCore.Builder /// public CookieSecurePolicy Secure { get; set; } = CookieSecurePolicy.None; + public CookieBuilder ConsentCookie { get; set; } = new CookieBuilder() + { + Name = ".AspNet.Consent", + Expiration = TimeSpan.FromDays(90), + IsEssential = true, + }; + + /// + /// Checks if consent policies should be evaluated on this request. The default is false. + /// + public Func CheckConsentNeeded { get; set; } + /// /// Called when a cookie is appended. /// diff --git a/src/Microsoft.AspNetCore.CookiePolicy/DeleteCookieContext.cs b/src/Microsoft.AspNetCore.CookiePolicy/DeleteCookieContext.cs index f0693bf71f..fd79ea8d4b 100644 --- a/src/Microsoft.AspNetCore.CookiePolicy/DeleteCookieContext.cs +++ b/src/Microsoft.AspNetCore.CookiePolicy/DeleteCookieContext.cs @@ -17,5 +17,8 @@ namespace Microsoft.AspNetCore.CookiePolicy public HttpContext Context { get; } public CookieOptions CookieOptions { get; } public string CookieName { get; set; } + public bool IsConsentNeeded { get; internal set; } + public bool HasConsent { get; internal set; } + public bool IssueCookie { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.CookiePolicy/ResponseCookiesWrapper.cs b/src/Microsoft.AspNetCore.CookiePolicy/ResponseCookiesWrapper.cs new file mode 100644 index 0000000000..fa68a3cbea --- /dev/null +++ b/src/Microsoft.AspNetCore.CookiePolicy/ResponseCookiesWrapper.cs @@ -0,0 +1,220 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.AspNetCore.CookiePolicy +{ + internal class ResponseCookiesWrapper : IResponseCookies, ITrackingConsentFeature + { + private const string ConsentValue = "yes"; + + private bool? _isConsentNeeded; + private bool? _hasConsent; + + public ResponseCookiesWrapper(HttpContext context, CookiePolicyOptions options, IResponseCookiesFeature feature) + { + Context = context; + Feature = feature; + Options = options; + } + + private HttpContext Context { get; } + + private IResponseCookiesFeature Feature { get; } + + private IResponseCookies Cookies => Feature.Cookies; + + private CookiePolicyOptions Options { get; } + + public bool IsConsentNeeded + { + get + { + if (!_isConsentNeeded.HasValue) + { + _isConsentNeeded = Options.CheckConsentNeeded == null ? false + : Options.CheckConsentNeeded(Context); + } + + return _isConsentNeeded.Value; + } + } + + public bool HasConsent + { + get + { + if (!_hasConsent.HasValue) + { + var cookie = Context.Request.Cookies[Options.ConsentCookie.Name]; + _hasConsent = string.Equals(cookie, ConsentValue, StringComparison.Ordinal); + } + + return _hasConsent.Value; + } + } + + public bool CanTrack => !IsConsentNeeded || HasConsent; + + public void GrantConsent() + { + if (!HasConsent && !Context.Response.HasStarted) + { + var cookieOptions = Options.ConsentCookie.Build(Context); + // Note policy will be applied. We don't want to bypass policy because we want HttpOnly, Secure, etc. to apply. + Append(Options.ConsentCookie.Name, ConsentValue, cookieOptions); + } + _hasConsent = true; + } + + public void WithdrawConsent() + { + if (HasConsent && !Context.Response.HasStarted) + { + var cookieOptions = Options.ConsentCookie.Build(Context); + // Note policy will be applied. We don't want to bypass policy because we want HttpOnly, Secure, etc. to apply. + Delete(Options.ConsentCookie.Name, cookieOptions); + } + _hasConsent = false; + } + + private bool CheckPolicyRequired() + { + return !CanTrack + || Options.MinimumSameSitePolicy != SameSiteMode.None + || Options.HttpOnly != HttpOnlyPolicy.None + || Options.Secure != CookieSecurePolicy.None; + } + + public void Append(string key, string value) + { + if (CheckPolicyRequired() || Options.OnAppendCookie != null) + { + Append(key, value, new CookieOptions()); + } + else + { + Cookies.Append(key, value); + } + } + + public void Append(string key, string value, CookieOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + var issueCookie = CanTrack || options.IsEssential; + ApplyPolicy(options); + if (Options.OnAppendCookie != null) + { + var context = new AppendCookieContext(Context, options, key, value) + { + IsConsentNeeded = IsConsentNeeded, + HasConsent = HasConsent, + IssueCookie = issueCookie, + }; + Options.OnAppendCookie(context); + + key = context.CookieName; + value = context.CookieValue; + issueCookie = context.IssueCookie; + } + + if (issueCookie) + { + Cookies.Append(key, value, options); + } + } + + public void Delete(string key) + { + if (CheckPolicyRequired() || Options.OnDeleteCookie != null) + { + Delete(key, new CookieOptions()); + } + else + { + Cookies.Delete(key); + } + } + + public void Delete(string key, CookieOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Assume you can always delete cookies unless directly overridden in the user event. + var issueCookie = true; + ApplyPolicy(options); + if (Options.OnDeleteCookie != null) + { + var context = new DeleteCookieContext(Context, options, key) + { + IsConsentNeeded = IsConsentNeeded, + HasConsent = HasConsent, + IssueCookie = issueCookie, + }; + Options.OnDeleteCookie(context); + + key = context.CookieName; + issueCookie = context.IssueCookie; + } + + if (issueCookie) + { + Cookies.Delete(key, options); + } + } + + private void ApplyPolicy(CookieOptions options) + { + switch (Options.Secure) + { + case CookieSecurePolicy.Always: + options.Secure = true; + break; + case CookieSecurePolicy.SameAsRequest: + options.Secure = Context.Request.IsHttps; + break; + case CookieSecurePolicy.None: + break; + default: + throw new InvalidOperationException(); + } + switch (Options.MinimumSameSitePolicy) + { + case SameSiteMode.None: + break; + case SameSiteMode.Lax: + if (options.SameSite == SameSiteMode.None) + { + options.SameSite = SameSiteMode.Lax; + } + break; + case SameSiteMode.Strict: + options.SameSite = SameSiteMode.Strict; + break; + default: + throw new InvalidOperationException($"Unrecognized {nameof(SameSiteMode)} value {Options.MinimumSameSitePolicy.ToString()}"); + } + switch (Options.HttpOnly) + { + case HttpOnlyPolicy.Always: + options.HttpOnly = true; + break; + case HttpOnlyPolicy.None: + break; + default: + throw new InvalidOperationException($"Unrecognized {nameof(HttpOnlyPolicy)} value {Options.HttpOnly.ToString()}"); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.CookiePolicy.Test/CookieConsentTests.cs b/test/Microsoft.AspNetCore.CookiePolicy.Test/CookieConsentTests.cs new file mode 100644 index 0000000000..4e62d54a26 --- /dev/null +++ b/test/Microsoft.AspNetCore.CookiePolicy.Test/CookieConsentTests.cs @@ -0,0 +1,561 @@ +// 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; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.CookiePolicy.Test +{ + public class CookieConsentTests + { + [Fact] + public async Task ConsentChecksOffByDefault() + { + var httpContext = await RunTestAsync(options => { }, requestContext => { }, context => + { + var feature = context.Features.Get(); + Assert.False(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.True(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + Assert.Equal("Test=Value; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task ConsentEnabledForTemplateScenario() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task NonEssentialCookiesWithOptionsExcluded() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value", new CookieOptions() { IsEssential = false }); + return Task.CompletedTask; + }); + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task NonEssentialCookiesCanBeAllowedViaOnAppendCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + options.OnAppendCookie = context => + { + Assert.True(context.IsConsentNeeded); + Assert.False(context.HasConsent); + Assert.False(context.IssueCookie); + context.IssueCookie = true; + }; + }, + requestContext => { }, context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value", new CookieOptions() { IsEssential = false }); + return Task.CompletedTask; + }); + Assert.Equal("Test=Value; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task NeedsConsentDoesNotPreventEssentialCookies() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value", new CookieOptions() { IsEssential = true }); + return Task.CompletedTask; + }); + Assert.Equal("Test=Value; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task EssentialCookiesCanBeExcludedByOnAppendCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + options.OnAppendCookie = context => + { + Assert.True(context.IsConsentNeeded); + Assert.True(context.HasConsent); + Assert.True(context.IssueCookie); + context.IssueCookie = false; + }; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value", new CookieOptions() { IsEssential = true }); + return Task.CompletedTask; + }); + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task HasConsentReadsRequestCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + Assert.Equal("Test=Value; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task HasConsentIgnoresInvalidRequestCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=IAmATeapot"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task GrantConsentSetsCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + feature.GrantConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + + var cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers[HeaderNames.SetCookie]); + Assert.Equal(2, cookies.Count); + var consentCookie = cookies[0]; + Assert.Equal(".AspNet.Consent", consentCookie.Name); + Assert.Equal("yes", consentCookie.Value); + Assert.Equal(Net.Http.Headers.SameSiteMode.Lax, 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.Lax, testCookie.SameSite); + Assert.Null(testCookie.Expires); + } + + [Fact] + public async Task GrantConsentAppliesPolicyToConsentCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + options.MinimumSameSitePolicy = Http.SameSiteMode.Strict; + options.OnAppendCookie = context => + { + Assert.Equal(".AspNet.Consent", context.CookieName); + Assert.Equal("yes", context.CookieValue); + Assert.Equal(Http.SameSiteMode.Strict, context.CookieOptions.SameSite); + context.CookieName += "1"; + context.CookieValue += "1"; + }; + }, + requestContext => { }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + feature.GrantConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + return Task.CompletedTask; + }); + + var cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers[HeaderNames.SetCookie]); + Assert.Equal(1, cookies.Count); + var consentCookie = cookies[0]; + Assert.Equal(".AspNet.Consent1", consentCookie.Name); + Assert.Equal("yes1", consentCookie.Value); + Assert.Equal(Net.Http.Headers.SameSiteMode.Strict, consentCookie.SameSite); + Assert.NotNull(consentCookie.Expires); + } + + [Fact] + public async Task GrantConsentWhenAlreadyHasItDoesNotSetCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + feature.GrantConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + + Assert.Equal("Test=Value; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task GrantConsentAfterResponseStartsSetsHasConsentButDoesNotSetCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, + async context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + await context.Response.WriteAsync("Started."); + + feature.GrantConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + Assert.Throws(() => context.Response.Cookies.Append("Test", "Value")); + + await context.Response.WriteAsync("Granted."); + }); + + var reader = new StreamReader(httpContext.Response.Body); + Assert.Equal("Started.Granted.", await reader.ReadToEndAsync()); + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task WithdrawConsentWhenNotHasConsentNoOps() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + feature.WithdrawConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + context.Response.Cookies.Append("Test", "Value"); + return Task.CompletedTask; + }); + + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task WithdrawConsentDeletesCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value1"); + + feature.WithdrawConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + context.Response.Cookies.Append("Test", "Value2"); + return Task.CompletedTask; + }); + + var cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers[HeaderNames.SetCookie]); + Assert.Equal(2, cookies.Count); + var testCookie = cookies[0]; + Assert.Equal("Test", testCookie.Name); + Assert.Equal("Value1", testCookie.Value); + Assert.Equal(Net.Http.Headers.SameSiteMode.Lax, 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.Lax, consentCookie.SameSite); + Assert.NotNull(consentCookie.Expires); + } + + [Fact] + public async Task WithdrawConsentAppliesPolicyToDeleteCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + options.MinimumSameSitePolicy = Http.SameSiteMode.Strict; + options.OnDeleteCookie = context => + { + Assert.Equal(".AspNet.Consent", context.CookieName); + context.CookieName += "1"; + }; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + + feature.WithdrawConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + return Task.CompletedTask; + }); + + var cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers[HeaderNames.SetCookie]); + Assert.Equal(1, cookies.Count); + var consentCookie = cookies[0]; + Assert.Equal(".AspNet.Consent1", consentCookie.Name); + Assert.Equal("", consentCookie.Value); + Assert.Equal(Net.Http.Headers.SameSiteMode.Strict, consentCookie.SameSite); + Assert.NotNull(consentCookie.Expires); + } + + [Fact] + public async Task WithdrawConsentAfterResponseHasStartedDoesNotDeleteCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => + { + requestContext.Request.Headers[HeaderNames.Cookie] = ".AspNet.Consent=yes"; + }, + async context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.True(feature.HasConsent); + Assert.True(feature.CanTrack); + context.Response.Cookies.Append("Test", "Value1"); + + await context.Response.WriteAsync("Started."); + + feature.WithdrawConsent(); + + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + + // Doesn't throw the normal InvalidOperationException because the cookie is never written + context.Response.Cookies.Append("Test", "Value2"); + + await context.Response.WriteAsync("Withdrawn."); + }); + + var reader = new StreamReader(httpContext.Response.Body); + Assert.Equal("Started.Withdrawn.", await reader.ReadToEndAsync()); + Assert.Equal("Test=Value1; path=/; samesite=lax", httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + [Fact] + public async Task DeleteCookieDoesNotRequireConsent() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + }, + requestContext => { }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Delete("Test"); + return Task.CompletedTask; + }); + + var cookies = SetCookieHeaderValue.ParseList(httpContext.Response.Headers[HeaderNames.SetCookie]); + Assert.Equal(1, cookies.Count); + var testCookie = cookies[0]; + Assert.Equal("Test", testCookie.Name); + Assert.Equal("", testCookie.Value); + Assert.Equal(Net.Http.Headers.SameSiteMode.Lax, testCookie.SameSite); + Assert.NotNull(testCookie.Expires); + } + + [Fact] + public async Task OnDeleteCookieCanSuppressCookie() + { + var httpContext = await RunTestAsync(options => + { + options.CheckConsentNeeded = context => true; + options.OnDeleteCookie = context => + { + Assert.True(context.IsConsentNeeded); + Assert.False(context.HasConsent); + Assert.True(context.IssueCookie); + context.IssueCookie = false; + }; + }, + requestContext => { }, + context => + { + var feature = context.Features.Get(); + Assert.True(feature.IsConsentNeeded); + Assert.False(feature.HasConsent); + Assert.False(feature.CanTrack); + context.Response.Cookies.Delete("Test"); + return Task.CompletedTask; + }); + + Assert.Empty(httpContext.Response.Headers[HeaderNames.SetCookie]); + } + + private Task RunTestAsync(Action configureOptions, Action configureRequest, RequestDelegate handleRequest) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.Configure(configureOptions); + }) + .Configure(app => + { + app.UseCookiePolicy(); + app.Run(handleRequest); + }); + var server = new TestServer(builder); + return server.SendAsync(configureRequest); + } + } +} \ No newline at end of file