Kentico 12 (OWASP) CSP Inline-scripts

Patrick Thompson asked on November 15, 2024 20:36

Hi Reaching out to see if anyone has done this before in asp.net forms with Kentico 11-12. We are in the process of moving over to Experience, But we are looking for a quick security fix with not allowing inline-scripts with a nonce policy for CSP (Content-Security-Policty). There is Kentico Code and Custom code that contains Inline-scripts which means the site cannot pass OWASP after having the site vulnerability scanned. I have this working on a standard asp.net forms web project with an html page and it works very well. I Was able to create an IIS Module for Response filtering to update script tags in the html before processing the request back to the user/client. But if I apply this to Kentico CMS 11-12. I Just get a blank web page.

Curious to see if anyone else was able to get a IHttpModule with Kentico or something like this to work with Kentico, It can be done in MVC with the response filters. I have tested in other non-kentico Asp.net web applications and it works, im guessing it has something to do with the Kentico Lifecycle, possible? Below is what I have for code and setup:

In the Appcode folder (CspNonceModule.cs)

using System;
using System.IO;
using System.IO.Compression;
using System.Text.RegularExpressions;
using System.Web;

public class CspNonceModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.BeginRequest += OnBeginRequest;
        context.PreRequestHandlerExecute += OnPreRequestHandlerExecute;
        context.EndRequest += OnEndRequest;
    }

    private void OnBeginRequest(object sender, EventArgs e)
    {
        // bad cache
        HttpContext.Current.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
        HttpContext.Current.Response.Headers["Pragma"] = "no-cache";
        HttpContext.Current.Response.Headers["Expires"] = "0";

        // Generate a unique nonce for this request
        string nonce = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
        HttpContext.Current.Items["ScriptNonce"] = nonce;

        if (nonce != null)
        {
            // Add Content-Security-Policy header with the nonce
            HttpContext.Current.Response.Headers.Add(
                "Content-Security-Policy",
                $"script-src 'self' 'nonce-{nonce}'"
            );
        }
    }

    private void OnPreRequestHandlerExecute(object sender, EventArgs e)
    {
        // bad cache
        HttpContext.Current.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
        HttpContext.Current.Response.Headers["Pragma"] = "no-cache";
        HttpContext.Current.Response.Headers["Expires"] = "0";

        HttpContext context = HttpContext.Current;
        string contentType = context.Response.ContentType;
        string contentEncoding = context.Response.Headers["Content-Encoding"];

        System.Diagnostics.Debug.WriteLine($"OnPreRequestHandlerExecute - Content-Type: {contentType}");
        System.Diagnostics.Debug.WriteLine($"Content Encoding: {contentEncoding}");

        // Apply filter only to HTML content and if encoding is not set
        if (contentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) == true && string.IsNullOrEmpty(contentEncoding))
        {
            HttpContext.Current.Response.ContentType = "text/html";
            string nonce = (string)HttpContext.Current.Items["ScriptNonce"];
            context.Response.Filter = new ResponseFilterStream(context.Response.Filter, nonce);
        }
        else
        {
            System.Diagnostics.Debug.WriteLine($"Skipping non-HTML content: {contentType}");
        }
    }

    private void OnEndRequest(object sender, EventArgs e)
    {
        string contentType = HttpContext.Current.Response.ContentType;
        System.Diagnostics.Debug.WriteLine($"OnEndRequest - Content-Type: {contentType}");
    }

    public void Dispose()
    {
        // No resources to dispose
    }
}

public class ResponseFilterStream : Stream
{
    private readonly Stream _responseStream;
    private readonly string _nonce;
    private readonly MemoryStream _cacheStream = new MemoryStream();

    public ResponseFilterStream(Stream responseStream, string nonce)
    {
        _responseStream = responseStream;
        _nonce = nonce;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        if (count > 0)
        {
            string contentEncoding = HttpContext.Current.Response.Headers["Content-Encoding"];

            if (contentEncoding == "gzip")
            {
                DecompressGzipContent(buffer, offset, count);
            }
            else if (contentEncoding == "deflate")
            {
                DecompressDeflateContent(buffer, offset, count);
            }
            else
            {
                _cacheStream.Write(buffer, offset, count);
            }
        }
    }

    private void DecompressGzipContent(byte[] buffer, int offset, int count)
    {
        System.Diagnostics.Debug.WriteLine("Decompressing GZIP content.");
        try
        {
            using (var gzipStream = new GZipStream(new MemoryStream(buffer, offset, count), CompressionMode.Decompress))
            {
                gzipStream.CopyTo(_cacheStream);
            }
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Decompression failed: {ex.Message}");
            _cacheStream.Write(buffer, offset, count); // Fallback to raw data
        }
    }

    private void DecompressDeflateContent(byte[] buffer, int offset, int count)
    {
        System.Diagnostics.Debug.WriteLine("Decompressing Deflate content.");
        try
        {
            using (var deflateStream = new DeflateStream(new MemoryStream(buffer, offset, count), CompressionMode.Decompress))
            {
                deflateStream.CopyTo(_cacheStream);
            }
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Decompression failed: {ex.Message}");
            _cacheStream.Write(buffer, offset, count); // Fallback to raw data
        }
    }

    public override void Flush()
    {
        if (_cacheStream.Length > 0)
        {
            _cacheStream.Seek(0, SeekOrigin.Begin);

            string html = new StreamReader(_cacheStream).ReadToEnd();
            System.Diagnostics.Debug.WriteLine($"Decompressed HTML content: {html.Length} Characters.");

            // Modify the HTML to include nonce attributes in script tags
            try
            {
                html = Regex.Replace(html, @"<script\b([^>]*)>", $"<script nonce=\"{_nonce}\"$1>", RegexOptions.IgnoreCase);
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine($"Error processing HTML: {ex.Message}");
            }

            // Ensure proper encoding
            var encoding = HttpContext.Current.Response.ContentEncoding ?? System.Text.Encoding.UTF8;
            byte[] modifiedBuffer = encoding.GetBytes(html);

            // Correct Content-Length header after modification
            HttpContext.Current.Response.Headers["Content-Length"] = modifiedBuffer.Length.ToString();

            // Write modified content to the response stream
            _responseStream.Write(modifiedBuffer, 0, modifiedBuffer.Length);
        }

        // Flush the original response stream to ensure the client gets the modified data
        _responseStream.Flush();
    }

    private void WriteModifiedContent(string html)
    {
        var encoding = HttpContext.Current.Response.ContentEncoding ?? System.Text.Encoding.UTF8;
        byte[] modifiedBuffer = encoding.GetBytes(html);

        HttpContext.Current.Response.Headers["Content-Length"] = modifiedBuffer.Length.ToString();

        //HttpContext.Current.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
        //HttpContext.Current.Response.Headers["Pragma"] = "no-cache";
        //HttpContext.Current.Response.Headers["Expires"] = "0";

        _responseStream.Write(modifiedBuffer, 0, modifiedBuffer.Length);
    }

    // Implement required Stream members
    public override bool CanRead => _responseStream.CanRead;
    public override bool CanSeek => _responseStream.CanSeek;
    public override bool CanWrite => _responseStream.CanWrite;
    public override long Length => _responseStream.Length;
    public override long Position
    {
        get { return _responseStream.Position; }
        set { _responseStream.Position = value; }
    }
    public override void SetLength(long value) => _responseStream.SetLength(value);
    public override long Seek(long offset, SeekOrigin origin) => _responseStream.Seek(offset, origin);
    public override int Read(byte[] buffer, int offset, int count) => _responseStream.Read(buffer, offset, count);
}

Then in the web.config you have to reference the module with the rest of the site modules

<system.webServer>
    <httpProtocol>
<modules>
    <remove name="WebDAVModule" />
    <remove name="XHtmlModule" />
    <remove name="CMSApplicationModule" />
    <add name="XHtmlModule" type="CMS.OutputFilter.OutputFilterModule, CMS.OutputFilter" />
    <add name="CMSApplicationModule" preCondition="managedHandler" type="CMS.Base.ApplicationModule, CMS.Base" />
    <add name="CspNonceModule" type="CspNonceModule" />
</modules>
</httpProtocol>
</system.webServer>

Thanks!, Much appreciated

Correct Answer

Patrick Thompson answered on November 19, 2024 18:27

I was able to solve this issue, With Global Event Handlers that i did not notice in the documentation. https://docs.kentico.com/k12sp/custom-development/handling-global-events/making-custom-modifications-to-output-html

Global Event Code:

public class CustomHTMLOutput : Module
{
    public CustomHTMLOutput() 
        : base("CustomHTMLOutput")
    {
        //
        // TODO: Add constructor logic here
        //
    }

    protected override void OnInit()
    {
        base.OnInit();

        // Ensures that the an output filter instance is created on every request
        RequestEvents.PostMapRequestHandler.Execute += PostMapRequestHandler_Execute;
    }

    private void PostMapRequestHandler_Execute(object sender, EventArgs e)
    {
        // Creates an output filter instance
        ResponseOutputFilter.EnsureOutputFilter();

        // Assigns a handler to the OutputFilterContext.CurrentFilter.OnAfterFiltering event
        OutputFilterContext.CurrentFilter.OnAfterFiltering += CurrentFilter_OnAfterFiltering;
    }

    private void CurrentFilter_OnAfterFiltering(ResponseOutputFilter filter, ref string finalHtml)
    {
        // Process the resulting HTML

        string nonce = (string)HttpContext.Current.Items["ScriptNonce"];

        // Modify the HTML to include nonce attributes in script tags
        try
        {
            finalHtml = Regex.Replace(finalHtml, @"<script\b([^>]*)>", $"<script nonce=\"{nonce}\"$1>", RegexOptions.IgnoreCase);
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Error processing HTML: {ex.Message}");
        }
    }
}

Added IHttpModule on begin Request to add CSP to the page load if it does not exist and Generate Random Nonce:

public class CspNonceModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.BeginRequest += OnBeginRequest;
        context.PreRequestHandlerExecute += OnPreRequestHandlerExecute;
        context.EndRequest += OnEndRequest;
    }

    private void OnBeginRequest(object sender, EventArgs e)
    {

        HttpContext.Current.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
        HttpContext.Current.Response.Headers["Pragma"] = "no-cache";
        HttpContext.Current.Response.Headers["Expires"] = "0";

        // Generate a unique nonce for this request
        string nonce = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
        HttpContext.Current.Items["ScriptNonce"] = nonce;

        if (nonce != null)
        {
            // Add Content-Security-Policy header with the nonce
            HttpContext.Current.Response.Headers.Add(
                "Content-Security-Policy",
                $"script-src 'self' 'nonce-{nonce}'"
            );
        }
    }

    private void OnPreRequestHandlerExecute(object sender, EventArgs e)
    {

    }

    private void OnEndRequest(object sender, EventArgs e)
    {

    }

    public void Dispose()
    {
        // No resources to dispose
    }
}
0 votesVote for this answer Unmark Correct answer

   Please, sign in to be able to submit a new answer.