Skip to main content

ASP.NET MVC action filter for defining Content Security Policy Level 2 policies.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.Security.Cryptography;

namespace Mvc
{
    /// <summary>
    /// Content Security Policy Level 2 inline execution modes.
    /// </summary>
    public enum ContentSecurityPolicyInlineExecution
    {
        /// <summary>
        /// Refuse any inline execution
        /// </summary>
        Refuse,
        /// <summary>
        /// Allow all inline execution
        /// </summary>
        Unsafe,
        /// <summary>
        /// Use nonce mechanism
        /// </summary>
        Nonce,
        /// <summary>
        /// Use hash mechanism
        /// </summary>
        Hash
    }

    /// <summary>
    /// Content Security Policy Level 2 sandbox flags
    /// </summary>
    [Flags]
    public enum ContentSecurityPolicySandboxFlags
    {
        /// <summary>
        /// Set no sandbox flags
        /// </summary>
        None = 0,
        /// <summary>
        /// Set allow-forms sandbox flag
        /// </summary>
        AllowForms = 1,
        /// <summary>
        /// Set allow-pointer-lock sandbox flag
        /// </summary>
        AllowPointerLock = 2,
        /// <summary>
        /// Set allow-popups sandbox flag
        /// </summary>
        AllowPopups = 4,
        /// <summary>
        /// Set allow-same-origin sandbox flag
        /// </summary>
        AllowSameOrigin = 8,
        /// <summary>
        /// Set allow-scripts sandbox flag
        /// </summary>
        AllowScripts = 16,
        /// <summary>
        /// Set allow-top-navigation sandbox flag
        /// </summary>
        AllowTopNavigation = 32
    }

    /// <summary>
    /// Action filter for defining Content Security Policy Level 2 policies
    /// </summary>
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
    public class ContentSecurityPolicyAttribute : FilterAttribute, IActionFilter, IResultFilter
    {
        internal const string ScriptDirective = "script-src";
        internal const string StyleDirective = "style-src";
        internal const string NonceRandomContextKey = "Mvc.ContentSecurityPolicy.NonceRandom";

        private const string _contentSecurityPolicyHeader = "Content-Security-Policy";
        private const string _contentSecurityPolicyReportOnlyHeader = "Content-Security-Policy-Report-Only";
        private const string _directivesDelimiter = ";";
        private const string _baseDirectiveFormat = "base-uri {0};";
        private const string _defaultDirectiveFormat = "default-src {0};";
        private const string _childDirectiveFormat = "child-src {0};";
        private const string _connectDirectiveFormat = "connect-src {0};";
        private const string _fontDirectiveFormat = "font-src {0};";
        private const string _formDirectiveFormat = "form-action {0};";
        private const string _ancestorsDirectiveFormat = "frame-ancestors {0};";
        private const string _imageDirectiveFormat = "img-src {0};";
        private const string _mediaDirectiveFormat = "media-src {0};";
        private const string _objectDirectiveFormat = "object-src {0};";
        private const string _sandboxDirective = "sandbox";
        private const string _reportDirectiveFormat = "report-uri {0};";
        private const string _unsafeInlineSource = " 'unsafe-inline'";
        private const string _nonceSourceFormat = " 'nonce-{0}'";
        private const string _allowFormsSandboxFlag = " allow-forms";
        private const string _allowPointerLockSandboxFlag = " allow-pointer-lock";
        private const string _allowPopupsSandboxFlag = " allow-popups";
        private const string _allowSameOriginSandboxFlag = " allow-same-origin";
        private const string _allowScriptsSandboxFlag = " allow-scripts";
        private const string _allowTopNavigationSandboxFlag = " allow-top-navigation";

        internal static IDictionary<string, string> InlineExecutionContextKeys = new Dictionary<string, string>
        {
            { ScriptDirective, "Mvc.ContentSecurityPolicy.ScriptInlineExecution" },
            { StyleDirective, "Mvc.ContentSecurityPolicy.StyleInlineExecution" }
        };

        internal static IDictionary<string, string> HashListBuilderContextKeys = new Dictionary<string, string>
        {
            { ScriptDirective, "Mvc.ContentSecurityPolicy.ScriptHashListBuilder" },
            { StyleDirective, "Mvc.ContentSecurityPolicy.StyleHashListBuilder" }
        };

        private static IDictionary<string, string> _hashListPlaceholders = new Dictionary<string, string>
        {
            { ScriptDirective, "<ScriptHashListPlaceholder>" },
            { StyleDirective, "<StyleHashListPlaceholder>" }
        };

        /// <summary>
        /// Gets or sets the list of URLs that can be used to specify the document base URL.
        /// </summary>
        public string BaseUri { get; set; }

        /// <summary>
        /// Gets or sets the default source list for directives which can fall back to the default sources.
        /// </summary>
        public string DefaultSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for child-src directive.
        /// </summary>
        public string ChildSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for connect-src directive.
        /// </summary>
        public string ConnectSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for font-src directive.
        /// </summary>
        public string FontSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for form-action directive.
        /// </summary>
        public string FormSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for frame-ancestors directive.
        /// </summary>
        public string AncestorsSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for img-src directive.
        /// </summary>
        public string ImageSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for media-src directive.
        /// </summary>
        public string MediaSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for object-src directive.
        /// </summary>
        public string ObjectSources { get; set; }

        /// <summary>
        /// Gets or sets the source list for script-src directive.
        /// </summary>
        public string ScriptSources { get; set; }

        /// <summary>
        /// Gets or sets the inline execution mode for scripts
        /// </summary>
        public ContentSecurityPolicyInlineExecution ScriptInlineExecution { get; set; }

        /// <summary>
        /// Gets or sets the source list for style-src directive.
        /// </summary>
        public string StyleSources { get; set; }

        /// <summary>
        /// Gets or sets the inline execution mode for styles
        /// </summary>
        public ContentSecurityPolicyInlineExecution StyleInlineExecution { get; set; }

        /// <summary>
        /// Gets or sets the value indicating if sandbox policy should be applied.
        /// </summary>
        public bool Sandbox { get; set; }

        /// <summary>
        /// Gets or sets the sandboxing flags (only used when Sandbox is true)
        /// </summary>
        public ContentSecurityPolicySandboxFlags SandboxFlags { get; set; }

        /// <summary>
        /// Gets or sets the value indicating if this is report only policy.
        /// </summary>
        public bool ReportOnly { get; set; }

        /// <summary>
        /// Gets or sets the URL to which the user agent should send reports about policy violations
        /// </summary>
        public string ReportUri { get; set; }

        private string ContentSecurityPolicyHeader
        {
            get { return ReportOnly ? _contentSecurityPolicyReportOnlyHeader : _contentSecurityPolicyHeader; }
        }

        /// <summary>
        /// Initializes new instance of ContentSecurityPolicyAttribute.
        /// </summary>
        public ContentSecurityPolicyAttribute()
        {
            ScriptInlineExecution = ContentSecurityPolicyInlineExecution.Refuse;
            StyleInlineExecution = ContentSecurityPolicyInlineExecution.Refuse;
            Sandbox = false;
            SandboxFlags = ContentSecurityPolicySandboxFlags.None;
            ReportOnly = false;
        }

        /// <summary>
        /// Called after the action method executes.
        /// </summary>
        /// <param name="filterContext"></param>
        public void OnActionExecuted(ActionExecutedContext filterContext) { }

        /// <summary>
        /// Called before an action method executes.
        /// </summary>
        /// <param name="filterContext"></param>
        public void OnActionExecuting(ActionExecutingContext filterContext)
        {
            if (filterContext == null)
            {
                throw new ArgumentNullException("filterContext");
            }

            StringBuilder policyBuilder = new StringBuilder();

            AppendDirective(policyBuilder, _baseDirectiveFormat, BaseUri);
            AppendDirective(policyBuilder, _defaultDirectiveFormat, DefaultSources);
            AppendDirective(policyBuilder, _childDirectiveFormat, ChildSources);
            AppendDirective(policyBuilder, _connectDirectiveFormat, ConnectSources);
            AppendDirective(policyBuilder, _fontDirectiveFormat, FontSources);
            AppendDirective(policyBuilder, _formDirectiveFormat, FormSources);
            AppendDirective(policyBuilder, _ancestorsDirectiveFormat, AncestorsSources);
            AppendDirective(policyBuilder, _imageDirectiveFormat, ImageSources);
            AppendDirective(policyBuilder, _mediaDirectiveFormat, MediaSources);
            AppendDirective(policyBuilder, _objectDirectiveFormat, ObjectSources);
            AppendDirectiveWithInlineExecution(filterContext, policyBuilder, ScriptDirective, ScriptSources, ScriptInlineExecution);
            AppendDirectiveWithInlineExecution(filterContext, policyBuilder, StyleDirective, StyleSources, StyleInlineExecution);
            AppendSandboxDirective(policyBuilder);
            AppendDirective(policyBuilder, _reportDirectiveFormat, ReportUri);

            if (policyBuilder.Length > 0)
            {
                filterContext.HttpContext.Response.AppendHeader(ContentSecurityPolicyHeader, policyBuilder.ToString());
            }
        }

        /// <summary>
        /// Called after an action result executes.
        /// </summary>
        /// <param name="filterContext">The filter context.</param>
        public void OnResultExecuted(ResultExecutedContext filterContext)
        {
            if (filterContext == null)
            {
                throw new ArgumentNullException("filterContext");
            }

            string contentSecurityPolicyHeaderValue = filterContext.HttpContext.Response.Headers[ContentSecurityPolicyHeader];

            if (!String.IsNullOrWhiteSpace(contentSecurityPolicyHeaderValue))
            {
                if (ScriptInlineExecution == ContentSecurityPolicyInlineExecution.Hash)
                {
                    contentSecurityPolicyHeaderValue = contentSecurityPolicyHeaderValue.Replace(_hashListPlaceholders[ScriptDirective], ((StringBuilder)filterContext.HttpContext.Items[HashListBuilderContextKeys[ScriptDirective]]).ToString());
                }

                if (StyleInlineExecution == ContentSecurityPolicyInlineExecution.Hash)
                {
                    contentSecurityPolicyHeaderValue = contentSecurityPolicyHeaderValue.Replace(_hashListPlaceholders[StyleDirective], ((StringBuilder)filterContext.HttpContext.Items[HashListBuilderContextKeys[StyleDirective]]).ToString());
                }

                filterContext.HttpContext.Response.Headers[ContentSecurityPolicyHeader] = contentSecurityPolicyHeaderValue;
            }
        }

        /// <summary>
        /// Called before an action result executes.
        /// </summary>
        /// <param name="filterContext">The filter context.</param>
        public void OnResultExecuting(ResultExecutingContext filterContext) { }

        private void AppendDirective(StringBuilder policyBuilder, string directiveFormat, string source)
        {
            if (!String.IsNullOrWhiteSpace(source))
            {
                policyBuilder.AppendFormat(directiveFormat, source);
            }
        }

        private void AppendDirectiveWithInlineExecution(ActionExecutingContext filterContext, StringBuilder policyBuilder, string directive, string source, ContentSecurityPolicyInlineExecution inlineExecution)
        {
            if (!String.IsNullOrWhiteSpace(source) || (inlineExecution != ContentSecurityPolicyInlineExecution.Refuse))
            {
                policyBuilder.Append(directive);

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

                filterContext.HttpContext.Items[InlineExecutionContextKeys[directive]] = inlineExecution;
                switch (inlineExecution)
                {
                    case ContentSecurityPolicyInlineExecution.Unsafe:
                        policyBuilder.Append(_unsafeInlineSource);
                        break;
                    case ContentSecurityPolicyInlineExecution.Nonce:
                        string nonceRandom = GetNonceRandom(filterContext);
                        policyBuilder.AppendFormat(_nonceSourceFormat, nonceRandom);
                        break;
                    case ContentSecurityPolicyInlineExecution.Hash:
                        filterContext.HttpContext.Items[HashListBuilderContextKeys[directive]] = new StringBuilder();
                        policyBuilder.Append(_hashListPlaceholders[directive]);
                        break;
                    default:
                        break;
                }

                policyBuilder.Append(_directivesDelimiter);
            }
        }

        private string GetNonceRandom(ActionExecutingContext filterContext)
        {
            string nonceRandom;

            if (filterContext.HttpContext.Items.Contains(NonceRandomContextKey))
            {
                nonceRandom = (string)filterContext.HttpContext.Items[NonceRandomContextKey];
            }
            else
            {
                nonceRandom = Guid.NewGuid().ToString("N");
                filterContext.HttpContext.Items[NonceRandomContextKey] = nonceRandom;
            }

            return nonceRandom;
        }

        private void AppendSandboxDirective(StringBuilder policyBuilder)
        {
            if (Sandbox)
            {
                policyBuilder.Append(_sandboxDirective);

                if (SandboxFlags != ContentSecurityPolicySandboxFlags.None)
                {
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowForms, _allowFormsSandboxFlag);
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowPointerLock, _allowPointerLockSandboxFlag);
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowPopups, _allowPopupsSandboxFlag);
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowSameOrigin, _allowSameOriginSandboxFlag);
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowScripts, _allowScriptsSandboxFlag);
                    AppendSandboxFlag(policyBuilder, ContentSecurityPolicySandboxFlags.AllowTopNavigation, _allowTopNavigationSandboxFlag);
                }

                policyBuilder.Append(_directivesDelimiter);
            }
        }

        private void AppendSandboxFlag(StringBuilder policyBuilder, ContentSecurityPolicySandboxFlags flag, string flagValue)
        {
            if (SandboxFlags.HasFlag(flag))
            {
                policyBuilder.Append(flagValue);
            }
        }
    }

    /// <summary>
    /// Provides support for Content Security Policy Level 2 protected elements.
    /// </summary>
    public static class ContentSecurityPolicyExtensions
    {
        #region Methods
        /// <summary>
        /// Writes an opening script tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <returns></returns>
        public static IDisposable BeginCspScript(this HtmlHelper htmlHelper)
        {
            return BeginCspScript(htmlHelper, null);
        }

        /// <summary>
        /// Writes an opening script tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <param name="htmlAttributes">An object that contains the HTML attributes to set for the element</param>
        /// <returns></returns>
        public static IDisposable BeginCspScript(this HtmlHelper htmlHelper, object htmlAttributes)
        {
            return BeginCspScript(htmlHelper, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        /// <summary>
        /// Writes an opening script tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <param name="htmlAttributes">An object that contains the HTML attributes to set for the element</param>
        /// <returns></returns>
        public static IDisposable BeginCspScript(this HtmlHelper htmlHelper, IDictionary<string, object> htmlAttributes)
        {
            return new ContentSecurityPolicyInlineElement(htmlHelper.ViewContext, ContentSecurityPolicyInlineElement.ScriptTagName, htmlAttributes);
        }

        /// <summary>
        /// Writes an opening style tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <returns></returns>
        public static IDisposable BeginCspStyle(this HtmlHelper htmlHelper)
        {
            return BeginCspStyle(htmlHelper, null);
        }

        /// <summary>
        /// Writes an opening style tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <param name="htmlAttributes">An object that contains the HTML attributes to set for the element</param>
        /// <returns></returns>
        public static IDisposable BeginCspStyle(this HtmlHelper htmlHelper, object htmlAttributes)
        {
            return BeginCspStyle(htmlHelper, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        /// <summary>
        /// Writes an opening style tag to the response, and sets attributes related to Content Security Policy
        /// </summary>
        /// <param name="htmlHelper">The HTML helper</param>
        /// <param name="htmlAttributes">An object that contains the HTML attributes to set for the element</param>
        /// <returns></returns>
        public static IDisposable BeginCspStyle(this HtmlHelper htmlHelper, IDictionary<string, object> htmlAttributes)
        {
            return new ContentSecurityPolicyInlineElement(htmlHelper.ViewContext, ContentSecurityPolicyInlineElement.StyleTagName, htmlAttributes);
        }
        #endregion

        #region Classes
        private class ContentSecurityPolicyInlineElement : IDisposable
        {
            #region Constants
            internal const string ScriptTagName = "script";
            internal const string StyleTagName = "style";

            private const string _nonceAttribute = "nonce";
            private const string _sha256SourceFormat = " 'sha256-{0}'";
            #endregion

            #region Fields
            private static IDictionary<string, string> _inlineExecutionContextKeys = new Dictionary<string, string>
            {
                { ScriptTagName, ContentSecurityPolicyAttribute.InlineExecutionContextKeys[ContentSecurityPolicyAttribute.ScriptDirective] },
                { StyleTagName, ContentSecurityPolicyAttribute.InlineExecutionContextKeys[ContentSecurityPolicyAttribute.StyleDirective] }
            };

            private static IDictionary<string, string> _hashListBuilderContextKeys = new Dictionary<string, string>
            {
                { ScriptTagName, ContentSecurityPolicyAttribute.HashListBuilderContextKeys[ContentSecurityPolicyAttribute.ScriptDirective] },
                { StyleTagName, ContentSecurityPolicyAttribute.HashListBuilderContextKeys[ContentSecurityPolicyAttribute.StyleDirective] }
            };

            private readonly ViewContext _viewContext;
            private readonly ContentSecurityPolicyInlineExecution _inlineExecution;
            private readonly int _viewBuilderIndex;
            private readonly TagBuilder _elementTag;
            #endregion

            #region Constructor
            public ContentSecurityPolicyInlineElement(ViewContext context, string elementTagName, IDictionary<string, object> htmlAttributes)
            {
                _viewContext = context;

                _inlineExecution = (ContentSecurityPolicyInlineExecution)_viewContext.HttpContext.Items[_inlineExecutionContextKeys[elementTagName]];

                _elementTag = new TagBuilder(elementTagName);
                _elementTag.MergeAttributes(htmlAttributes);
                if (_inlineExecution == ContentSecurityPolicyInlineExecution.Nonce)
                {
                    _elementTag.MergeAttribute(_nonceAttribute, (string)_viewContext.HttpContext.Items[ContentSecurityPolicyAttribute.NonceRandomContextKey]);
                }

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

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

            #region IDisposable Members
            public void Dispose()
            {
                if (_inlineExecution == ContentSecurityPolicyInlineExecution.Hash)
                {
                    StringBuilder viewBuilder = ((StringWriter)_viewContext.Writer).GetStringBuilder();
                    string elementContent = viewBuilder.ToString(_viewBuilderIndex, viewBuilder.Length - _viewBuilderIndex).Replace("\r\n", "\n");
                    byte[] elementHashBytes = new SHA256Managed().ComputeHash(Encoding.UTF8.GetBytes(elementContent));
                    string elementHash = Convert.ToBase64String(elementHashBytes);
                    ((StringBuilder)_viewContext.HttpContext.Items[_hashListBuilderContextKeys[_elementTag.TagName]]).AppendFormat(_sha256SourceFormat, elementHash);
                }
                _viewContext.Writer.Write(_elementTag.ToString(TagRenderMode.EndTag));
            }
            #endregion
        }
        #endregion
    }
}