November Happy Hour will be moved to Thursday December 5th.

Ethan Schofer
Feb 21, 2024
  973
(1 votes)

Roll Your Own Security Headers

Proper security headers are a must for your Optimizely driven website. There are a variety of tools out there that will help with this, but when possible I prefer to roll my own solution, especially for such a low level feature as security headers. No need to keep a NuGet package up to date. No need to worry about constant version updates. Its all self contained in the code base that I control. I have done this with some custom middleware that can be set up in Startup.cs. The headers this could add include:

  • X-Content-Type-Options
  • X-Frame-Options
  • Server
  • Strict-Transport-Security
  • X-XSS-Protection
  • Referrer-Policy
  • Permissions-Policy
  • Content-Security-Policy
  • X-Download-Options

The first thing I need to do is create some constants classes that contain all the header names and values I will need. Each class contains a string of the header name and strings of any options. Content Security Policy and Permissions Policy, being a little more complex, also needs a enum to cover some options.

X-Content-Type-Options

/// <summary>
/// X-Content-Type-Options-related constants.
/// </summary>
public static class ContentTypeOptionsConstants
{
    /// <summary>
    /// Header value for X-Content-Type-Options
    /// </summary>
    public static readonly string Header = "X-Content-Type-Options";

    /// <summary>
    /// Disables content sniffing
    /// </summary>
    public static readonly string NoSniff = "nosniff";
}

In this case I only have the nosniff options because that is what I will be using.

X-Frame-Options

/// <summary>
/// X-Frame-Options-related constants.
/// </summary>
public static class FrameOptionsConstants
{
    /// <summary>
    /// The header value for X-Frame-Options
    /// </summary>
    public static readonly string Header = "X-Frame-Options";

    /// <summary>
    /// The page cannot be displayed in a frame, regardless of the site attempting to do so.
    /// </summary>
    public static readonly string Deny = "DENY";

    /// <summary>
    /// The page can only be displayed in a frame on the same origin as the page itself.
    /// </summary>
    public static readonly string SameOrigin = "SAMEORIGIN";

    /// <summary>
    /// The page can only be displayed in a frame on the specified origin. {0} specifies the format string
    /// </summary>
    public static readonly string AllowFromUri = "ALLOW-FROM {0}";
}

Server

/// <summary>
/// Server headery-related constants.
/// </summary>
public static class ServerConstants
{
    /// <summary>
    /// The header value for X-Powered-By
    /// </summary>
    public static readonly string Header = "Server";
}

Strict-Transportat-Security

/// <summary>
/// Strict-Transport-Security-related constants.
/// </summary>
public static class StrictTransportSecurityConstants
{
    /// <summary>
    /// Header value for Strict-Transport-Security
    /// </summary>
    public static readonly string Header = "Strict-Transport-Security";

    /// <summary>
    /// Tells the user-agent to cache the domain in the STS list for the provided number of seconds {0} 
    /// </summary>
    public static readonly string MaxAge = "max-age={0}";

    /// <summary>
    /// Tells the user-agent to cache the domain in the STS list for the provided number of seconds {0} and include any subdomains.
    /// </summary>
    public static readonly string MaxAgeIncludeSubdomains = "max-age={0}; includeSubDomains";

    /// <summary>
    /// Tells the user-agent to remove, or not cache the host in the STS cache.
    /// </summary>
    public static readonly string NoCache = "max-age=0";
}

X-XSS-Protection

/// <summary>
/// X-XSS-Protection-related constants.
/// </summary>
public static class XssProtectionConstants
{
    /// <summary>
    /// Header value for X-XSS-Protection
    /// </summary>
    public static readonly string Header = "X-XSS-Protection";

    /// <summary>
    /// Enables the XSS Protections
    /// </summary>
    public static readonly string Enabled = "1";

    /// <summary>
    /// Disables the XSS Protections offered by the user-agent.
    /// </summary>
    public static readonly string Disabled = "0";

    /// <summary>
    /// Enables XSS protections and instructs the user-agent to block the response in the event that script has been inserted from user input, instead of sanitizing.
    /// </summary>
    public static readonly string Block = "1; mode=block";

    /// <summary>
    /// A partially supported directive that tells the user-agent to report potential XSS attacks to a single URL. Data will be POST'd to the report URL in JSON format. 
    /// {0} specifies the report url, including protocol
    /// </summary>
    public static readonly string Report = "1; report={0}";
}

Referrer-Policy

/// <summary>
/// Referrer Policy related constants
/// </summary>
public static class ReferrerPolicyConstants
{
    /// <summary>
    /// Header value for Referrer-Policy
    /// </summary>
    public static readonly string Header = "Referrer-Policy";

    public static readonly string NoReferrer = "no-referrer";

    public static readonly string NoReferrerWhenDowngrade = "no-referrer-when-downgrade";

    public static readonly string SameOrigin = "same-origin";

    public static readonly string Origin = "origin";

    public static readonly string StrictOrigin = "strict-origin";

    public static readonly string OriginWhenCrossOrigin = "origin-when-cross-origin";

    public static readonly string StrictOriginWhenCrossOrigin = "strict-origin-when-cross-origin";

    public static readonly string UnsafeUrl = "unsafe-url";
}

Permissions-Policy

/// <summary>
/// Permissions Policy related constants
/// </summary>
public static class PermissionsPolicyConstants
{
    /// <summary>
    /// Header value for Permissions-Policy
    /// </summary>
    public static readonly string Header = "Permissions-Policy";
}

When setting permissions policy, there are a variety of functions that you can set permissions for. The various functions I have put into an enum:

/// <summary>
/// Enum of some permission policies
/// </summary>
public enum PermissionPolicy
{
    [EnumSelectionDescription(Text = "Accelerometer", Value = "accelerometer")]
    Accelerometer = 1,

    [EnumSelectionDescription(Text = "Camera", Value = "camera")]
    Camera = 2,

    [EnumSelectionDescription(Text = "Geolocation", Value = "")]
    Geolocation = 3,

    [EnumSelectionDescription(Text = "Gyroscope", Value = "gyroscope")]
    Gyroscope = 4,

    [EnumSelectionDescription(Text = "Magnetometer", Value = "magnetometer")]
    Magnetometer = 5,

    [EnumSelectionDescription(Text = "Microphone", Value = "microphone")]
    Microphone = 6,

    [EnumSelectionDescription(Text = "Payment", Value = "payment")]
    Payment = 7,

    [EnumSelectionDescription(Text = "Usb", Value = "usb")]
    Usb = 8
}

Content-Security-Policy

/// <summary>
/// Permissions Policy related constants
/// </summary>
public static class ContentSecurityPolicyConstants
{
    /// <summary>
    /// Header value for Permissions-Policy
    /// </summary>
    public static readonly string Header = "Content-Security-Policy";

}

Content Security Policy needs to be set for a variety of types of content. I control these with an enum also:

public enum ContentSecurityPolicy
{
    [EnumSelectionDescription(Text = "DefaultSource", Value = "default-src 'self'")]
    DefaultSource = 1,

    [EnumSelectionDescription(Text = "ConnectSource", Value = " connect-src * 'self' data: https:")]
    ConnectSource = 2,

    [EnumSelectionDescription(Text = "FontSource", Value = " font-src 'self' data: https:")]
    FontSource = 3,

    [EnumSelectionDescription(Text = "FrameSource", Value = " frame-src 'self' data: https:")]
    FrameSource = 4,

    [EnumSelectionDescription(Text = "ImageSource", Value = "  img-src * 'self' data: https: blob:")]
    ImageSource = 5,

    [EnumSelectionDescription(Text = "ScriptSource", Value = " script-src 'self' 'nonce-{nonceValue}' 'strict-dynamic' ")]
    ScriptSource = 6,

    [EnumSelectionDescription(Text = "StyleSource", Value = " style-src 'self' 'unsafe-inline' *")]
    StyleSource = 7,

    [EnumSelectionDescription(Text = "FormAction", Value = " form-action 'self' data: https:")]
    FormAction = 8,

    [EnumSelectionDescription(Text = "MediaSource", Value = " media-src 'self' data: https: blob:")]
    MediaSource = 9
}

X-Download-Options

/// <summary>
/// Permissions Policy related constants
/// </summary>
public static class XDownloadOptionsConstants
{
    /// <summary>
    /// Header value for Permissions-Policy
    /// </summary>
    public static readonly string Header = "X-Download-Options";

    public static readonly string NoOpen = "noopen";
}

The real work I do is in a builder class, SecurityHeadersBuilder. This builder has a variety of methods for setting the different header values. I then create a method that builds up the security policy. You could conceptually have a variety of policies. I only need one, so I have a default method that creates all of my headers. It stores them in a policy class.

First the policy class. Its pretty straight forward, it just has an add and remove method for setting headers.

/// <summary>
/// The security headers policy
/// </summary>
public class SecurityHeadersPolicy
{
    /// <summary>
    /// Headers to add
    /// </summary>
    public IDictionary<string, string> SetHeaders { get; }
        = new Dictionary<string, string>();

    /// <summary>
    /// Headers to remove
    /// </summary>
    public ISet<string> RemoveHeaders { get; }
        = new HashSet<string>();
}

It stores these values in a dictionary.

The builder then adds the headers to this policy:

/// <summary>
/// Middle ware to create the desired security headers
/// </summary>
public class SecurityHeadersBuilder
{
    private readonly SecurityHeadersPolicy _policy = new();

    /// <summary>
    /// The number of seconds in one year
    /// </summary>
    public const int OneYearInSeconds = 60 * 60 * 24 * 365;

    private CompositeFormat FrameOptionsAllowFromUri { get; set; } = CompositeFormat.Parse(FrameOptionsConstants.AllowFromUri);

    private CompositeFormat XssProtectionConstantsReport { get; set; } = CompositeFormat.Parse(XssProtectionConstants.Report);

    private CompositeFormat StrictTransportSecurityConstantsMaxAge { get; set; } = CompositeFormat.Parse(StrictTransportSecurityConstants.MaxAge);

    private CompositeFormat StrictTransportSecurityConstantsMaxAgeIncludeSubdomains { get; set; } = CompositeFormat.Parse(StrictTransportSecurityConstants.MaxAgeIncludeSubdomains);

    /// <summary>
    /// Add default headers in accordance with most secure approach
    /// </summary>
    public SecurityHeadersBuilder AddDefaultSecurePolicy()
    {
        this.AddXssProtectionBlock();
        this.AddStrictTransportSecurityMaxAge();
        this.AddPermissionsPolicy();
        this.AddXssProtectionBlock();
        this.AddContentTypeOptionsNoSniff();
        this.AddReferrerPolicyStrictOriginWhenCrossOrigin();
        this.AddXDownloadOptionsNoOpen();

        return this;
    }

    /// <summary>
    /// Add X-Frame-Options DENY to all requests.
    /// The page cannot be displayed in a frame, regardless of the site attempting to do so
    /// </summary>
    public SecurityHeadersBuilder AddFrameOptionsDeny()
    {
        this._policy.SetHeaders[FrameOptionsConstants.Header] = FrameOptionsConstants.Deny;
        return this;
    }

    /// <summary>
    /// Add X-Frame-Options SAMEORIGIN to all requests.
    /// The page can only be displayed in a frame on the same origin as the page itself.
    /// </summary>
    public SecurityHeadersBuilder AddFrameOptionsSameOrigin()
    {
        this._policy.SetHeaders[FrameOptionsConstants.Header] = FrameOptionsConstants.SameOrigin;
        return this;
    }

    /// <summary>
    /// Add X-Frame-Options ALLOW-FROM {uri} to all requests, where the uri is provided
    /// The page can only be displayed in a frame on the specified origin.
    /// </summary>
    /// <param name="uri">The uri of the origin in which the page may be displayed in a frame</param>
    public SecurityHeadersBuilder AddFrameOptionsSameOrigin(string uri)
    {
        this._policy.SetHeaders[FrameOptionsConstants.Header] = string.Format(CultureInfo.InvariantCulture, this.FrameOptionsAllowFromUri, uri);
        return this;
    }


    /// <summary>
    /// Add X-XSS-Protection 1 to all requests.
    /// Enables the XSS Protections
    /// </summary>
    public SecurityHeadersBuilder AddXssProtectionEnabled()
    {
        this._policy.SetHeaders[XssProtectionConstants.Header] = XssProtectionConstants.Enabled;
        return this;
    }

    /// <summary>
    /// Add X-XSS-Protection 0 to all requests.
    /// Disables the XSS Protections offered by the user-agent.
    /// </summary>
    public SecurityHeadersBuilder AddXssProtectionDisabled()
    {
        this._policy.SetHeaders[XssProtectionConstants.Header] = XssProtectionConstants.Disabled;
        return this;
    }

    /// <summary>
    /// Add X-XSS-Protection 1; mode=block to all requests.
    /// Enables XSS protections and instructs the user-agent to block the response in the event that script has been inserted from user input, instead of sanitizing.
    /// </summary>
    public SecurityHeadersBuilder AddXssProtectionBlock()
    {
        this._policy.SetHeaders[XssProtectionConstants.Header] = XssProtectionConstants.Block;
        return this;
    }

    /// <summary>
    /// Add X-XSS-Protection 1; report=http://site.com/report to all requests.
    /// A partially supported directive that tells the user-agent to report potential XSS attacks to a single URL. Data will be POST'd to the report URL in JSON format.
    /// </summary>
    public SecurityHeadersBuilder AddXssProtectionReport(string reportUrl)
    {
        this._policy.SetHeaders[XssProtectionConstants.Header] =
            string.Format(CultureInfo.InvariantCulture, this.XssProtectionConstantsReport, reportUrl);
        return this;
    }

    /// <summary>
    /// Add Strict-Transport-Security max-age=<see cref="maxAge"/> to all requests.
    /// Tells the user-agent to cache the domain in the STS list for the number of seconds provided.
    /// </summary>
    public SecurityHeadersBuilder AddStrictTransportSecurityMaxAge(int maxAge = OneYearInSeconds)
    {
        this._policy.SetHeaders[StrictTransportSecurityConstants.Header] =
            string.Format(CultureInfo.InvariantCulture, this.StrictTransportSecurityConstantsMaxAge, maxAge);
        return this;
    }

    /// <summary>
    /// Add Strict-Transport-Security max-age=<see cref="maxAge"/>; includeSubDomains to all requests.
    /// Tells the user-agent to cache the domain in the STS list for the number of seconds provided and include any subdomains.
    /// </summary>
    public SecurityHeadersBuilder AddStrictTransportSecurityMaxAgeIncludeSubDomains(int maxAge = OneYearInSeconds)
    {
        this._policy.SetHeaders[StrictTransportSecurityConstants.Header] =
            string.Format(CultureInfo.InvariantCulture, this.StrictTransportSecurityConstantsMaxAgeIncludeSubdomains, maxAge);
        return this;
    }

    /// <summary>
    /// Add Strict-Transport-Security max-age=0 to all requests.
    /// Tells the user-agent to remove, or not cache the host in the STS cache
    /// </summary>
    public SecurityHeadersBuilder AddStrictTransportSecurityNoCache()
    {
        this._policy.SetHeaders[StrictTransportSecurityConstants.Header] =
            StrictTransportSecurityConstants.NoCache;
        return this;
    }

    /// <summary>
    /// Add X-Content-Type-Options nosniff to all requests.
    /// Can be set to protect against MIME type confusion attacks.
    /// </summary>
    public SecurityHeadersBuilder AddContentTypeOptionsNoSniff()
    {
        this._policy.SetHeaders[ContentTypeOptionsConstants.Header] = ContentTypeOptionsConstants.NoSniff;
        return this;
    }

    /// <summary>
    /// Removes the Server header from all responses
    /// </summary>
    public SecurityHeadersBuilder RemoveServerHeader()
    {
        this._policy.RemoveHeaders.Add(ServerConstants.Header);
        return this;
    }

    /// <summary>
    /// Add the XDownload option headers
    /// </summary>
    /// <returns></returns>
    public SecurityHeadersBuilder AddXDownloadOptionsNoOpen()
    {
        this._policy.SetHeaders[XDownloadOptionsConstants.Header] = XDownloadOptionsConstants.NoOpen;
        return this;

    }

    /// <summary>
    /// Adds a custom header to all requests
    /// </summary>
    /// <param name="header">The header name</param>
    /// <param name="value">The value for the header</param>
    /// <returns></returns>
    public SecurityHeadersBuilder AddCustomHeader(string header, string value)
    {
        if (string.IsNullOrEmpty(header))
        {
            throw new ArgumentNullException(nameof(header));
        }

        this._policy.SetHeaders[header] = value;
        return this;
    }

    /// <summary>
    /// Remove a header from all requests
    /// </summary>
    /// <param name="header">The to remove</param>
    /// <returns></returns>
    public SecurityHeadersBuilder RemoveHeader(string header)
    {
        if (string.IsNullOrEmpty(header))
        {
            throw new ArgumentNullException(nameof(header));
        }

        this._policy.RemoveHeaders.Add(header);
        return this;
    }

    /// <summary>
    /// Add referrer policy header of NoReferrer
    /// </summary>
    /// <returns></returns>
    public SecurityHeadersBuilder AddReferrerPolicyNoReferrer()
    {
        this._policy.SetHeaders[ReferrerPolicyConstants.Header] = ReferrerPolicyConstants.NoReferrer;
        return this;
    }

    /// <summary>
    /// Add referrer policy header of NoReferrer
    /// </summary>
    /// <returns></returns>
    public SecurityHeadersBuilder AddReferrerPolicyStrictOriginWhenCrossOrigin()
    {
        this._policy.SetHeaders[ReferrerPolicyConstants.Header] = ReferrerPolicyConstants.StrictOriginWhenCrossOrigin;
        return this;
    }

    /// <summary>
    /// Add the permissions policy
    /// </summary>
    /// <returns></returns>
    public SecurityHeadersBuilder AddPermissionsPolicy()
    {
        // Get all permissions policies
        var permissionPolicies = Enum.GetValues<PermissionPolicy>();

        // Loop through the policies
        var policy = (from permissionPolicy in permissionPolicies
                      select permissionPolicy.Value() into value
                      where !string.IsNullOrEmpty(value)
                      select $"{value}=()").ToList();

        this._policy.SetHeaders[PermissionsPolicyConstants.Header] = string.Join(',', policy.ToArray());
        return this;
    }

    /// <summary>
    /// Builds a new <see cref="SecurityHeadersPolicy"/> using the entries added.
    /// </summary>
    /// <returns>The constructed <see cref="SecurityHeadersPolicy"/>.</returns>
    public SecurityHeadersPolicy Build() => this._policy;
}

Breaking down whats happening here:

First I create the policy:

private readonly SecurityHeadersPolicy _policy = new();

Then I create a constant to store 1 year in seconds (For the Strinct Transport Security header, I set a max age, which should be a year)

/// <summary>
/// The number of seconds in one year
/// </summary>
public const int OneYearInSeconds = 60 * 60 * 24 * 365;

Next, I have a few header values that need string replacement. For each of these I create a CompositeFormat object by parsing the header value. Creating the CompositeFormat caches the parsing of this string, adding a small performance boost on subsequent calls. The replacement will happen later:

private CompositeFormat FrameOptionsAllowFromUri { get; set; } = CompositeFormat.Parse(FrameOptionsConstants.AllowFromUri);

private CompositeFormat XssProtectionConstantsReport { get; set; } = CompositeFormat.Parse(XssProtectionConstants.Report);

private CompositeFormat StrictTransportSecurityConstantsMaxAge { get; set; } = CompositeFormat.Parse(StrictTransportSecurityConstants.MaxAge);

private CompositeFormat StrictTransportSecurityConstantsMaxAgeIncludeSubdomains { get; set; } = CompositeFormat.Parse(StrictTransportSecurityConstants.MaxAgeIncludeSubdomains);

In this case, if you look above, you can see that FrameOptionsConstants.AllowFromUri equals "ALLOW-FROM {0}".  When I set this header, I will replace '{0}' with the url.

Next I create a method for setting each header value. For example, for FrameOptions, I have three methods for setting the different values:

/// <summary>
/// Add X-Frame-Options DENY to all requests.
/// The page cannot be displayed in a frame, regardless of the site attempting to do so
/// </summary>
public SecurityHeadersBuilder AddFrameOptionsDeny()
{
    this._policy.SetHeaders[FrameOptionsConstants.Header] = FrameOptionsConstants.Deny;
    return this;
}

/// <summary>
/// Add X-Frame-Options SAMEORIGIN to all requests.
/// The page can only be displayed in a frame on the same origin as the page itself.
/// </summary>
public SecurityHeadersBuilder AddFrameOptionsSameOrigin()
{
    this._policy.SetHeaders[FrameOptionsConstants.Header] = FrameOptionsConstants.SameOrigin;
    return this;
}

/// <summary>
/// Add X-Frame-Options ALLOW-FROM {uri} to all requests, where the uri is provided
/// The page can only be displayed in a frame on the specified origin.
/// </summary>
/// <param name="uri">The uri of the origin in which the page may be displayed in a frame</param>
public SecurityHeadersBuilder AddFrameOptionsSameOrigin(string uri)
{
    this._policy.SetHeaders[FrameOptionsConstants.Header] = string.Format(CultureInfo.InvariantCulture, this.FrameOptionsAllowFromUri, uri);
    return this;
}

Each method sets the header to the desired value. Using the policy object I created, it adds the header using the constant of the header name from our constants classes, and then the constant of the desired value.

Removing the server header is common, so I have a specific method for this:

/// <summary>
/// Removes the Server header from all responses
/// </summary>
public SecurityHeadersBuilder RemoveServerHeader()
{
    this._policy.RemoveHeaders.Add(ServerConstants.Header);
    return this;
}

For permission policy, I want to allow all possible permissions of features, so I just iterate over the enum and add all the values.

/// <summary>
/// Add the permissions policy
/// </summary>
/// <returns></returns>
public SecurityHeadersBuilder AddPermissionsPolicy()
{
    // Get all permissions policies
    var permissionPolicies = Enum.GetValues<PermissionPolicy>();

    // Loop through the policies
    var policy = (from permissionPolicy in permissionPolicies
                  select permissionPolicy.Value() into value
                  where !string.IsNullOrEmpty(value)
                  select $"{value}=()").ToList();

    this._policy.SetHeaders[PermissionsPolicyConstants.Header] = string.Join(',', policy.ToArray());
    return this;
}

For your needs, you might need to create a different version of this method that only sets the permissions you need.

I have a specific method that creates our default policy:

/// <summary>
/// Add default headers in accordance with most secure approach
/// </summary>
public SecurityHeadersBuilder AddDefaultSecurePolicy()
{
    this.AddXssProtectionBlock();
    this.AddStrictTransportSecurityMaxAge();
    this.AddPermissionsPolicy();    
    this.AddContentTypeOptionsNoSniff();
    this.AddReferrerPolicyStrictOriginWhenCrossOrigin();
    this.AddXDownloadOptionsNoOpen();

    return this;
}

Here I am only calling the methods I want/need to make my policy. Notice Content Security Policy is missing here. I will come back to that.

Lastly I have a 'build' method that returns the policy:

/// <summary>
/// Builds a new <see cref="SecurityHeadersPolicy"/> using the entries added.
/// </summary>
/// <returns>The constructed <see cref="SecurityHeadersPolicy"/>.</returns>
public SecurityHeadersPolicy Build() => this._policy;

Middleware

The next piece I need is the actual middleware to create and use these headers. Middleware requires an invoke method that does the work. That is, it takes the policies created in the builder and adds them as actual headers. Additionally, this is where I add the Content Security Policy.

using System.Globalization;
using EPiServer.Framework.ClientResources;
using Hero.OptimizelyCMS.Foundation.Extensions;
using System.Web;
using Hero.OptimizelyCMS.Foundation.Utilities;

namespace Hero.OptimizelyCMS.Foundation.SecurityPolicy.SecurityMiddleware;

/// <summary>
/// Middleware for setting security headers
/// </summary>
public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly SecurityHeadersPolicy _policy;
    private readonly ICspNonceService _cspNonceService;

    public SecurityHeadersMiddleware(RequestDelegate next, SecurityHeadersPolicy policy, ICspNonceService cspNonceService)
    {
        this._next = next;
        this._policy = policy;
        this._cspNonceService = cspNonceService;
    }

    /// <summary>
    /// Add and remove desired headers
    /// </summary>
    /// <param name="context">The current HttpContext</param>
    /// <returns></returns>
    public async Task Invoke(HttpContext context)
    {
        // Get existing headers
        var headersDictionary = context.Response.Headers;

        // If we should add Csp
        if (ShouldAddCsp(context))
        {
            // Add CSP
            headersDictionary[ContentSecurityPolicyConstants.Header] = this.GetContentSecurityPolicy();

            // Frame options
            headersDictionary[FrameOptionsConstants.Header] = FrameOptionsConstants.SameOrigin;
        }

        // Loop through headers to add
        foreach (var headerValuePair in this._policy.SetHeaders)
        {
            // Add each header
            headersDictionary[headerValuePair.Key] = headerValuePair.Value;
        }

        // Loop through headers to remove
        foreach (var header in this._policy.RemoveHeaders)
        {
            // Remove the header
            headersDictionary.Remove(header);
        }

        await this._next(context);
    }

    /// <summary>
    /// Determine if we should add Csp
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    private static bool ShouldAddCsp(HttpContext context)
    {
        // Get the current url
        var currentUrl = context.Request.Url();
        var path = currentUrl.PathAndQuery;
        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);

        // Get the first segment
        var segmentZero = segments.ElementAtOrDefault(0);

        // If there is no segment zero, we are on the home page
        if (string.IsNullOrEmpty(segmentZero))
        {
            return true;
        }

        // If the segment is one of these, we should not add CSP
        return segmentZero.ToLower(CultureInfo.InvariantCulture) switch
        {
            "error" => false,
            "episerver" => false,
            "util" => false,
            "cleaner" => false,
            "redirectmanager" => false,
            _ => true,
        };
    }

    /// <summary>
    /// Create the Csp
    /// </summary>
    /// <returns></returns>
    public string GetContentSecurityPolicy()
    {
        var policy = new List<string>();

        // Get Csp Enum values
        var contentSecurityPolicies = Enum.GetValues<ContentSecurityPolicy>();

        // Loop through each policy
        foreach (var contentSecurityPolicy in contentSecurityPolicies)
        {
            // Get the policy value
            var value = contentSecurityPolicy.Value();

            // Add the nonce to the policy
            if (string.IsNullOrEmpty(value))
            {
                continue;
            }

            var nonce = this._cspNonceService.GetNonce();
            nonce = HttpUtility.JavaScriptStringEncode(nonce);
            value = value.Replace("{nonceValue}", nonce);
            policy.Add(value);
        }

        // Return the policy
        return string.Join(';', policy.ToArray());
    }
}

And lets break down whats happening here:

I inject the RequestDelegate. This is how the middleware will move on to the next piece of middleware once done here. I add our header policy, and an instance of ICspNonceService, for adding a content security policy nonce (More on this in a bit) .

When Invoke is called, I get the current headers dictionary, I determine if I should add the Content Security Policy, and then I loop through all the appropriate headers in our policy and add them to the response, and remove any headers that should be removed.

I found that adding the Content Security Policy while inside the CMS broke a lot of CMS functionality. So I look at the path and I don't add the security policy if I am in the CMS. This includes the normal cms paths (episerver, util) as well as some custom urls for custom admin tools. You might need to include other paths, depending on any custom tools or third party add ons you have installed.

/// <summary>
/// Determine if we should add Csp
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private static bool ShouldAddCsp(HttpContext context)
{
    // Get the current url
    var currentUrl = context.Request.Url();
    var path = currentUrl.PathAndQuery;
    var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);

    // Get the first segment
    var segmentZero = segments.ElementAtOrDefault(0);

    // If there is no segment zero, we are on the home page
    if (string.IsNullOrEmpty(segmentZero))
    {
        return true;
    }

    // If the segment is one of these, we should not add CSP
    return segmentZero.ToLower(CultureInfo.InvariantCulture) switch
    {
        "error" => false,
        "episerver" => false,
        "util" => false,
        "cleaner" => false,
        "redirectmanager" => false,
        _ => true,
    };
}

If I need to actually build the policy, I loop through the Enum I set up and add these values.

/// <summary>
/// Create the Csp
/// </summary>
/// <returns></returns>
public string GetContentSecurityPolicy()
{
    var policy = new List<string>();

    // Get Csp Enum values
    var contentSecurityPolicies = Enum.GetValues<ContentSecurityPolicy>();

    // Loop through each policy
    foreach (var contentSecurityPolicy in contentSecurityPolicies)
    {
        // Get the policy value
        var value = contentSecurityPolicy.Value();

        // Add the nonce to the policy
        if (string.IsNullOrEmpty(value))
        {
            continue;
        }

        var nonce = this._cspNonceService.GetNonce();
        nonce = HttpUtility.JavaScriptStringEncode(nonce);
        value = value.Replace("{nonceValue}", nonce);
        policy.Add(value);
    }

    // Return the policy
    return string.Join(';', policy.ToArray());
}

There is one small thing to call out here. I want to use a nonce with scripts. A nonce is a hash that is unique for each request. If a script tag doesnt have the nonce, the browser will treat is as suspicious and wont run the script. A script tag with a nonce might look like this:

<script src="/dist/main.min.js?t=638433482440000000" nonce="wm+Z3Sws/IkrrJUnTulrX4M0Lez8VY49of/BSW8vyOw="></script>

I created a custom implementation of this service and I use this to generate the nonce and string replace it in the ScriptSource attribute of the Content Security Policy.

/// <summary>
/// Concrete implementation of nonce service for generating the nonce
/// </summary>
public class CspNonceService : ICspNonceService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CspNonceService(IHttpContextAccessor httpContextAccessor) => this._httpContextAccessor = httpContextAccessor;

    // Cache key
    public const string Key = "csp-nonce";

    /// <summary>
    /// Get the current nonce
    /// </summary>
    /// <returns></returns>
    public string GetNonce()
    {
        // Get the cache
        var items = this._httpContextAccessor.HttpContext?.Items;

        // If we have no items and the key is not in the cache
        if (items != null && !items.ContainsKey(Key))
        {
            // Generate the nonce and add it to cache
            items.Add(Key, GenerateNonce());
        }

        // Get the nonce from cache
        var nonce = items != null ? items[Key] : GenerateNonce();

        // Return the nonce
        return nonce?.ToString();
    }

    /// <summary>
    /// Generate a new nonce
    /// </summary>
    /// <returns></returns>
    private static string GenerateNonce()
    {
        // Generate a new nonce
        var numArray = new byte[32];
        using (var randomNumberGenerator = RandomNumberGenerator.Create())
        {
            randomNumberGenerator.GetBytes(numArray);
        }

        // Convert it to base 64 string
        return Convert.ToBase64String(numArray);
    }
}

Startup

The last step is to register any resources I need, and add the middleware to the pipeline.

In Startup.cs, I register the nonce service in the ConfigureServices method:

// Add nonce service
services.AddScoped<ICspNonceService>(sp => new CspNonceService(sp.GetRequiredService<IHttpContextAccessor>()));

Optimizely can add this to its own script blocks once this is registered (https://docs.developers.optimizely.com/content-management-system/docs/content-security-policy).

NOTE: Using the nonce is an all or nothing proposition. If you implement this, any script tags added to pages will need the nonce. This will require you to manually set it using the ICspNonceService implementation.

Next I create an extension method to make this policy easier to set.

/// <summary>
/// Method for implementing security header middleware
/// </summary>
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseSecurityHeadersMiddleware(this IApplicationBuilder app, SecurityHeadersBuilder builder)
    {
        var policy = builder.Build();
        return app.UseMiddleware<SecurityHeadersMiddleware>(policy);
    }
}

Lastly I add the middleware to the pipeline, also in Startup.cs in the configure method. I add it early in the pipeline so all requests get it:

// Add security headers
app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder()
    .AddDefaultSecurePolicy()
    .RemoveHeader("X-Powered-By")
);

This can be customized easily to your specific needs.

NOTE: I am running locally and you can see that the server header is still there. The Server header for Kestrel has to be removed in a different way, when you configure Kestrel in your code.

Now when I go to my site I can see all of my headers:

And I can see if I log in to the CMS, the Content Security Policy is not being added:

Lastly, if I scan a site using this configuration, I can see that my headers are set up appropriately:

Potential Future Enhancement: With Content Security Policy, here I have constructed a fairly permissive policy. You can actually include specific urls for the various parts of the policy. This would probably best be maintained an appsettings, and then retrieved and added to the policy.

Feb 21, 2024

Comments

Scott Reed
Scott Reed Feb 22, 2024 09:25 AM

Most easiest way it to use one of the multiple module you can install to use it.

Andy just released a brand new updated Jhoose Security Module: https://jhoose.co.uk/2024/02/19/introducing-jhoose-security-module-v2-0/ 

Mark also has a module Stott Security: https://world.optimizely.com/blogs/mark-stott/dates/2023/10/adding-cors-management-to-optimizely-cms-12/ 

Although it's nice to have the auto nonce stuff, I'm not sure if that feature is in those modules

Scott Reed
Scott Reed Feb 22, 2024 09:28 AM

I see you said "There are a variety of tools out there that will help with this, but when possible I prefer to roll my own solution, especially for such a low level feature as security headers. No need to keep a NuGet package up to date. No need to worry about constant version updates."

I guess there's ups and downs with this approach. I'd prefer using something robust and tested by the community that's kept up to date and I can just updated in a few seconds. Manual addition means a lot of management, copying to new projects and versions of it with bug updates becoming out of sync. Fine for a single project but not great in an agency environment

Mark Stott
Mark Stott Feb 22, 2024 11:29 AM

Attempting to be free of Bias as the author of Stott Security.

Both Andy's Jhoose Security Module and my Stott Security module support nonce attributes on script and style tags with a fresh nonce on every request.  I'm working with some clients who have Jhoose Security installed as well as having shipped Stott Security on numerous CMS 12 solutions.  Both are good solutions with feature parity in terms of security headers and approach the requirement with a different user set in mind.

I will add that Stott Security also includes CORS management and includes a full audit of all configuration changes.

Please do check out both solutions and see which works for you as this will come down to a personal and business preference.

Andrew Markham
Andrew Markham Feb 22, 2024 03:02 PM

Not wanting to be left out :)

One of the reasons why I wrote the module was to provide a user interface to manage the Content Security Policy of the site.  I found that on too many occasions a new tool was added via GTM that didn't work due to the CSP.

I imagine the Mark had similar thoughts when he developed his module.

It's good to share different approaches, and give people different options.  

Mark Stott
Mark Stott Feb 22, 2024 03:26 PM

I think we've had very similar journeys there Andy :)

In some ways I wish I'd know about your Jhoose Security Module when I was trying to solve these very problems for CMS 11 clients, but then I don't think I'd have embarked on my own journey if I had been aware of your module.  I would have missed out on so many technical challenges and so much learning and enjoyment.  I hope that Ethan has had a similarly enjoyable journey too.

I'm really glad that our interface choices are so radically different because it really adds choice for our collective users.

Please login to comment.
Latest blogs
Optimizely SaaS CMS + Coveo Search Page

Short on time but need a listing feature with filters, pagination, and sorting? Create a fully functional Coveo-powered search page driven by data...

Damian Smutek | Nov 21, 2024 | Syndicated blog

Optimizely SaaS CMS DAM Picker (Interim)

Simplify your Optimizely SaaS CMS workflow with the Interim DAM Picker Chrome extension. Seamlessly integrate your DAM system, streamlining asset...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Optimizely CMS Roadmap

Explore Optimizely CMS's latest roadmap, packed with developer-focused updates. From SaaS speed to Visual Builder enhancements, developer tooling...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Set Default Culture in Optimizely CMS 12

Take control over culture-specific operations like date and time formatting.

Tomas Hensrud Gulla | Nov 15, 2024 | Syndicated blog