Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Major updates to NetEscapades.​AspNetCore.​SecurityHeaders

$
0
0

In this post I provide an update on the NetEscapades.AspNetCore.SecurityHeaders project. I've just released a preview version of 1.0.0, which addresses some longstanding requests for extra functionality, updates the supported target frameworks, and more. In this post I provide a quick overview of the library, and then discuss the major changes made in this first preview.

What are security headers?

Security headers are headers that you can return in your HTTP responses which improve the overall security of your application. The headers instruct browsers to activate or disable various features, with the overall goal of hardening your application and reducing your attack surface area.

Some of these headers apply to all HTTP responses, while others only really make sense for HTML responses. Nevertheless, it can make sense to apply theoretically-HTML-only headers to non-HTML responses as part of a defence-in-depth approach, as described by OWASP.

The main problem with security headers is that there are a lot of them, and the list is generally growing and evolving, with new headers being introduced and others being retired. What's more, different headers use different patterns for lists—some use ; separators, others use ,, and others use a space—so it's easy to set them up incorrectly.

The NetEscapades.AspNetCore.SecurityHeaders package aims to help you set up security headers for your ASP.NET Core app. It provides sensible defaults, with a fluent builder pattern for customizing and configuring the headers for your specific application requirements.

Adding security headers to your app

In this section I show the quickest was to get started with NetEscapades.AspNetCore.SecurityHeaders and to start adding security headers to your application.

First, add the package to your app:

dotnet add package NetEscapades.AspNetCore.SecurityHeaders --version 1.0.0-preview.1

Alternatively, add the package to your .csproj directly

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <!-- 👇 Add the package -->
    <PackageReference Include="NetEscapades.AspNetCore.SecurityHeaders" Version="1.0.0-preview.1" />
  </ItemGroup>
</Project>

Finally, add the security headers middleware to the start of your middleware pipeline with the UseSecurityHeaders() extension method. For example:

var builder = WebApplication.CreateBuilder();

var app = builder.Build();

// 👇 Add the security headers to the start of the pipeline
app.UseSecurityHeaders();

app.MapGet("/", () => "Hello world!");

app.Run();

The SecurityHeadersMiddleware registers a callback that adds several headers to all responses. By default, the middleware adds the following header to your responses:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: Deny
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: object-src 'none'; form-action 'self'; frame-ancestors 'none'
  • Cross-Origin-Opener-Policy: same-origin
  • Strict-Transport-Security: max-age=31536000; includeSubDomains - only applied to HTTPS responses

These headers were chosen based on guidance from OWASP to provide security hardening benefits, while being broadly applicable to most applications.

These headers don't represent the most secure set you could choose, but rather a "generally safe" set for most applications. Ideally you should customise these headers to add a more secure (and more restrictive) set.

If you want to change the headers that are applied, you can create an instance of HeaderPolicyCollection, and use the fluent builder interface to customize the added headers. The following shows an example which specifically adds all the default headers independently.

var policyCollection = new HeaderPolicyCollection()
    .AddFrameOptionsDeny()
    .AddContentTypeOptionsNoSniff()
    .AddStrictTransportSecurityMaxAgeIncludeSubDomains(maxAgeInSeconds: 60 * 60 * 24 * 365) // maxage = one year in seconds
    .AddReferrerPolicyStrictOriginWhenCrossOrigin()
    .RemoveServerHeader()
    .AddContentSecurityPolicy(builder =>
    {
        builder.AddObjectSrc().None();
        builder.AddFormAction().Self();
        builder.AddFrameAncestors().None();
    })
    .AddCustomHeader("X-My-Test-Header", "Header value");

app.UseSecurityHeaders(policyCollection);

For most of the history of NetEscapades.AspNetCore.SecurityHeaders, this was been the only way to configure the security headers for your application. The advantage is that it's simple—there's no services to add, nothing to "reason" about—whatever headers you configure, are added.

However, some people wanted more control, for example to be able to add different headers to different endpoints in their application, or to customize the headers on a request-by-request basis. That flexibility is largely what the changes in 1.0.0 are about, but as this is a major version, I took the opportunity to make some other larger changes too.

For the rest of this post I'll describe some of the biggest changes in 1.0.0-preview.1.

Major changes in 1.0.0-preview.1

The following sections describe the high level changes and features included in 1.0.0-preview.1. These aren't guaranteed to make it into the final 1.0.0 release, so do let me know what you think about them!

Before we get started, the first thing to note is that NetEscapades.AspNetCore.SecurityHeaders finally has a logo:

NetEscapades.AspNetCore.SecurityHeaders

A big thanks to Khalid for putting that together so quickly! Now lets look at the new features and breaking changes.

Changes to the supported frameworks

I first created NetEscapades.AspNetCore.SecurityHeaders way back in 2016, when ASP.NET Core was just being released. Back then, ASP.NET Core could also be run on .NET Framework. The original thought was that this mode would serve as a migration path from ASP.NET to ASP.NET Core.

That path is rarely recommended these days, and instead, a "strangler fig" pattern is recommended. ASP.NET Core has not been supported on .NET Framework since version 2.1, and as such 1.0.0-preview.1 of NetEscapades.AspNetCore.SecurityHeaders finally drops support for .NET Framework too, by dropping support for netstandard2.0 and everything prior to .NET Core 3.1.

Given the package still supports .NET Core 3.1+ it's very unlikely you'll be impacted by this change unless you're on a (very) unsupported version of .NET Core. And if you are, I would strongly advise you to update anyway!

Changes to headers

In the next section I describe some of the changes to specific security headers, including changes to default values, new APIs, and deprecated methods.

Changes to the default headers

When you don't specify a custom HeaderPolicyCollection, NetEscapades.AspNetCore.SecurityHeaders applies a default set of headers, as I described previously. These headers are encapsulated in the AddDefaultSecurityHeaders() extension method, so the following are equivalent:

app.UseSecurityHeaders(); // 👈 This...

var policies = new HeaderPolicyCollection()
    .AddDefaultSecurityHeaders();
app.UseSecurityHeaders(policies); // 👈 ..is equivalent to this.

In 1.0.0-preview.1 the headers that are applied have changed slightly:

  • Cross-Origin-Opener-Policy=same-origin is now added.
  • X-XSS-Protection=1; mode-block is no longer added.

The full set of headers added by default are now:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: Deny
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: object-src 'none'; form-action 'self'; frame-ancestors 'none'
  • Cross-Origin-Opener-Policy: same-origin
  • Strict-Transport-Security: max-age=31536000; includeSubDomains - HTTPS responses only

If you don't want this change, you can simply create a custom HeaderPolicyCollection configured as it was previously:

new HeaderPolicyCollection()
  .AddFrameOptionsDeny()
  .AddXssProtectionBlock() // This is no longer included by default
  .AddContentTypeOptionsNoSniff()
  .AddStrictTransportSecurityMaxAge()
  .AddReferrerPolicyStrictOriginWhenCrossOrigin()
  .RemoveServerHeader()
  .AddContentSecurityPolicy(builder =>
  {
      builder.AddObjectSrc().None();
      builder.AddFormAction().Self();
      builder.AddFrameAncestors().None();
  });

You may wonder why X-XSS-Protection was removed from the default headers. The X-XSS-Protection header is interesting in that it's a security header that used to be recommended to add to your applications to improve security. However these days it's not recommended, because, as described on MDN:

The X-XSS-Protection header "can create XSS vulnerabilities in otherwise safe websites"

That's clearly not an example of a good header to add by default, so it's been removed from the default set of security headers and marked obsolete. You can still add it to your application if you want to, it's just not recommended unless you understand the risks.

The Expect-CT header lets sites opt-in to certificate transparency requirements, but only Chrome and other Chromium-based browsers implemented Expect-CT, On top of that, Chromium deprecated the header in version 107 (Oct 2022), because Chromium now enforces CT by default.

Given the header is deprecated and no longer recommended, it's now marked obsolete in NetEscapades.AspNetCore.SecurityHeaders.

New PermisionsPolicyBuilder.AddDefaultSecureDirectives() method

The Permissions-Policy header provides a way to allow or deny the browser from using various features, such as the Web Bluetooth API or the Camera. Support for Permissions-Policy has been available for some time (with many new policies added recently).

One slight annoyance with building up a Permissions-Policy is that there are so many policies. If you're building a JSON API (for example) then you'll likely want to disable essentially all of them, at which point you're calling a lot of methods.

1.0.0-preview.1 adds two new convenience method that add (most) of the headers recommended by OWASP for REST endpoints. If you just want to add the default set of directives you can call AddPermissionsPolicyWithDefaultSecureDirectives() directly on HeaderPolicyCollection:

var policies = new HeaderPolicyCollection()
    .AddPermissionsPolicyWithDefaultSecureDirectives();

Alternatively, if you want to customize the Permissions-Policy, you can call PermissionsPolicyBuilder.AddDefaultSecureDirectives(), and then add (or override) additional directives:

var policies = new HeaderPolicyCollection()
    .AddPermissionsPolicy(p => 
    {
      p.AddDefaultSecureDirectives(); // Add the default directives
      p.AddAttributionReporting().None(); // Additional customization
    });

In both cases the default secure directives added are equivalent to:

accelerometer=(), ambient-light-sensor=(), autoplay=(), camera=(), display-capture=(),
encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(),
microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(),
screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()

Note that this isn't quite the same set of headers as suggested by OWASP, because NetEscapades.AspNetCore.SecurityHeaders doesn't include experimental features (to limit the scope and avoid too many breaking changes), but if you want to replicate those directives, you can always use the AddCustomDirective() helper.

New AddDefaultApiSecurityHeaders() method

The set of security headers added by AddDefaultSecurityHeaders() was chosen to provide a good balance between security and applicability for a basic ASP.NET Core website that's serving HTML. But many apps only serve JSON and are never expected to be loaded directly in the browser. In those scenarios we can apply a more "aggressive" set of headers.

The new AddDefaultApiSecurityHeaders() extension method is designed for applying to APIs, and is based on the recommendation from OWASP. It adds the following headers (I've also highlighted any differences with AddDefaultSecurityHeaders()):

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: Deny
  • Referrer-Policy: no-referrer
    • Compared to strict-origin-when-cross-origin for AddDefaultSecurityHeaders()
  • Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
    • Compared to object-src 'none'; form-action 'self'; frame-ancestors 'none' for AddDefaultSecurityHeaders()
  • Permissions-Policy: accelerometer=(), ambient-light-sensor=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()
    • Not included in AddDefaultSecurityHeaders()
  • Strict-Transport-Security: max-age=31536000; includeSubDomains - HTTPS responses only

You can use AddDefaultApiSecurityHeaders() in your application by applying it to a HeaderPolicyCollection, for example:

var builder = WebApplication.CreateBuilder();
var app = builder.Build();

// 👇 Configure to use the API security headers
app.UseSecurityHeaders(p => p.AddDefaultApiSecurityHeaders());

app.MapGet("/", () => "Hello world!");
app.Run();

It's worth noting that OWASP recommends always adding other non-security headers to API responses, such as Cache-Control: no-store and Content-Type.

Feature-Policy is marked obsolete

Feature-Policy is a now-obsolete name for the Permissions-Policy header. The header has been deprecated, and as such the extension methods for adding Feature-Policy to your header collection have been marked [Obsolete]. You're recommended to switch to using Permissions-Policy instead.

Applying different headers to some endpoints

The biggest feature by far in 1.0.0-preview.1 is the ability to apply different headers to different endpoints. This can be particularly useful if you want to maximally lock down your site, for example by applying a more restrictive Content-Security-Policy on certain pages, and relaxing it only when necessary.

Let's say, for example, that your application contains both HTML and JSON endpoints. Perhaps you're serving HTML via Razor Pages, but you have some API endpoints that are also available. You would want to:

  • Apply the default security headers to all responses by default
  • For the API endpoints, apply the headers added by AddDefaultApiSecurityHeaders()

Prior to 1.0.0-preview.1, that wasn't possible, but now it is! 🎉 To achieve this we need to do 3 things:

  1. Configure default and named policies for the application.
  2. Add the middleware using UseSecurityHeaders() (as before).
  3. Apply custom policies to endpoints.

The following example shows all of those steps

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

// 1. 👇 Configure the policies for the application
builder.Services.AddSecurityHeaderPolicies()
  .SetDefaultPolicy(p => p.AddDefaultSecurityHeaders()) // 👈 Configure the default policy
  .AddPolicy("API", p => p.AddDefaultApiSecurityHeaders()); // 👈 Configure named policies

var app = builder.Build();

// 2. 👇 Add the security headers middleware
app.UseSecurityHeaders();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

app.MapRazorPages();
app.MapGet("/api", () => "Hello world")
  .WithSecurityHeadersPolicy("API"); // 3. 👈 Apply a named policy to the endpoint

app.Run();

In the example above, the default security headers are applied to all responses except when the /api endpoint is invoked, in which case the named policy "API" is applied (which adds the AddDefaultApiSecurityHeaders() headers).

If you're working with MVC controllers or Razor Pages, you can apply a named policy to an endpoint using the [SecurityHeadersPolicyAttribute] header.

This should cater to the vast majority of cases where you need to apply multiple policies in an application, but if you really need it, you can now completely customise the policy that's applied, as you'll see in the next section.

Customizing the headers completely

Applying different policies to different endpoints works well when you have a fixed number of policies to apply. However, in some cases you need to completely customise the headers for a given request. This might be the case if, for example, you are running a multi-tenant application, and the headers need to be matched to the incoming request.

In 1.0.0-preview.1, you can now completely customize the headers that will be applied by providing a lambda method that is executed just before the headers are applied, and by returning the HeaderPolicyCollection to apply.

For example, lets imagine that you want to apply a different set of headers for some requests. You can call the SetPolicySelector() as shown below and provide a function to execute whenever a policy is about to be applied:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSecurityHeaderPolicies()
  .SetPolicySelector((PolicySelectorContext ctx) =>
  {
      // TODO: anything you need to build the HeaderPolicyCollection
      // e.g. use services from the DI container (if you need to)
      IServiceProvider services = ctx.HttpContext.RequestServices; 

      var selector = services.GetService<TenantHeaderPolicyCollectionSelector>();
      var tenant = services.GetService<ITenant>();

      HeaderPolicyCollection policy = selector.GetPolicyForTenant(tenant);
      return policy; // 👈 This is the policy that is applied
  });

var app = builder.Build();

app.UseSecurityHeaders();
app.MapGet("/api", () => "Hello world");
app.Run();

The lambda/method you pass to SetPolicySelector() is provided a PolicySelectorContext, which includes all the information you might need to decide which policy to apply:

  • HttpContext HttpContext—The current HttpContext for the request.
  • IReadOnlyDictionary<string, IReadOnlyHeaderPolicyCollection> ConfiguredPolicies—The named policies configured for the application.
  • IReadOnlyHeaderPolicyCollection DefaultPolicy—The default policy that applies to the request.
  • string? EndpointPolicyName—The name of the endpoint policy that applies to the request, if any.
  • IReadOnlyHeaderPolicyCollection? EndpointPolicy—The endpoint policy that applies to the request, if any. If no endpoint-specific policy applies to the request, returns null.
  • IReadOnlyHeaderPolicyCollection SelectedPolicy—The policy that would be applied to the endpoint by default: equivalent to EndpointPolicy if available, otherwise DefaultPolicy.

Your policy selector must return a policy collection. If you don't want to customise the policy for the request, return ctx.SelectedPolicy.

Note that you should avoid building a new HeaderPolicyCollection on every request for performance reasons. Where possible, cache and reuse HeaderPolicyCollection instances. If you do need to create a new HeaderPolicyCollection from a IReadOnlyHeaderPolicyCollection, you can call Copy() to return a mutable instance.

With the combination of endpoint-specific policies and the customisation available in SetPolicySelector(), I hope that people will now be able to customise their applications more easily, without needing to resort to hacking the internals of the library!

"Document headers" functionality has been removed

One consequence of the additional customisation possible with SetPolicySelector() and endpoint policies is that the concept of "document headers" have been removed. The ApplyDocumentHeadersToContentTypes() and ApplyDocumentHeadersToAllResponses() extension methods have now been marked [Obsolete] and are no-ops.

These methods were originally added because some security headers don't really make sense when applied to passive content like JSON responses compared to HTML responses. Consequently, by default, some security headers (such as Content-Security-Policy) would be omitted unless the response was HTML or JavaScript.

The only real benefit to this approach was that it reduced the size of the response (by omitting some headers). The down-side was that it added confusion and an additional configuration knob people had to consider. What's more OWASP actually recommends you do send these headers even if you have no intention of returning HTML, as part of a defence-in-depth approach. What's more, some of the headers that were previously considered "document/HTML only" actually should be added to all responses to protect against drag-and-drop style clickjacking attacks.

So in 1.0.0-preview.1 the concept of a DocumentHeaderPolicy has been removed, and headers are always applied to all requests regardless of the response content type.

Mostly…some headers only apply to HTTPS requests for example, or should not be applied to localhost requests. But the "document header" concept is no more.

If you want to re-instate the "document header" functionality for some reason, you can recreate something similar with SetPolicySelector(). There are lots of different ways you could achieve it, but here's one example:

var builder = WebApplication.CreateBuilder(args);

// The mime types considered "documents"
string[] documentTypes = [ "text/html", "application/javascript", "text/javascript" ];
var documentPolicy = new HeaderPolicyCollection().AddDefaultSecurityHeaders();

builder.Services.AddSecurityHeaderPolicies()
  .SetDefaultPolicy(p => p.AddDefaultApiSecurityHeaders())
  .SetPolicySelector(ctx =>
  {
      // If the response is one of the "document" types...
      if (documentTypes.Contains(ctx.HttpContext.Response.ContentType))
      {
          // ... then return the "document" policy
          return documentPolicy;
      }

      // Otherwise return the original selected policy
      return ctx.SelectedPolicy;
  });

var app = builder.Build();

app.UseSecurityHeaders();
app.MapGet("/api", () => "Hello world");
app.Run();

As I've already said, I don't recommend you do this, but the point is you can now, which is why I chose to remove the document headers functionality.

Changes to nonce generation

In previous versions of NetEscapades.AspNetCore.SecurityHeaders a nonce (number used once) was generated at the start of a request only when required by a Content-Security-Policy. However, with the changes in 1.0.0-preview.1 it's no longer possible to know ahead of time that a nonce will be required (because the header policies can change later in the request, due to a named endpoint policy for example).

As a result, the nonce is no longer generated at the start of the request. Instead, it's generated lazily when you call HttpContext.GetNonce(). The end result is the same as before—a single nonce is generated per-request, and only when it's required. The slight breaking change is the fact that you now must call GetNonce() to retrieve the nonce. Previously you could have retrieved the nonce directly from HttpContext.Items (even though you shouldn't); in 1.0.0-preview.1 that's no longer possible.

That covers the majority of the major changes made between versions 0.24.0 and 1.0.0-preview.1. You can find the full diff here, including an API diff (using the workflow I described in a previous post).

Summary

Adding security-related headers to your HTTP responses is an easy way to harden your application against attacks. NetEscapades.AspNetCore.SecurityHeaders provides an easy way to do this, and has recently been through some major changes that I'm looking for feedback on before releasing the final stable version.

In summary, the major changes are:

  • Support for .NET Core 3.1+ only
  • Updates to headers
    • Changes to the default headers
    • New utility methods for adding API-related security headers and a locked-down Permissions-Policy header
    • X-XSS-Protection, Expect-CT, and Feature-Policy are now obsolete
  • Support for applying different header policies to different endpoints
  • Support for customizing the header policy per-request
  • Removal of "document header" concept
  • Changes to nonce generation.

If you can, please give 1.0.0-preview.1 a try, especially if you're currently relying on workarounds for any of the customisation functionality, and let me know what you think by opening an issue on GitHub. Thanks!


Viewing all articles
Browse latest Browse all 743

Trending Articles