26-04-2024

Implementing CSP Level 3 on Kentico 13

Implementing CSP Level 3 on Kentico 13

Kentico 13 does not support CSP Level 3, full stop. It says so in their documentation here (opens new window).

This guide will show you how it is possible by making adjustments to our application to allow how it will work.

Short CSP summary

So we are all up to speed. CSP allows better security for your website. CSP Level 3 will allow you to precisely dictate browsers to allow scripts and other resources to be loaded on your site. If you haven't already, read up on CSP on any of these links: https://content-security-policy.com/, https://developer.chrome.com/docs/privacy-security/csp.

Why doesn't it work?

The reason being is that CSP Level 3 restricts where and how you are loading resources on your site. The main issue is that the JavaScript scripts loaded by Kentico will not run because of the CSP rules. To fix this we will create a solution that will allow these scripts to run through modifications to your application.

Create a CSP policy

The first step is to apply the CSP header to your website. I use this library (opens new window) to simplify the application of security headers. Configure your application using that middleware and make sure to turn on strict-dynamic for script-src and nonce usage. We are going to need these directives to make sure Kentico scripts are allowed to run.

Nonce

The nonce is a random generated number that is created on every request. This nonce value is added to the CSP header (thanks to the middleware mentioned earlier). When there is a script tag on the page with the nonce attribute set to the same value, the modern browser will allow the script to run. Note that the downside of this is that cannot cache the html output or server html that is statically generated to the client. You would cache the nonce value and not use the latest one generated in every request. In some conditions using a nonce might not be viable, there is an alternative where you can hash the contents of a script element and add it to your CSP request header that will allow the contents of the script to run.

Strict-dynamic

The condition of this rule is that any and all scripts that are initially loaded need to have a nonce attribute, even scripts hosted on the same domain as your site! This rule will make sure that scripts that are validated to run on the site to be allowed to run any script, inject script tags on the page, etc. It is the strictest version but once you have nonces applied your scripts are free to load and inject things in your HTML.

Apply nonces

Add the nonce value to the scripts that you are loading on your site by using the asp-add-nonce taghelper attribute provided by the library. Confirm that your scripts are running by opening the dev tools in your browser and viewing the console. There should be no errors appearing. Now it's time to apply the nonces to the scripts added by Kentico.

Kentico Scripts

If you are using pagebuilder or formbuilder features from Kentico, you should be familiar with the <page-builder-scripts /> taghelper that you will need to add to make sure the functionality works. We need to add the nonce attribute to the scripts that Kentico adds.

Create a html decorator

We don't have an option to add the nonce values to scripts that Kentico adds. What we can do is parse the output and add the nonces. To inject the nonce value to the script tags we will need to create a html decorator class that will do this.

Create a static partial class called KenticoScriptHtmlDecorator

using System.IO;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Html;

/// <summary>
/// This class's job is to modify the HTML output of Kentico.
/// </summary>
internal static partial class KenticoScriptHtmlDecorator
{
    [GeneratedRegex("<form(?:.*?)id=\"(?:.*?)\"(?:.*?)onsubmit=\"(.*?)\">", RegexOptions.IgnoreCase, "en-US")]
    private static partial Regex OnSubmitRegex();

    [GeneratedRegex("<script[^>]*>", RegexOptions.IgnoreCase, "en-US")]
    private static partial Regex ScriptRegex();

    /// <summary>
    /// Adds nonce values to script tags of the html    /// </summary>    /// <param name="content"></param>    /// <param name="nonce"></param>    /// <returns></returns>    public static string TransformHtml(IHtmlContent? content, string nonce)
    {
		if (content == null)
       	{
			return string.Empty;
       	}

		var htmlString = GetHtmlOutputString(content);
       	return AddNonceAttribute(htmlString, nonce);
    }

    /// <summary>
    /// Adds nonce values to script tags of the html and removes inline onsubmit
	/// </summary>
	/// <param name="content"></param>
	/// <param name="nonce"></param>
	/// <returns></returns>
	public static string TransformFormHtml(IHtmlContent? content, string nonce)
    {
		var htmlString = TransformHtml(content, nonce);
       	return RemoveInlineOnSubmit(htmlString);
    }

    private static string RemoveInlineOnSubmit(string htmlString)
    {
		// Regex gets all form tags with an inline `onsubmit` event handler.
       	// Captures the inline `onsubmit` event handler script.
		var matches = OnSubmitRegex().Matches(htmlString);

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

    private static string AddNonceAttribute(string htmlString, string nonce)
    {
		// Replace matches with the same match plus the nonce attribute
       	return ScriptRegex().Replace(htmlString, match => match.Value.Replace(">", $" nonce=\"{nonce}\">"));
    }

    private static string GetHtmlOutputString(IHtmlContent content)
    {
		using var writer = new StringWriter();
       	content.WriteTo(writer, HtmlEncoder.Default);
       	return writer.ToString();
    }
}

We create a method called AddNonceAttribute. The arguments are htmlstring and the nonce value. It's job is to match the script tags of a htmlString and then insert the nonce attribute. We match using regex and to keep the performance to an optimum, we are using static GeneratedRegex to do the matching.

We create a method called RemoveInlineOnSubmit. It's job is to remove the inline on submit (which is not allowed according the CSP rules). As earlier, we are using GeneratedRegex to do the matching.

The public methods TransformHtml and TransformFormHtml are going to be used in later on to decorate the HTML that Kentico creates.

Kentico Form Widget

The Kentico form widget adds scripts to the html and dynamically will do so depending on validation errors or interactions with some form fields (such as the file uploader). What we need to do is get the HTML output of the component and then run the HTML decorator method to inject the nonce value.

We need to wrap the Kentico form component with a widget of our own. This allows us to render the html output of the form component widget from Kentico. Use the decorator class created earlier to transform the HTML. Now the scripts from the Kentico form will be allowed to run. Note that if you are already using the standard Kentico Form widget on your sites, this does mean that you will have to reapply the new widget on all pages where you have Kentico for widget added.

Create a ExtendedFormWidgetViewComponent class.

using Kentico.PageBuilder.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Newtonsoft.Json;

[assembly: RegisterWidget(
    ExtendedFormWidgetViewComponent.IDENTIFIER,
    typeof(ExtendedFormWidgetViewComponent),
    "Extended Form",
    typeof(ExtendedFormWidgetProperties),
    Description = "Custom widget that renders a Kentico form",
    IconClass = "icon-form")]

public class ExtendedFormWidgetViewComponent : ViewComponent
{
    /// <summary>
    /// Widget identifier.
	/// </summary>
	public const string IDENTIFIER = "ExtendedFormWidget";

    /// <summary>
    /// Creates an instance of <see cref="ExtendedFormWidgetViewComponent"/> class.
	/// </summary>
	/// <param name="widgetHelper">Handles widget basic functionality.</param>
	public ExtendedFormWidgetViewComponent()
    {

	}

    public ViewViewComponentResult Invoke(ComponentViewModel<ExtendedFormWidgetProperties> properties)
    {
		var model = new ExtendedFormWidgetViewModel();

        if (!string.IsNullOrWhiteSpace(properties.Properties.Title))
        {
			model.Title = JsonConvert.DeserializeObject<TextWithHeading>(properties.Properties.Title);
        }

        model.SelectedForm = properties.Properties.SelectedForm;
        model.Description = properties.Properties.Description;

        return View("~/Components/Widgets/ExtendedForm/_ExtendedFormWidget.cshtml", model);
    }
}

Register it as a widget. Create also a ExtendedFormWidgetProperties class, have it inherit from the FormWidgetProperties class of Kentico. Remember we are creating a wrapper for the existing widget. So the standard fields/properties that the form widget will show up in this new widget that you have created. Feel free to also add your own fields if you want!

using Kentico.Forms.Web.Mvc;
using Kentico.Forms.Web.Mvc.Widgets;

public class ExtendedFormWidgetProperties : FormWidgetProperties
{
}

Finally create the ExtendedFormWidget view. In the view we call the method RenderNestedWidgetAsync to render Kentico's form widget. It returns a IHtmlContentProxy type but this implements the IHtmlContent interface. Use the TransformFormHtml method that we have created earlier in the decorator class, you can retrieve the nonce value via ViewContext.HttpContext.GetNonce(). The result will be the fully rendered HTML of the form with the injected nonces! Process then to render this HTML using @Html.Raw().

@using Kentico.Content.Web.Mvc
@using Kentico.PageBuilder.Web.Mvc
@using Kentico.Web.Mvc
@using NetEscapades.AspNetCore.SecurityHeaders
@using Decorators
@model ExtendedFormWidgetViewModel

@{
    var htmlOutput = await Html.Kentico().RenderNestedWidgetAsync(SystemComponentIdentifiers.FORM_WIDGET_IDENTIFIER, Model);
    var transformedHtml = KenticoScriptHtmlDecorator.TransformFormHtml(htmlOutput, ViewContext.HttpContext.GetNonce());
}

@Html.Raw(transformedHtml)

Your form won't be able to submit yet though. We have removed the inline onsubmit attribute from the form element with the decorator class. So now we need to add the following script to your layout.cshtml. This script will handle the submissions of your Kentico forms.

<script asp-add-nonce>
    // 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>

Remember that because of the trict-dynamic rule, anything the Kentico scripts will do, injecting more scripts or loading other resources will be allowed, that's great for us.

Kentico page-builder scripts

The next area we need to inject scripts is the page-builder feature. When using this feature you have added the <page-builder-scripts /> tag helper to your views. What we have to do is to create our own implementation that will inherit Kentico's tag helper.

Create the class CustomPageBuilderScriptsTagHelper. It overrides the PageBuilderScripts TagHelper class provided by Kentico. This class will be the taghelper that we use to load Kentico scripts. In the Process method, we call the base implementation, get the html output and then transform the HTML using the decorator class to inject the nonce attribute. Then set the output to the transformed html.

using Kentico.Content.Web.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
using NetEscapades.AspNetCore.SecurityHeaders;

/// <summary>
/// This class parses the scripts that will be added by Kentico and adds nonce values to the script elements.
/// </summary>
/// <param name="htmlHelper"></param>
public class CustomPageBuilderScriptsTagHelper(IHtmlHelper htmlHelper) : PageBuilderScriptsTagHelper(htmlHelper)
{
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
		base.Process(context, output);

       	var transformedHtml = KenticoScriptHtmlDecorator.TransformHtml(output, ViewContext.HttpContext.GetNonce());

       	output.Content.SetHtmlContent(transformedHtml);
    }
}

Replace Kentico's taghelper with our newly created implementation of it. The page builder scripts should be allowed to run now.

<custom-page-builder-scripts />

Loading live site in CMS admin

While things are solved for your live site. There is an issue if you try to load the site in the CMS admin because of the CSP rules. To workaround this we need to relax the CSP rules. With the rules we have in place CMS admin is simply not able establish a secure connection with the live site application and have it run in editmode.

To detect if the site is being loaded from the CMS admin we will create a middleware to do this. We cannot rely on the editmode property of the PageBuilder() method, this is because the library that we have added runs really early on in the process adding the CSP header, before Kentico has already itself detected if it's in editmode or not.

Create the class KenticoSecurityHeadersMiddleware. We run the code in context.Response.OnStarting because we want modify the response headers in the last possible moment.

using System.Linq;
using System.Threading.Tasks;
using Kentico.PageBuilder.Web.Mvc;
using Kentico.Web.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;

public class KenticoSecurityHeadersMiddleware(RequestDelegate next, IConfiguration configuration)
{
    public async Task InvokeAsync(HttpContext context)
    {       // Apply special CSP rules if Live site requests are loaded in Kentico Admin
       context.Response.OnStarting((state) =>
       {
          var httpContext = (HttpContext)state;
          /*
          Because the CSP header middleware adds the header really early in the process          we cannot confirm if PageBuilderContext is editmode or not          but if the request referrer is from one of the Allowed Ancestors or the request path starts with /cmsctx          then we know that the request originates from the Kentico Admin.          */          // Remove the CSP policy so that request can be established first.          string[] allowedAncestor = ["localhost:51872"];

          var referer = httpContext.Request.Headers.Referer.ToString();

          try
          {
             var match = (!string.IsNullOrEmpty(referer) && allowedAncestor.Any(x =>
                            referer.StartsWith(x))) ||
                         httpContext.Request.Path.StartsWithSegments("/cmsctx") ||
                         httpContext.Kentico().PageBuilder().EditMode;

             // Apply only the frame-ancestors rule if request comes from Kentico Admin
             if (match)
             {                context.Response.Headers.Remove("Content-Security-Policy");
                context.Response.Headers.Append("Content-Security-Policy",
                   $"frame-ancestors 'self' {string.Join(" ", allowedAncestor)}");
             }          }          catch
          {
             // ignored
          }


          return Task.CompletedTask;
       }, context);       await next(context);
    }}

To detect if the live site is in a page builder context we check the following conditions:

  • Does the request referrer match an allowed ancestor?
  • Does the request path match /cmstx?
  • And as a fallback: Is it in editmode? If one of these conditions is true we apply a relaxed set of CSP headers to the response headers so that CMS admin is able to load the live site application and establish a secure connection. It's up to you how relaxed you want the CSP headers is to be. In this example I have kept the frame-ancestor's rule only (to allow the CMS admin to load the site in an Iframe).

Add the middleware early on in your Configure() method.

app.UseMiddleware<KenticoSecurityHeadersMiddleware>();

Dotnet watch

In case you are using dotnet watch and still want to run the full CSP rules on your local environment add the following snippet in your layout.cshtml.

<environment names="Development">
    <script asp-add-nonce src="/_framework/aspnetcore-browser-refresh.js"></script>
</environment>

Dotnet watch automatically injects this script normally but since it won't have the nonce value the browser automatic refresh will no longer work. We have to manually add it. Note that there is no way to remove or modify the automatically injected script, though there might be more flexibility in the future release of .NET 9 (opens new window).

Final notes

After doing these adjustments your web application should run with the strictest CSP rules! Always remember that we are modifying key areas of Kentico that might change in the future in a hotfix or update, always reconfirm these areas that they are working when you are doing so.

If you have to apply the styles-src rules similarly like the script-src, just take inspiration on how we have done it for the scripts and apply it there.

© 2024 Bob Haring