diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs index 233b6b8..8dd608b 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs @@ -8,6 +8,7 @@ using Joonasw.AspNetCore.SecurityHeaders.Hpkp.Builder; using Joonasw.AspNetCore.SecurityHeaders.Hsts; using Joonasw.AspNetCore.SecurityHeaders.ReferrerPolicy; +using Joonasw.AspNetCore.SecurityHeaders.ReportTo; using Joonasw.AspNetCore.SecurityHeaders.XContentTypeOptions; using Joonasw.AspNetCore.SecurityHeaders.XFrameOptions; using Joonasw.AspNetCore.SecurityHeaders.XXssProtection; @@ -103,6 +104,13 @@ public static IApplicationBuilder UseHpkp(this IApplicationBuilder app) return app.UseMiddleware(); } + public static IApplicationBuilder UseReportTo( + this IApplicationBuilder app, + ReportToOptions options) + { + return app.UseMiddleware(new OptionsWrapper(options)); + } + /// /// Sets the X-Frame-Options header to DENY by default /// diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs index 201248c..c948c0e 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs @@ -108,6 +108,10 @@ public class CspOptions /// The URL where violation reports should be sent. /// public string ReportUri { get; set; } + /// + /// The group where violation reports should be sent. + /// + public string ReportTo { get; set; } public bool IsNonceNeeded => Script.AddNonce || Style.AddNonce; @@ -210,6 +214,10 @@ public CspOptions() { values.Add("report-uri " + ReportUri); } + if (!string.IsNullOrWhiteSpace(ReportTo)) + { + values.Add("report-to " + ReportTo); + } string headerValue = string.Join(";", values.Where(s => s.Length > 0)); diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/Joonasw.AspNetCore.SecurityHeaders.csproj b/src/Joonasw.AspNetCore.SecurityHeaders/Joonasw.AspNetCore.SecurityHeaders.csproj index aededb6..8ab44a4 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/Joonasw.AspNetCore.SecurityHeaders.csproj +++ b/src/Joonasw.AspNetCore.SecurityHeaders/Joonasw.AspNetCore.SecurityHeaders.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToMiddleware.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToMiddleware.cs new file mode 100644 index 0000000..18fa626 --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToMiddleware.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Joonasw.AspNetCore.SecurityHeaders.ReportTo +{ + public class ReportToMiddleware + { + private const string HeaderName = "Report-To"; + private readonly RequestDelegate _next; + private readonly string _headerValue; + + public ReportToMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _headerValue = options.Value.BuildHeaderValue(); + } + + public async Task Invoke(HttpContext context) + { + // Check if a CSP header has already been added to the response + // This can happen for example if a middleware re-executes the pipeline + if (!ContainsReportToHeader(context.Response)) + { + context.Response.Headers.Add(HeaderName, _headerValue); + } + await _next(context); + } + + private bool ContainsReportToHeader(HttpResponse response) + { + return response.Headers.Any(h => h.Key.Equals(HeaderName, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToOptionsExtensions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToOptionsExtensions.cs new file mode 100644 index 0000000..b21bb38 --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportTo/ReportToOptionsExtensions.cs @@ -0,0 +1,61 @@ +using Newtonsoft.Json; +using System; + +namespace Joonasw.AspNetCore.SecurityHeaders.ReportTo +{ + internal static class ReportToOptionsExtensions + { + public static string BuildHeaderValue(this ReportToOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.Groups == null || options.Groups.Count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options.Groups), "ReportToOptions must have at least one group"); + } + + var values = new string[options.Groups.Count]; + for (var i = 0; i < options.Groups.Count; i++) + { + var group = options.Groups[i]; + + if (group.MaxAgeSeconds <= 0) + { + throw new ArgumentOutOfRangeException(nameof(group.MaxAgeSeconds), "ReportTo max age must be positive"); + } + + if (group.Endpoints == null || group.Endpoints.Count <= 0) + { + throw new ArgumentNullException(nameof(group.Endpoints), "At least one endpoint required"); + } + + for (var j = 0; j < group.Endpoints.Count; j++) + { + var e = group.Endpoints[j]; + + if (string.IsNullOrWhiteSpace(e.Url)) + { + throw new ArgumentException($"{nameof(group.Endpoints)}[{j}].Url", "Url for endpoint required"); + } + + if (e.Priority.HasValue && e.Priority <= 0) + { + throw new ArgumentException($"{nameof(group.Endpoints)}[{j}].Priority", "Priority must be positive if present"); + } + + if (e.Weight.HasValue && e.Weight <= 0) + { + throw new ArgumentException($"{nameof(group.Endpoints)}[{j}].Weight", "Weight must be positive if present"); + } + } + + values[i] = JsonConvert.SerializeObject(group); + } + + return string.Join(",\r\n", values); + } + } +} diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/ReportToOptions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportToOptions.cs new file mode 100644 index 0000000..7668985 --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportToOptions.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Joonasw.AspNetCore.SecurityHeaders +{ + public class ReportToOptions + { + public IList Groups { get; set; } = new List(); + + public class Group + { + [JsonProperty("group")] + public string GroupName { get; set; } + + [JsonProperty("include_subdomains")] + public bool IncludeSubdomains { get; set; } + + [JsonProperty("max_age")] + public int MaxAgeSeconds { get; set; } + + [JsonProperty("endpoints")] + public IList Endpoints { get; set; } = new List(); + + public class Endpoint + { + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("priority")] + public int? Priority { get; set; } + + [JsonProperty("weight")] + public int? Weight { get; set; } + } + } + } +} diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportToMiddlewareTests.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportToMiddlewareTests.cs new file mode 100644 index 0000000..48c444e --- /dev/null +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportToMiddlewareTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Joonasw.AspNetCore.SecurityHeaders.ReportTo; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Joonasw.AspNetCore.SecurityHeaders.Tests +{ + public class ReportToMiddlewareTests + { + [Fact] + public async Task ReportToHeaderSetCorrectly() + { + bool reportToHeaderExists = false; + RequestDelegate mockNext = (HttpContext ctx) => + { + reportToHeaderExists = ctx.Response.Headers.ContainsKey("Report-To"); + return Task.CompletedTask; + }; + var options = new ReportToOptions() + { + Groups = new[] + { + new ReportToOptions.Group { + GroupName = "a", + MaxAgeSeconds = 60, + Endpoints = new [] + { + new ReportToOptions.Group.Endpoint() { Url = "a" }, + } + }, + }, + }; + + var sut = new ReportToMiddleware(mockNext, Options.Create(options)); + var mockContext = new DefaultHttpContext(); + + await sut.Invoke(mockContext); + + Assert.True(reportToHeaderExists); + } + + [Fact] + public async Task ReportToHeaderSupportsMultiple() + { + bool reportToHeaderExists = false; + RequestDelegate mockNext = (HttpContext ctx) => + { + var header = ctx.Response.Headers["Report-To"]; + reportToHeaderExists = !string.IsNullOrEmpty(header); + return Task.CompletedTask; + }; + var options = new ReportToOptions() + { + Groups = new[] + { + new ReportToOptions.Group { + GroupName = "a", + MaxAgeSeconds = 60, + Endpoints = new [] + { + new ReportToOptions.Group.Endpoint() { Url = "a" }, + } + }, + new ReportToOptions.Group { + GroupName = "b", + MaxAgeSeconds = 60, + Endpoints = new [] + { + new ReportToOptions.Group.Endpoint() { Url = "b" }, + } + }, + }, + }; + + var sut = new ReportToMiddleware(mockNext, Options.Create(options)); + var mockContext = new DefaultHttpContext(); + + await sut.Invoke(mockContext); + + Assert.True(reportToHeaderExists); + } + } +}