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

Added support for CSP report-to #55

Open
wants to merge 2 commits into
base: master
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Joonasw.AspNetCore.SecurityHeaders/AppBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -245,5 +247,22 @@ public static IApplicationBuilder UseExpectCT(this IApplicationBuilder app)
{
return app.UseMiddleware<ExpectCTMiddleware>();
}

/// <summary>
/// Sets the Reporting-Endpoints header.
/// </summary>
/// <param name="app">The <see cref="IApplicationBuilder"/></param>
/// <param name="builderAction">The builder action.</param>
/// <returns>IApplicationBuilder.</returns>
public static IApplicationBuilder UseReportingEndpoints(this IApplicationBuilder app, Action<ReportingEndpointsBuilder> builderAction)
{
var builder = new ReportingEndpointsBuilder();
builderAction(builder);

var options = builder.BuildOptions();
options.Validate();

return app.UseMiddleware<ReportingEndpointsMiddleware>(new OptionsWrapper<ReportingEndpointsOptions>(options));
}
}
}
16 changes: 14 additions & 2 deletions src/Joonasw.AspNetCore.SecurityHeaders/Csp/Builder/CspBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,24 @@ public void SetReportOnly()
/// <param name="uri">The URL where violation reports should be sent.</param>
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;
}

/// <summary>
/// Sets the group name where violation reports are sent.
/// </summary>
/// <param name="groupName">The group name where violation reports should be sent.</param>
public void ReportViolationsToGroup(string groupName)
{
if (string.IsNullOrWhiteSpace(groupName))
throw new ArgumentNullException(nameof(groupName));

_options.ReportTo = groupName;
}

public void SetUpgradeInsecureRequests()
{
_options.UpgradeInsecureRequests = true;
Expand Down
9 changes: 9 additions & 0 deletions src/Joonasw.AspNetCore.SecurityHeaders/CspOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ public class CspOptions
/// </summary>
public string ReportUri { get; set; }

/// <summary>
/// The group where violation reports should be sent.
/// </summary>
public string ReportTo { get; set; }

public bool IsNonceNeeded => Script.AddNonce || Style.AddNonce;

/// <summary>
Expand Down Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Joonasw.AspNetCore.SecurityHeaders.Tests")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Joonasw.AspNetCore.SecurityHeaders.ReportingEndpoints.Builder
{
using System;

/// <summary>
/// Builder for Reporting-Endpoints which instructs the user agent to store reporting endpoints for an origin.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<ReportingEndpointsOptions> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Joonasw.AspNetCore.SecurityHeaders
{
public class ReportingEndpointsOptions
{
/// <summary>
/// Gets or sets the endpoints to send the violation reports.
/// </summary>
/// <value>The endpoints.</value>
public IDictionary<string, string> Endpoints { get; set; } = new Dictionary<string, string>();

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}\""));
}
}
7 changes: 7 additions & 0 deletions test/Joonasw.AspNetCore.SecurityHeaders.Samples/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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 =>
{
Expand Down
23 changes: 23 additions & 0 deletions test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentNullException>("groupName", () => builder.ReportViolationsToGroup(null));
Assert.Throws<ArgumentNullException>("groupName", () => builder.ReportViolationsToGroup(string.Empty));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>("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<ArgumentException>("groupName", () => builder.AddEndpoint("csp-endpoint", null));
}

[Fact]
public void ReportingEndpointsBuilder_ShouldThrowException_WhenUrlIsMissingValue()
{
var builder = new ReportingEndpointsBuilder();

Assert.Throws<ArgumentNullException>("url", () => builder.AddEndpoint("csp-endpoint", null));
}

[Fact]
public void ReportingEndpointsBuilder_ShouldThrowException_WhenUrlIsNotSecure()
{
var builder = new ReportingEndpointsBuilder();

Assert.Throws<ArgumentException>("url", () => builder.AddEndpoint("csp-endpoint", "http://example.com/csp-reports"));
}

[Fact]
public void ReportingEndpointsOptions_ShouldThrowException_WhenNoEndpointSpecified()
{
var options = new ReportingEndpointsOptions
{
Endpoints = new Dictionary<string, string>()
};

var builder = new ReportingEndpointsBuilder();

Assert.Throws<InvalidOperationException>(() => options.Validate());
}

[Fact]
public void ReportingEndpointsOptions_ShouldThrowException_WhenAnUrlIsNotSecure()
{
var options = new ReportingEndpointsOptions
{
Endpoints = new Dictionary<string, string>
{
{ "csp-endpoint", "https://example.com/csp-reports" },
{ "hpkp-endpoint", "http://example.com/hpkp-reports" }
}
};

var builder = new ReportingEndpointsBuilder();

Assert.Throws<InvalidOperationException>(() => options.Validate());
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
{ "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);
}
}
}