Content Security Policy in ASP.NET Core MVC - TagHelper
Some time ago I've written about HtmlHelper providing support for Content Security Policy. The solution presented worked well, but it required quite nasty markup and usage of <text>
pseudo element.
@using (Html.BeginCspScript())
{
<text>
$(document).ready(function () {
...
});
</text>
}
ASP.NET Core MVC has introduced a new way for server-side code to participate in rendering HTML elements - TagHelpers. The key aspect of TagHelpers is that they attach to HTML elements and allow for modifying them. This is exactly the functionality which should allow improving the markup of previously mentioned solution, so I've decided to try creating one. I've started with class inheriting from TagHelper
.
public class ContentSecurityPolicyTagHelper : TagHelper
{
public override void Process(TagHelperContext context, TagHelperOutput output)
{
}
}
The Process method is the one which will be executed when there will be an HTML element matching the TagHelper. The default matching is based on a naming convention which looks for TagHelper by class name (without the TagHelper suffix). This behavior can be adjusted with help of HtmlTargetElementAttribute. A single instance of HtmlTargetElementAttribute is being treated as logical "and" of all conditions while multiple instances are treated as logical "or". In this case I've decided that it would be nice to support two custom elements (<csp-script>
and <csp-style>
) as well as standard <script>
and <style>
elements with asp-csp
attribute.
[HtmlTargetElement("csp-style")]
[HtmlTargetElement("style", Attributes = "asp-csp")]
[HtmlTargetElement("csp-script")]
[HtmlTargetElement("script", Attributes = "asp-csp")]
public class ContentSecurityPolicyTagHelper : TagHelper
{
public override void Process(TagHelperContext context, TagHelperOutput output)
{
}
}
The custom elements need to be translated into standard ones as soon as processing starts. Also the custom attribute should be removed.
[HtmlTargetElement("csp-style")]
[HtmlTargetElement("style", Attributes = "asp-csp")]
[HtmlTargetElement("csp-script")]
[HtmlTargetElement("script", Attributes = "asp-csp")]
public class ContentSecurityPolicyTagHelper : TagHelper
{
public override void Process(TagHelperContext context, TagHelperOutput output)
{
SetTagName(output);
output.Attributes.RemoveAll("asp-csp");
}
private void SetTagName(TagHelperOutput output)
{
if (output.TagName == "csp-style")
{
output.TagName = "style";
}
else if (output.TagName == "csp-script")
{
output.TagName = "script";
}
}
}
Now the actual Content Security Policy related processing can be added. The ContentSecurityPolicyAttribute from previous post is putting all the relevant information into HttpContext.Items
from which I needed to retrieve them. ASP.NET Core MVC provides ViewContextAttribute which informs the framework that property should be set with the current ViewContext when TagHelper is being created, this is how access to HttpContext can be achieved.
...
public class ContentSecurityPolicyTagHelper : TagHelper
{
[ViewContext]
public ViewContext ViewContext { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
...
}
...
}
After retrieving the needed values from the HttpContext support for nonce can be provided without much effort.
...
public class ContentSecurityPolicyTagHelper : TagHelper
{
private static IDictionary<string, string> _inlineExecutionContextKeys =
new Dictionary<string, string>
{
{ "script", "ScriptInlineExecution" },
{ "style", "StyleInlineExecution" }
};
...
public override void Process(TagHelperContext context, TagHelperOutput output)
{
...
ContentSecurityPolicyInlineExecution currentInlineExecutionPolicy =
(ContentSecurityPolicyInlineExecution)ViewContext
.HttpContext.Items[_inlineExecutionContextKeys[output.TagName]];
if (currentInlineExecutionPolicy == ContentSecurityPolicyInlineExecution.Nonce)
{
output.Attributes.Add("nonce", (string)ViewContext.HttpContext.Items["NonceRandom"]);
}
else if (currentInlineExecutionPolicy == ContentSecurityPolicyInlineExecution.Hash)
{
}
}
...
}
For the hash support the content of the element is needed. The TagHelper provides a GetChildContentAsync and GetContent methods for this purpose. In order to decide which one to use a check should be done to see if the content has been modified by other TagHelper (in another words if the content should be accessed directly or from the buffer). After obtaining the content the hash can be calculated and added to the list which ContentSecurityPolicyAttribute will use later to generate the response headers. As the content is being obtained by asynchronous method the asynchronous version of Process method must be used.
...
public class ContentSecurityPolicyTagHelper : TagHelper
{
private static IDictionary<string, string> _inlineExecutionContextKeys =
new Dictionary<string, string>
{
{ "script", "ScriptInlineExecution" },
{ "style", "StyleInlineExecution" }
};
private static IDictionary<string, string> _hashListBuilderContextKeys =
new Dictionary<string, string>
{
{ "script", "ScriptHashListBuilder" },
{ "style", "StyleHashListBuilder" }
};
...
public override async Task Process(TagHelperContext context, TagHelperOutput output)
{
...
ContentSecurityPolicyInlineExecution currentInlineExecutionPolicy =
(ContentSecurityPolicyInlineExecution)ViewContext
.HttpContext.Items[_inlineExecutionContextKeys[output.TagName]];
if (currentInlineExecutionPolicy == ContentSecurityPolicyInlineExecution.Nonce)
{
output.Attributes.Add("nonce", (string)ViewContext.HttpContext.Items["NonceRandom"]);
}
else if (currentInlineExecutionPolicy == ContentSecurityPolicyInlineExecution.Hash)
{
string content = output.Content.IsModified ?
output.Content.GetContent() : (await output.GetChildContentAsync()).GetContent();
content = content.Replace("\r\n", "\n");
byte[] contentHashBytes = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(content));
string contentHash = Convert.ToBase64String(contentHashBytes);
((StringBuilder)ViewContext.HttpContext.Items[_hashListBuilderContextKeys[output.TagName]])
.AppendFormat(" 'sha256-{0}'", contentHash);
}
}
...
}
After registering new TagHelper with addTagHelper
directive it can be used through new custom elements or by simply adding the attribute to the standard ones.
<script asp-csp>
$(document).ready(function() {
...
});
</script>
The full version of code can be found at GitHub and it is ready to use together with ContentSecurityPolicyAttribute as part of Lib.AspNetCore.Mvc.Security.