Handling Cross-Site Scripting (XSS) in ASP.NET MVC

Handling XSS in ASP.NET MVC with custom Razor Html helpers and HttpModule

Cross-site Scripting (XSS) refers to client-site code injection attack where an attacker can execute malicious scripts into a web application. Basically attacker manages to upload malicious script code to the website which will be later on served to the users and executed in their browser.

It is ofter use to steal form inputs, cookie values, change the page layout or redirect to attackers website.

ASP.NET has build in request validation which is very efficiently protecting from any type of a suspecious data send in a request. More details about request validation you can find on MSDN page Request Validatin in ASP.NET

Note

Request validation is switched on by default and should never be switched off as it is creating spaces for potential code injection and XSS attacks among others. In case you need to post HTML or JavaScript tag in your request, use HTML encoding and decoding when sending and receiving data.

The part left is to protect from the client side so even if you have script injected in your website from an attack, end used is still protected by the browser, but you first need to tell the browser which policy to apply. This ensures XSS protection from both server and client side.

Xss

More details on how the client side Content Source Policy or CSP is implemented in browser and how to tell a browser to apply policy you can find on MDN web docs. What we re going to do in ASP.NET is basically writing an implementation for generating Content-Source-Policy headers dynamically on the fly during the ASP.NET MVC request pipeline.

Implementation

Basically we will generate dynamic nonce id for every inine script which will be unique on the request level and collect all base urls (schema + host) of script tags that are referencing resource which is not on our domain. All the script resources that are not known will not be included in the Content-Source-Policy in the response and therefore they will be blocked by the browser.

Since we are going to use same collection HttpContext.Items for storing on the View rendering and reading in the HttpModule, we'll put these collection keys in constants class to make them available for both adding and retrieving from the current HttpContext.Items collection.

using System;

namespace Mvc.Xss
{
    internal static class Constants
    {
       public const String XSS_INLINE_KEY = "script-inline";
       public const String XSS_BLOCK_KEY = "script-block";
    }
}

    

Now we can start with HtmlHelper classes for rendering JavaScript blocks. We need to use these HtmlHelper for every place we need a JavaScript block whether it is inline or referencing external js file. Every other script block will not be recognized when writing CSP headers and will be blocked by the browser.

First we need and Html helper extension method for inline script

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Web.Mvc;
using Yahoo.Yui.Compressor;

namespace Mvc.Xss.HtmlHelpers
{
    public static partial class Extensions
    {
        public static MvcHtmlString InlineScriptBlock(this HtmlHelper htmlHelper, String[] paths, bool minify = true)
        {
            var context = htmlHelper.ViewContext.RequestContext.HttpContext;
            var builder = new TagBuilder("script");
            String nonceId = Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", "");
            builder.Attributes.Add("type", "text/javascript");
            builder.Attributes.Add("nonce", nonceId);
            var nonceCollection = context.Items[Constants.XSS_INLINE_KEY] as IList<String>;
            if (nonceCollection == null)
            {
                nonceCollection = new List<String>();
                context.Items[Constants.XSS_INLINE_KEY] = nonceCollection;
            }
            nonceCollection.Add(nonceId);
            return GetHtmlTag(htmlHelper, builder, paths, minify);
        }

        public static MvcHtmlString InlineScriptBlock(this HtmlHelper htmlHelper, String path, bool minify = true)
        {
            return InlineScriptBlock(htmlHelper, new String[] { path }, minify);
        }

        private static MvcHtmlString GetHtmlTag(HtmlHelper htmlHelper, TagBuilder builder, String[] paths, bool minify)
        {
            var context = htmlHelper.ViewContext.RequestContext.HttpContext;
            List<String> physicalPaths = new List<string>();
            String cacheKey = string.Join("-", paths.Select(p => String.Concat(p.ToLower(), "_", minify.ToString())));
            String cachedContent = context.Cache.Get(cacheKey) as String;
            if (cachedContent == null)
            {
                foreach (String path in paths)
                {
                    var physicalPath = context.Server.MapPath(path);
                    physicalPaths.Add(physicalPath);

                    if (File.Exists(physicalPath))
                    {
                        using (var fileStream = new FileStream(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                        {
                            using (var streamReader = new StreamReader(fileStream))
                            {
                                cachedContent = String.Concat(cachedContent, streamReader.ReadToEnd(), System.Environment.NewLine);
                            }
                        }
                    }
                }

                if (minify)
                {
                    switch (builder.TagName.ToLower().Trim())
                    {
                        case "style":
                            cachedContent = new CssCompressor().Compress(cachedContent);
                            break;
                        case "script":
                            cachedContent = new JavaScriptCompressor().Compress(cachedContent);
                            break;
                        default:
                            throw new ArgumentException(String.Format("Unknown resource type {0}", builder.TagName));
                    }
                }
                context.Cache.Insert(cacheKey, cachedContent, new System.Web.Caching.CacheDependency(physicalPaths.ToArray()), DateTime.MaxValue, TimeSpan.FromHours(1));
            }
            builder.InnerHtml = cachedContent;
            return MvcHtmlString.Create(builder.ToString());
        }

    }
}
    

Since scripts can be referenced by an url we also need a script block Html helper which references external .js file

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace Mvc.Xss.HtmlHelpers
{
    public static partial class Extensions
    {
        public static MvcHtmlString ScriptBlock(this HtmlHelper htmlHelper, Uri url)
        {
            var context = htmlHelper.ViewContext.RequestContext.HttpContext;
            String urlToAdd = "'self'";
            var builder = new TagBuilder("script");
            builder.Attributes.Add("type", "text/javascript");
            builder.Attributes.Add("src", url.AbsoluteUri);
            var scriptWebCollection = context.Items[Constants.XSS_BLOCK_KEY] as IList<String>;
            if (scriptWebCollection == null)
            {
                scriptWebCollection = new List<String>();
                context.Items[Constants.XSS_BLOCK_KEY] = scriptWebCollection;
            }

            if (!url.Host.Equals(context.Request.Url.Host, StringComparison.InvariantCultureIgnoreCase) || url.Port != context.Request.Url.Port)
            {
                urlToAdd = $"{url.Scheme}://{url.Host}{(url.Port == 80 ? String.Empty : $":{url.Port}")}".ToLower();
            }
            if (!scriptWebCollection.Where(v => v.Equals(urlToAdd, StringComparison.InvariantCultureIgnoreCase)).Any())
            {
                scriptWebCollection.Add(urlToAdd);
            }
            return MvcHtmlString.Create(builder.ToString());
        }

        public static MvcHtmlString ScriptBlock(this HtmlHelper htmlHelper, String url)
        {
            var context = htmlHelper.ViewContext.RequestContext.HttpContext;
            if (url.StartsWith("~") || url.StartsWith("/"))
            {
                url = VirtualPathUtility.ToAbsolute(url);
                url = $"{context.Request.Url.Scheme}://{context.Request.Url.Host}{(context.Request.Url.Port == 80 ? String.Empty : $":{context.Request.Url.Port}")}{url}";
            }
            return ScriptBlock(htmlHelper, new Uri(url));
        }
    }
}

    

Next thing to to is to write out all collected nonce ids and URLs of external JavaScript files. We will do this with HttpModule after the page is rendered because we are collecting nonce ids and URLs on the view rendering.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Web;

namespace Mvc.Xss.HttpModules
{
    public class XssHeadersModule : IHttpModule
    {
        public void Dispose()
        {
            
        }

        public void Init(HttpApplication context)
        {
            context.PostRequestHandlerExecute += context_PostRequestHandlerExecute;
        }

        private void context_PostRequestHandlerExecute(object sender, EventArgs e)
        {
            HttpApplication app = sender as HttpApplication;
            HttpContext context = app.Context;
            string contentType = app.Context.Response.ContentType;

            if (context.Request.HttpMethod == "GET" &&
                contentType.Equals("text/html") &&
                context.Response.StatusCode == 200 &&
                context.CurrentHandler != null)
            {
                var request = context.Request;
                String xssHeadersKey = "Content-Security-Policy";
                String xssHeadersValue = String.Empty;
                request.Headers.Remove(xssHeadersKey);
                var nonceCollection = context.Items[Constants.XSS_INLINE_KEY] as IList<String>;
                if (nonceCollection != null && nonceCollection.Any())
                {
                    xssHeadersValue = $"script-src {String.Join(" ", nonceCollection.Select(n=> $"'nonce-{n}'"))}";
                }

                var scriptWebCollection = context.Items[Constants.XSS_BLOCK_KEY] as IList<String>;
                if (scriptWebCollection != null && scriptWebCollection.Any())
                {
                    if (String.IsNullOrWhiteSpace(xssHeadersValue))
                    {
                        xssHeadersValue = $"script-src";
                    }
                    xssHeadersValue = $"{xssHeadersValue} {String.Join(" ", scriptWebCollection)}";
                }
                else{
                    xssHeadersValue = $"{xssHeadersValue} 'self'";
                }

                context.Response.Headers.Add(xssHeadersKey, xssHeadersValue);
            }

        }
    }
}

    

We have all components ready, so we can start using them in our Web Application.

How to use it

In order to use HtmlHelpers we need to reference then in Views/Web.config file of the Web Application project to which we want to apply XSS protection.

  <system.web.webPages.razor>
    <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.2.3.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
    <pages pageBaseType="System.Web.Mvc.WebViewPage">
      <namespaces>
        <add namespace="Mvc.Xss.HtmlHelpers" />
        <add namespace="System.Web.Mvc" />
        <add namespace="System.Web.Mvc.Ajax" />
        <add namespace="System.Web.Mvc.Html" />
        <add namespace="System.Web.Optimization"/>
        <add namespace="System.Web.Routing" />
        <add namespace="WebApplication1" />
      </namespaces>
    </pages>
  </system.web.webPages.razor>
    

After adding the namespace Mvc.Xss.HtmlHelpers, our helpers will be available in any razor view of the Web Application project.

This is only half way done because HtmlHelpers will oonly collect nonce ids and script urls, they will not make any changes in the response headers. This has to be done with the HttpModule which we need to reference in the main Web.config

</configuration>
  <system.webServer>
    <modules>
      <remove name="XssModule" />
      <add name="XssModule" type="Mvc.Xss.HttpModules.XssHeadersModule, Mvc.Xss" />
    </modules>
  </system.webServer>
</configuration>
    

Now we have all components in place and set up. We can do a quick test to see if our XSS protection is working on the browse level as well.

Demo

For the demo we will use simple Razor view which loads two script, one inline an one script block. Both scripts are loaded with our html helper extensions.

<!DOCTYPE html>
<html>
<head>
    <title>XSS test</title>
    @Html.InlineScriptBlock("~/Scripts/Sample1.js", false)
    @Html.ScriptBlock("https://code.jquery.com/jquery-3.3.1.min.js")
</head>
<body>
    @RenderBody()
</body>
</html>
    

If we load this page we will not see any error in console of the browser. Now let's try to simulate inline script injection by lading the inline script without our html helper extension, as it was a malicious script retrieved from the database.

I will even add nonce attribute value I generated as new guid to make it as realistic as possible.

<!DOCTYPE html>
<html>
<head>
    <title>XSS test</title>
    <script type="text/javascript" nonce="b16a118a15094ba4bf329d03b651556e">
        console.log("I am the XSS attack");
    </script>
    @Html.ScriptBlock("https://code.jquery.com/jquery-3.3.1.min.js")
</head>
<body>
    @RenderBody()
</body>
</html>
    

Once you load the page and check browser console, you will see the following error.

Xss -attack -console

Browser did not manage to match script origin with dynamically generated policy from the ASP.NET. Even the nonce attribute I added is invalidated.

If we do the same with script block tag we will get the same error in browser and if you check the network tab of the browser console you will see that external script file is not loaded. It has been blocked by the CSP policy generated from ASP.NET and passed to client side in response headers.

<!DOCTYPE html>
<html>
<head>
    <title>XSS test</title>
    @Html.InlineScriptBlock("~/Scripts/Sample1.js", false)
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.3.1.min.js" nonce="43d7ac07b4af430ebbd42d62833133f2"></script>
</head>
<body>
    @RenderBody()
</body>
</html>
    

Xss -attack -network

References

Disclaimer

Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.


About the author

DEJAN STOJANOVIC

Dejan is a passionate Software Architect/Developer. He is highly experienced in .NET programming platform including ASP.NET MVC and WebApi. He likes working on new technologies and exciting challenging projects

CONNECT WITH DEJAN  Loginlinkedin Logintwitter Logingoogleplus Logingoogleplus

JavaScript

read more

SQL/T-SQL

read more

Umbraco CMS

read more

PowerShell

read more

Comments for this article