diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs index 233b6b8..a90110b 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs @@ -8,6 +8,8 @@ using Joonasw.AspNetCore.SecurityHeaders.Hpkp.Builder; using Joonasw.AspNetCore.SecurityHeaders.Hsts; using Joonasw.AspNetCore.SecurityHeaders.ReferrerPolicy; +using Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints; +using Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints.Builder; using Joonasw.AspNetCore.SecurityHeaders.XContentTypeOptions; using Joonasw.AspNetCore.SecurityHeaders.XFrameOptions; using Joonasw.AspNetCore.SecurityHeaders.XXssProtection; @@ -245,5 +247,22 @@ public static IApplicationBuilder UseExpectCT(this IApplicationBuilder app) { return app.UseMiddleware(); } + + /// + /// Sets the Reporting-Endpoints header. + /// + /// The + /// The builder action. + /// IApplicationBuilder. + public static IApplicationBuilder UseReportingEndpoints(this IApplicationBuilder app, Action builderAction) + { + var builder = new ReportingEndpointsBuilder(); + builderAction(builder); + + var options = builder.BuildOptions(); + options.Validate(); + + return app.UseMiddleware(new OptionsWrapper(options)); + } } } diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/Csp/Builder/CspBuilder.cs b/src/Joonasw.AspNetCore.SecurityHeaders/Csp/Builder/CspBuilder.cs index f858a5f..35679c1 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/Csp/Builder/CspBuilder.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/Csp/Builder/CspBuilder.cs @@ -110,12 +110,24 @@ public void SetReportOnly() /// The URL where violation reports should be sent. public void ReportViolationsTo(string uri) { - if(uri == null) throw new ArgumentNullException(nameof(uri)); - if(uri.Length == 0) throw new ArgumentException("Uri can't be empty", nameof(uri)); + if (uri == null) throw new ArgumentNullException(nameof(uri)); + if (uri.Length == 0) throw new ArgumentException("Uri can't be empty", nameof(uri)); _options.ReportUri = uri; } + /// + /// Sets the group name where violation reports are sent. + /// + /// The group name where violation reports should be sent. + public void ReportViolationsToGroup(string groupName) + { + if (string.IsNullOrWhiteSpace(groupName)) + throw new ArgumentNullException(nameof(groupName)); + + _options.ReportTo = groupName; + } + public void SetUpgradeInsecureRequests() { _options.UpgradeInsecureRequests = true; diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs index 25ab7e6..f9d5b37 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs @@ -115,6 +115,11 @@ public class CspOptions /// 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; /// @@ -218,6 +223,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/Properties/AssemblyInfo.cs b/src/Joonasw.AspNetCore.SecurityHeaders/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f01dfa8 --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Joonasw.AspNetCore.SecurityHeaders.Tests")] diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/Builder/ReportingEndpointsBuilder.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/Builder/ReportingEndpointsBuilder.cs new file mode 100644 index 0000000..1819c68 --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/Builder/ReportingEndpointsBuilder.cs @@ -0,0 +1,33 @@ +namespace Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints.Builder +{ + using System; + + /// + /// Builder for Reporting-Endpoints which instructs the user agent to store reporting endpoints for an origin. + /// + public class ReportingEndpointsBuilder + { + private readonly ReportingEndpointsOptions _options = new ReportingEndpointsOptions(); + + public ReportingEndpointsBuilder AddEndpoint(string groupName, string url) + { + if (string.IsNullOrWhiteSpace(groupName)) + throw new ArgumentNullException(nameof(groupName)); + + if (_options.Endpoints.ContainsKey(groupName)) + throw new ArgumentException("The provided group name already exist", nameof(groupName)); + + if (string.IsNullOrWhiteSpace(url)) + throw new ArgumentNullException(nameof(url)); + + if (!url.StartsWith("https://")) + throw new ArgumentException("The URL must start with https://", nameof(url)); + + _options.Endpoints.Add(groupName, url); + + return this; + } + + public ReportingEndpointsOptions BuildOptions() => _options; + } +} diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/ReportingEndpointsMiddleware.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/ReportingEndpointsMiddleware.cs new file mode 100644 index 0000000..884006d --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpoints/ReportingEndpointsMiddleware.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints +{ + public class ReportingEndpointsMiddleware + { + private const string HeaderName = "Reporting-Endpoints"; + + private readonly RequestDelegate _next; + private readonly string _cachedHeaderValue; + + public ReportingEndpointsMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _cachedHeaderValue = options.Value.ToHeaderValue(); + } + + public async Task InvokeAsync(HttpContext context) + { + if (!ContainsReportToHeader(context.Response)) + context.Response.Headers.Add(HeaderName, _cachedHeaderValue); + + 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/ReportingEndpointsOptions.cs b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpointsOptions.cs new file mode 100644 index 0000000..0312b8c --- /dev/null +++ b/src/Joonasw.AspNetCore.SecurityHeaders/ReportingEndpointsOptions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Joonasw.AspNetCore.SecurityHeaders +{ + public class ReportingEndpointsOptions + { + /// + /// Gets or sets the endpoints to send the violation reports. + /// + /// The endpoints. + public IDictionary Endpoints { get; set; } = new Dictionary(); + + internal void Validate() + { + if (!Endpoints.Any()) + throw new InvalidOperationException("No endpoints specified on UseReportingEndpoints"); + + if (Endpoints.Values.Any(x => !x.StartsWith("https://"))) + throw new InvalidOperationException("The endpoint URL must start with https://"); + } + + internal string ToHeaderValue() => string.Join(",", Endpoints.Select(kvp => $"{kvp.Key}=\"{kvp.Value}\"")); + } +} diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Samples/Startup.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Samples/Startup.cs index 3e564e9..3b9b1d2 100644 --- a/test/Joonasw.AspNetCore.SecurityHeaders.Samples/Startup.cs +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Samples/Startup.cs @@ -110,6 +110,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF // csp.SetReportOnly(); // csp.ReportViolationsTo("/csp-report"); + // csp.ReportViolationsToGroup("csp-report"); // Require UseReportingEndpoints with same group name // csp.SetUpgradeInsecureRequests(); //Upgrade HTTP URIs to HTTPS // csp.OnSendingHeader = context => @@ -191,6 +192,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF // .FromNowhere(); //}); + app.UseReportingEndpoints(builder => + { + builder.AddEndpoint("csp-report", "https://example.com/csp-report") + .AddEndpoint("hpkp-report", "https://example.com/hpkp-report"); + }); + app.UseRouting(); app.UseEndpoints(routes => { diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspBuilderTests.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspBuilderTests.cs index be4b12b..7ec52cf 100644 --- a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspBuilderTests.cs +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspBuilderTests.cs @@ -145,5 +145,28 @@ public async Task OnSendingHeader_ShouldNotSendTest() Assert.True(sendingHeaderContext.ShouldNotSend); } + + [Theory] + [InlineData("csp-endpoint")] + [InlineData("test-endpoint")] + public void ReportViolationsToGroup_SetCorrectly(string groupName) + { + var builder = new CspBuilder(); + + builder.ReportViolationsToGroup(groupName); + var options = builder.BuildCspOptions(); + + Assert.Equal(groupName, options.ReportTo); + Assert.Equal($"report-to {groupName}", options.ToString(null).headerValue); + } + + [Fact] + public void ReportViolationsToGroup_ThrowsArgumentNullException_WhenIsMissingValue() + { + var builder = new CspBuilder(); + + Assert.Throws("groupName", () => builder.ReportViolationsToGroup(null)); + Assert.Throws("groupName", () => builder.ReportViolationsToGroup(string.Empty)); + } } } diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsBuilderTests.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsBuilderTests.cs new file mode 100644 index 0000000..c440386 --- /dev/null +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsBuilderTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints.Builder; +using Xunit; + +namespace Joonasw.AspNetCore.SecurityHeaders.Tests +{ + public class ReportingEndpointsBuilderTests + { + [Fact] + public void ReportingEndpointsBuilder_Should_SetEndpoints() + { + var builder = new ReportingEndpointsBuilder(); + builder.AddEndpoint("csp-endpoint", "https://example.com/csp-reports") + .AddEndpoint("hpkp-endpoint", "https://example.com/hpkp-reports"); + + var options = builder.BuildOptions(); + + Assert.Equal("csp-endpoint=\"https://example.com/csp-reports\",hpkp-endpoint=\"https://example.com/hpkp-reports\"", options.ToHeaderValue()); + } + + [Fact] + public void ReportingEndpointsBuilder_ShouldThrowException_WhenGroupNameIsMissingValue() + { + var builder = new ReportingEndpointsBuilder(); + + Assert.Throws("groupName", () => builder.AddEndpoint(null, null)); + } + + [Fact] + public void ReportingEndpointsBuilder_ShouldThrowException_WhenGroupNameIsDuplicated() + { + var builder = new ReportingEndpointsBuilder(); + builder.AddEndpoint("csp-endpoint", "https://example.com/csp-reports"); + + Assert.Throws("groupName", () => builder.AddEndpoint("csp-endpoint", null)); + } + + [Fact] + public void ReportingEndpointsBuilder_ShouldThrowException_WhenUrlIsMissingValue() + { + var builder = new ReportingEndpointsBuilder(); + + Assert.Throws("url", () => builder.AddEndpoint("csp-endpoint", null)); + } + + [Fact] + public void ReportingEndpointsBuilder_ShouldThrowException_WhenUrlIsNotSecure() + { + var builder = new ReportingEndpointsBuilder(); + + Assert.Throws("url", () => builder.AddEndpoint("csp-endpoint", "http://example.com/csp-reports")); + } + + [Fact] + public void ReportingEndpointsOptions_ShouldThrowException_WhenNoEndpointSpecified() + { + var options = new ReportingEndpointsOptions + { + Endpoints = new Dictionary() + }; + + var builder = new ReportingEndpointsBuilder(); + + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void ReportingEndpointsOptions_ShouldThrowException_WhenAnUrlIsNotSecure() + { + var options = new ReportingEndpointsOptions + { + Endpoints = new Dictionary + { + { "csp-endpoint", "https://example.com/csp-reports" }, + { "hpkp-endpoint", "http://example.com/hpkp-reports" } + } + }; + + var builder = new ReportingEndpointsBuilder(); + + Assert.Throws(() => options.Validate()); + } + } +} diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsMiddlewareTests.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsMiddlewareTests.cs new file mode 100644 index 0000000..9025a5f --- /dev/null +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/ReportingEndpointsMiddlewareTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Xunit; +using Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints; + +namespace Joonasw.AspNetCore.SecurityHeaders.Tests +{ + public class ReportingEndpointsMiddlewareTests + { + [Fact] + public async Task HeaderShouldBeSetCorrectly() + { + var headerValue = (string)null; + RequestDelegate mockNext = (HttpContext ctx) => + { + headerValue = ctx.Response.Headers["Reporting-Endpoints"]; + return Task.CompletedTask; + }; + + var options = Options.Create(new ReportingEndpointsOptions + { + Endpoints = new Dictionary + { + { "csp-endpoint", "https://example.com/csp-reports" }, + { "hpkp-endpoint", "https://example.com/hpkp-reports" } + } + }); + var mockContext = new DefaultHttpContext(); + var sut = new ReportingEndpointsMiddleware(mockNext, options); + + await sut.InvokeAsync(mockContext); + + Assert.Equal("csp-endpoint=\"https://example.com/csp-reports\",hpkp-endpoint=\"https://example.com/hpkp-reports\"", headerValue); + } + } +}