How to Secure Your Kentico Xperience Site With Content Security Policy

In this blog post, I will outline what I’ve done to solve the issue of applying CSP headers to a pre-existing site.

So you’ve been tasked with applying CSP headers, and now you’ve landed here. Welcome! Hopefully, I can alleviate many of the problems you might run into. Perhaps you don’t know what CSP headers even are.

Recently, we at BizStream have seen an uptick in the awareness and need for Content Security Policy headers on our clients’ sites. Here, I will outline what I’ve done to solve the issue of applying CSP headers to a pre-existing site.

What Are CSP Headers?

From MDN, Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross-Site Scripting (XSS) and data injection attacks. These attacks are used for everything from data theft to site defacement, to malware distribution.

In other words, it’s a way to whitelist everything that your site uses as a safe resource, such that it will reject anything else. While its purpose is to protect your site from malicious attacks, it is only your first line of defense and is not intended to be a complete solution for preventing attacks. For a more in-depth explanation of Content Security Policy, you can check out Mike West and Joe Medley’s article, An Introduction to Content Security Policy.

From MDN, Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross-Site Scripting (XSS) and data injection attacks. These attacks are used for everything from data theft to site defacement, to malware distribution.

In other words, it’s a way to whitelist everything that your site uses as a safe resource, such that it will reject anything else. While its purpose is to protect your site from malicious attacks, it is only your first line of defense and is not intended to be a complete solution for preventing attacks. For a more in-depth explanation of Content Security Policy, you can check out Mike West and Joe Medley’s article, An Introduction to Content Security Policy.

Policy Directives

Policy directives describe rules for a particular resource or policy area. For example, the following policy directive will allow content from the same domain and trusted.com, including all subdomains.

				
					script-src 'self' trusted.com *.trusted.com
				
			

I won’t go over every available directive (there are a lot of them!). The best practice would be to set every available directive, if possible. The important ones to note are script-src and style-src. With the CSP Level 3 specification, we find a saving grace in the new strict-dynamic directive. This allows us to mark which inline scripts and styles are safe to run. Consequently, this also automatically marks scripts and styles inserted by the marked scripts as safe to run. For example, Google Tag Manager will insert analytics scripts when it initializes. If you’re wondering about what a CSP level is, it’s just the specification version. Level 3 is the latest iteration as of writing this blog post. If you’re interested in reading up, you can go directly to the source from W3.

Initial Problems

When working with Kentico, there are quite a few hoops to jump through to ensure your site is secure. Unfortunately, to fully secure the Kentico Mother, we would need to edit the source files of Kentico. This is a big red flag for pain down the line when upgrading to the next Kentico version. Therefore, we are currently unable to secure the Kentico Mother with CSP and will need to create a bypass instead.

Amongst finding all sources for our resources to add to our CSP headers, we have a small laundry list of things we need to add for our site to function with the CSP restrictions. My example was built in .NET Framework 4.8; the same concept is possible but built differently in .NET Core. However, The concepts should still carry over.

Here’s an outline of the things we need to do in order to secure our site:

  • Create an HtmlOutput filter to apply nonces to all inline scripts and styles
  • Create an HtmlOutput filter to remove inline onsubmit event handlers in Kentico’s FormBuilder forms and add a script to use an event listener instead.
  • Create a conditional filter that only applies our CSP headers when the request is for the live site resources.
  • Add environment variables for development URLs like localhost.

The Solution

First, we can create the HtmlOutputFilter base class that our other filters will inherit in order to share the ability to parse and edit the HtmlOutput of our controllers.

				
					// Filters/HtmlOutputFilter.cs
public class HtmlOutputFilter : MemoryStream
{
    private readonly Stream stream;

    public HtmlOutputFilter(Stream stream)
    {
        this.stream = stream;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        var html = Encoding.UTF8.GetString(buffer);
        html = TransformHtmlOutput(html);
        buffer = Encoding.UTF8.GetBytes(html);
        stream.Write(buffer, offset, buffer.Length);
    }

    public virtual string TransformHtmlOutput(string htmlString)
    {
        throw new Exception($"{nameof(HtmlOutputFilter)} was not overridden or base.${nameof(TransformHtmlOutput)} was called.");
    }
}

				
			

Next, we can build out our two filters to add nonces and remove the inline onsubmit event handlers.


On the topic of nonces, I should probably explain what these are a little. Nonces are a one-time use value. For CSP headers, we supply a nonce to the headers themselves and also add a nonce attribute to each inline script and style tag on our page. This way, the browser knows which inline elements to trust. If we were to statically define our nonces, they would be meaningless. An attacker could simply look at the nonce value, add it to their scripts, and inject their malicious scripts on the next request. What we are doing is using NWebsec to dynamically generate a nonce per request. That way, an attacker does not have a way to know the nonce beforehand.

				
					// Filters/AddNonceToScriptsAndStylesFilter.cs
public class AddNonceToScriptsAndStylesFilter : HtmlOutputFilter
{
    public AddNonceToScriptsAndStylesFilter(Stream stream)
        : base(stream) { }

    public override string TransformHtmlOutput(string htmlString)
    {
        var nonceMatch = Regex.Match(htmlString, "nonce=\"(.*?)\"");

        // Regex gets all script tags that do not have a nonce attribute.
        var stringWithNonces = Regex.Replace(
            htmlString,
            "(<script)((?!.*?(?:nonce))(\\s|.)*?</script>(\\r\\n)?)|(<style)((?!.*?(?:nonce))(\\s|.)*?</style>(\\r\\n)?)",
            match => $"{match.Groups[1]} {nonceMatch}{match.Groups[2]}");

        return stringWithNonces;
    }
}
// Filters/RemoveKenticoFormInlineEventHandlersFilter
public class RemoveKenticoFormInlineEventHandlersFilter : HtmlOutputFilter
{
    public RemoveKenticoFormInlineEventHandlersFilter(Stream stream)
        : base(stream) { }

    public override string TransformHtmlOutput(string htmlString)
    {
        // Regex gets all form tags with an inline `onsubmit` event handler.
        // Captures the inline `onsubmit` event handler script.
        var matches = Regex.Matches(htmlString, "<form(?:.*?)id=\"(?:.*?)\"(?:.*?)onsubmit=\"(.*?)\">");

        foreach (Match match in matches)
        {
            // Remove `onsubmit` event handler.
            htmlString = htmlString.Replace($"onsubmit=\"{match.Groups[1]}\"", string.Empty);
        }

        return htmlString;
    }
}
Then, we can implement our filters as attributes.
// Attributes/AddNonceToScriptsAndStylesAttribute.cs
public class AddNonceToScriptsAndStylesAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var response = filterContext.HttpContext.Response;

        if (response.Filter == null)
        {
            return;
        }

        response.Filter = new AddNonceToScriptsAndStylesFilter(response.Filter);
    }
}
// Attributes/RemoveKenticoFormInlineEventHandlersAttribute.cs
public class RemoveKenticoFormInlineEventHandlersAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var response = filterContext.HttpContext.Response;

        if (response.Filter == null)
        {
            return;
        }

        response.Filter = new RemoveKenticoFormInlineEventHandlersFilter(response.Filter);
    }
}

				
			

We will also create a conditional filter that only applies our CSP headers and attributes when accessing data from the live site, ignoring the Kentico Mother. The conditional filter is a higher order function that will return the function to be used in the global filter provider. There are two lists of strings that let you configure subdomains or paths. They signify requests from the admin application. cmsctx/ is present for Form Builder. Be careful with the paths; otherwise, you may accidentally collide with a live site path and leave a vulnerability. For example, you might want to add admin/ to the path list. If you have an admin page on the live site, you will end up disabling CSP headers for that page.

				
					// Filters/KenticoConditionalFilter.cs
public static class KenticoConditionalFilter
{
    private static readonly string[] AdminSubdomains = new[] { "admin.", "devadmin.", "stageadmin." };
    private static readonly string[] AdminPathSegments = new[] { "cmsctx/" };

    public static Func<ControllerContext, ActionDescriptor, object> AddWhenNotKentico(object filter)
        => (ControllerContext controllerContext, ActionDescriptor actionDescriptor)
        =>
        {
            var isPreviewMode = controllerContext.HttpContext.Kentico().Preview().Enabled;
            var isEditMode = controllerContext.HttpContext.Kentico().PageBuilder().EditMode;
            var urlReferrer = controllerContext.HttpContext.Request.UrlReferrer;
            var isRequestFromAdmin = urlReferrer?.Port == 51872
                || AdminSubdomains.Any(subdomain => urlReferrer?.Host.StartsWith(subdomain) == true)
                || AdminPathSegments.Any(pathSegment => urlReferrer?.Segments.Any(segment => pathSegment == segment) == true);

            if (isPreviewMode || isEditMode || isRequestFromAdmin)
            {
                return null;
            }

            return filter;
        };
}

				
			

Now we will create a FilterConfig that will define the CSP headers and attributes to be used globally. We are utilizing NWebsec to add CSP headers to our responses.

You can check out these resources to learn more about the directives and CSP as a whole. 

Content Security Policy Reference, Content Security Policy (CSP)

Something to note, you can support older browsers that don’t have full support by including self, unsafe-inline, http:, and https: in the script-src directive. These will be ignored as long as strict-dynamic is present. In browsers that don’t support strict-dynamic the source will be ignored, and the other sources will be used instead.

You can also test your headers without preventing content from loading by using the ReportOnly equivalent of each attribute from NWebsec. Pairing this with the CspReportUriReportOnlyAttribute lets you post information on violations that would have occurred if the policy was active.

				
					// FilterConfig.cs
public class FilterConfig
{
    public static void Configure()
    {
        var conditions = new Func<ControllerContext, ActionDescriptor, object>[]
            {
                // Report Only
                // KenticoConditionalFilter.AddWhenNotKentico(new CspReportOnlyAttribute()),
                // KenticoConditionalFilter.AddWhenNotKentico(new CspBaseUriReportOnlyAttribute { Self = true }),
                // KenticoConditionalFilter.AddWhenNotKentico(new CspDefaultSrcReportOnlyAttribute { Self = true }),
                // KenticoConditionalFilter.AddWhenNotKentico(new CspScriptSrcReportOnlyAttribute { Self = true, StrictDynamic = true, UnsafeInline = true }),

                KenticoConditionalFilter.AddWhenNotKentico(new XContentTypeOptionsAttribute()),
                KenticoConditionalFilter.AddWhenNotKentico(new CspAttribute()),
                KenticoConditionalFilter.AddWhenNotKentico(new CspBaseUriAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspDefaultSrcAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspScriptSrcAttribute { Self = true, StrictDynamic = true, UnsafeInline = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspStyleSrcAttribute { Self = true, UnsafeInline = true, CustomSources = "*.google.com https://fonts.googleapis.com/css" }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspImgSrcAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspObjectSrcAttribute { None = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspMediaSrcAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspFrameSrcAttribute { Self = true, CustomSources = "*.vimeo.com *.youtube.com" }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspFrameAncestorsAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspChildSrcAttribute { None = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspFontSrcAttribute { Self = true, CustomSources = "https://fonts.googleapis.com/css https://fonts.gstatic.com" }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspConnectSrcAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspFormActionAttribute { Self = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new CspWorkerSrcAttribute { None = true }),
                KenticoConditionalFilter.AddWhenNotKentico(new AddNonceToScriptsAndStylesAttribute()),
                KenticoConditionalFilter.AddWhenNotKentico(new RemoveKenticoFormInlineEventHandlersAttribute()),
            };

        var conditionalFilterProvider = new ConditionalFilterProvider(conditions);

        FilterProviders.Providers.Add(conditionalFilterProvider);
    }
}
In Global.asax.cs, we call Configure from the FilterConfig to register our conditional filter provider.
// Global.asax.cs
public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        // Registers filters
        FilterConfig.Configure();

        // Enables and configures selected Kentico ASP.NET MVC integration features
        ApplicationConfig.RegisterFeatures(ApplicationBuilder.Current);

        // Registers routes including system routes for enabled features
        RouteConfig.RegisterRoutes(RouteTable.Routes);

        // Registers enabled bundles
        BundleConfig.RegisterBundles(BundleTable.Bundles);
    }
}

				
			

In the layout, we add an empty script with a manually applied nonce so that we can add it to the other styles and scripts in the HtmlOutput. We also add the event listener to replace the ones we removed with RemoveKenticoFormInlineEventHandlersFilter.cs.

				
					<!-- Layout.cshtml -->
<head>
    @*Empty script with nonce so that the filters can retrieve it.*@
    <script @Html.CspScriptNonce()></script>
</head>

<body>
    <!-- At the bottom of the body -->
    <script>
        // Adds an event listener to the document that will catch submit events on Kentico forms.
        document.addEventListener('submit', function(event) {
            let target = event.target;

            while(target && target !== this) {
                if (target.matches('form[data-ktc-ajax-update]')) {
                    window.kentico.updatableFormHelper.submitForm(event);
                }

                target = target.parentNode;
            }
        });
    </script>
</ body>

				
			

Now you can run your application! You may find that you missed a couple of spots, or your directives may be a bit too restrictive. This is a lot of trial and error. Each site is unique, so I can only help you so much. You may even run into issues I haven’t experienced yet. To analyze the strength of your headers, you can use CSP Scanner. Overall, CSP headers can be quite a beast to conquer, especially if you’re retrofitting the headers. Hopefully, this post helps make your life easier when trying to implement CSP into your application.

Referenced Resources

About the Author

Nick Kooman

Nick began as an intern at BizStream, not even a year after graduating from high school, and is now a full-time developer! Nick thinks that growth stagnates when you are comfortable, and the best moments in life happen when you are uncomfortable. He believes he would not have had the chance to make a connection with BizStream if he had not stepped out of his comfort zone during school. When he’s not working, Nick spends time with friends, plays video games, and watches YouTube.

Subscribe to Our Blog

Stay up to date on what BizStream is doing and keep in the loop on the latest in marketing & technology.