Content Security Policy in ASP.NET MVC - Scripts

Content Security Policy Level 2 specification defines a mechanism for providing policies around sources from which the application will be loading resources. This allows for better protection against many different injection vulnerabilities.

In this post I'm going to show how you can use it with ASP.NET MVC in order to mitigate cross-site scripting (XSS) attacks by defining trusted sources for running scripts. For our example we will use a page which contains reference to three script files and one inline script.

<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.11.3 jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/8.5/highlight.min.js"></script>
<script src="~/Content/js/marked-0.3.3.min.js"></script>
<script>
    $(document).ready(function () {
        ...
    });
</script>

Delivering Policy

Delivering a policy should be done via Content-Security-Policy response header, which should contain a semicolon (;) delimited list of directives. Typically every directive consists of directive name and space separated source list. In our case we want to use script-src directive which refers to scripts. If the page above is being returned from ASP.NET MVC action, we can easily inject the header with an action filter.

public sealed class ContentSecurityPolicyAttribute : FilterAttribute, IActionFilter
{
    public string ScriptSource { get; set; }

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
        return;
    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        StringBuilder policyBuilder = new StringBuilder();

        if (!String.IsNullOrWhiteSpace(ScriptSrc))
        {
            policyBuilder.AppendFormat("script-src {0};", ScriptSource);
        }

        if (policyBuilder.Length &gt; 0)
        {
            filterContext.HttpContext.Response.AppendHeader("Content-Security-Policy", policyBuilder.ToString());
        }
    }
}

In order to test this attribute we will apply it to the action with 'none' in the source list - this should result in refusing all scripts.

public class HomeController : Controller
{
    [ContentSecurityPolicy(ScriptSource = "'none'")]
    public ActionResult Index()
    {
        ...
        return View();
    }
}

If we navigate to our action in Chrome we should see something similar to the screenshot below in the console.

Content Security Policy Errors

Now we can set the correct source list, which will allow the resources we want. Allowing requests to the domain of the application can be done by using 'self' as one of the sources.

[ContentSecurityPolicy(ScriptSource = "'self' cdnjs.cloudflare.com")]

This will remove all "Refused to load" errors leaving only "Refused to execute".

Inline Execution

The Content Security Policy mechanism provides three ways for allowing inline execution:

  • Adding 'unsafe-inline' as a source, which allows all inline execution.
  • Whitelisting scripts by using a randomly generated nonce.
  • Whitelisting scripts by specifying its hash as an allowed source of script

First one is self-explanatory and provides no security so I should focus on two remaining ones. For the nonce mechanism to work application needs to generate a unique value for every request, which should be added to script source list as 'nonce-$RANDOM'. Let’s change the filter to handle this.

public enum ContentSecurityPolicyInlineExecution
{
    Refuse,
    Unsafe,
    Nonce,
    Hash
}

public sealed class ContentSecurityPolicyAttribute : FilterAttribute, IActionFilter
{
    public string ScriptSource { get; set; }

    public ContentSecurityPolicyInlineExecution ScriptInlineExecution { get; set; }

    public ContentSecurityPolicyAttribute()
    {
        ScriptInlineExecution = ContentSecurityPolicyInlineExecution.Refuse;
    }

    ...

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        StringBuilder policyBuilder = new StringBuilder();

        if (!String.IsNullOrWhiteSpace(ScriptSource) || (ScriptInlineExecution != ContentSecurityPolicyInlineExecution.Refuse))
        {
            policyBuilder.Append("script-src");

            if (!String.IsNullOrWhiteSpace(ScriptSource))
            {
                policyBuilder.AppendFormat(" {0}", ScriptSource);
            }

            filterContext.HttpContext.Items["ScriptInlineExecution"] = ScriptInlineExecution;
            switch (ScriptInlineExecution)
            {
                case ContentSecurityPolicyInlineExecution.Unsafe:
                    policyBuilder.Append(" 'unsafe-inline'");
                    break;
                case ContentSecurityPolicyInlineExecution.Nonce:
                    string nonceRandom = Guid.NewGuid().ToString("N");
                    filterContext.HttpContext.Items["NonceRandom"] = nonceRandom;
                    policyBuilder.AppendFormat(" 'nonce-{0}'", nonceRandom);
                    break;
                default:
                    break;
            }

            policyBuilder.Append(";");
        }

        if (policyBuilder.Length &gt; 0)
        {
            filterContext.HttpContext.Response.AppendHeader("Content-Security-Policy", policyBuilder.ToString());
        }
    }
}

The filter is now storing the information that we are using nonce as well as its random value in HttpContext.Items. This is needed because this random value must be added through nonce attribute to every script element which we want to allow. This can be easily done with proper HtmlHelper extension.

public static class ContentSecurityPolicyExtensions
{
    public static IDisposable BeginCspScript(this HtmlHelper htmlHelper)
    {
        return new ContentSecurityPolicyScript(htmlHelper.ViewContext);
    }

    private class ContentSecurityPolicyScript : IDisposable
    {
        private readonly ViewContext _viewContext;
        private readonly ContentSecurityPolicyInlineExecution _scriptInlineExecution;
        private readonly TagBuilder _scriptTag;

        public ContentSecurityPolicyScript(ViewContext context)
        {
            _viewContext = context;

            _scriptInlineExecution = (ContentSecurityPolicyInlineExecution)_viewContext.HttpContext.Items["ScriptInlineExecution"];

            _scriptTag = new TagBuilder("script");
            if (_scriptInlineExecution == ContentSecurityPolicyInlineExecution.Nonce)
            {
                _scriptTag.MergeAttribute("nonce", (string)_viewContext.HttpContext.Items["NonceRandom"]);
            }

            _viewContext.Writer.Write(_scriptTag.ToString(TagRenderMode.StartTag));
        }

        public void Dispose()
        {
            _viewContext.Writer.Write(_scriptTag.ToString(TagRenderMode.EndTag));
        }
    }
}

Using this helper might be a little tricky because Razor may not be able to switch properly to HTML mode by itself. This is why we will use <text> pseudo element.

@using (Html.BeginCspScript())
{
    <text>
    $(document).ready(function () {
        ...
    });
    </text>
}

Now we just need to enable the mechanism on our action and the browser will execute our inline script.

[ContentSecurityPolicy(ScriptSource = "'self' cdnjs.cloudflare.com", ScriptInlineExecution = ContentSecurityPolicyInlineExecution.Nonce)]

The hash based mechanism is a little bit more sophisticated. In order to whitelist a script application needs to provide its hash as a part of the source list in 'hashAlgorithm-hashValue' format. Allowed hash algorithms include SHA256, SHA384 and SHA512. The computed hash should be provided in Base64 encoded form. Implementing support for this will be a more tricky. The hashes can be computed not sooner than during the result processing, so our action filter needs to leave a placeholder for them and add them after executing the result.

public sealed class ContentSecurityPolicyAttribute : FilterAttribute, IActionFilter, IResultFilter
{
    ...

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        StringBuilder policyBuilder = new StringBuilder();

        if (!String.IsNullOrWhiteSpace(ScriptSource) || (ScriptInlineExecution != ContentSecurityPolicyInlineExecution.Refuse))
        {
            policyBuilder.Append("script-src");

            ...

            filterContext.HttpContext.Items[ScriptInlineExecutionContextKey] = ScriptInlineExecution;
            switch (ScriptInlineExecution)
            {
                ...
                case ContentSecurityPolicyInlineExecution.Hash:
                    filterContext.HttpContext.Items["ScriptHashListBuilder"] = new StringBuilder();
                    policyBuilder.Append("<scripthashlistplaceholder>");
                    break;
                default:
                    break;
            }

            policyBuilder.Append(";");
        }

        ...
    }

    public void OnResultExecuted(ResultExecutedContext filterContext)
    {
        if (ScriptInlineExecution == ContentSecurityPolicyInlineExecution.Hash)
        {
            filterContext.HttpContext.Response.Headers["Content-Security-Policy"] = filterContext.HttpContext.Response.Headers["Content-Security-Policy"].Replace("<scripthashlistplaceholder>", ((StringBuilder)filterContext.HttpContext.Items["ScriptHashListBuilder"]).ToString());
        }
    }

    public void OnResultExecuting(ResultExecutingContext filterContext)
    { }
}

The hashes itself can be computed by the helper we have already written - it can access the internal StringBuilder of the writer in order to extract the content which needs hashing. All that needs to be done is modifying ContentSecurityPolicyScript class.

private class ContentSecurityPolicyScript : IDisposable
{
    ...
    private readonly int _viewBuilderIndex;

    public ContentSecurityPolicyScript(ViewContext context)
    {
        _viewContext = context;

        _scriptInlineExecution = (ContentSecurityPolicyInlineExecution)_viewContext.HttpContext.Items[ContentSecurityPolicyAttribute.ScriptInlineExecutionContextKey];

        _scriptTag = new TagBuilder("script");
        if (_scriptInlineExecution == ContentSecurityPolicyInlineExecution.Nonce)
        {
            _scriptTag.MergeAttribute("nonce", (string)_viewContext.HttpContext.Items[ContentSecurityPolicyAttribute.NonceRandomContextKey]);
        }

        _viewContext.Writer.Write(_scriptTag.ToString(TagRenderMode.StartTag));

        if (_scriptInlineExecution == ContentSecurityPolicyInlineExecution.Hash)
        {
            _viewBuilderIndex = ((StringWriter)_viewContext.Writer).GetStringBuilder().Length;
        }
    }

    public void Dispose()
    {
        if (_scriptInlineExecution == ContentSecurityPolicyInlineExecution.Hash)
        {
            StringBuilder viewBuilder = ((StringWriter)_viewContext.Writer).GetStringBuilder();
            string scriptContent = viewBuilder.ToString(_viewBuilderIndex, viewBuilder.Length - _viewBuilderIndex).Replace("\r\n", "\n");
            byte[] scriptHashBytes = new SHA256Managed().ComputeHash(Encoding.UTF8.GetBytes(scriptContent));
            string scriptHash = Convert.ToBase64String(scriptHashBytes);
            ((StringBuilder)_viewContext.HttpContext.Items[ContentSecurityPolicyAttribute.ScriptHashListBuilderContextKey]).AppendFormat(" 'sha256-{0}'", scriptHash);
        }
        _viewContext.Writer.Write(_scriptTag.ToString(TagRenderMode.EndTag));
    }
}

You can notice that I'm replacing \r\n with \n. Specification doesn't mention anything about this, but tests show that both Firefox and Chrome are doing that. Without this replace the hash would never match for multiple lines scripts. This way we have all mechanisms available.

The created components allow usage of Content Security Policy in ASP.NET MVC application without much effort. Both classes are available as part of Lib.Web.Mvc and will be extended with more directives before its next release.