ASP.NET MVC – Smart Way to Prevent Cross-Site Request Forgery (CSRF) Attempts – WebAPI (AJAX XHR) and Normal POST Operations

Monday, August 12th, 2019

ASP.NET MVC – The Smart Way to Prevent Cross-Site Request Forgery (CSRF) Attempts

WebAPI (AJAX XHR) and Normal POST Operations

If your ASP.NET MVC application uses some WebAPI endpoints which are called using XHR (AJAX) requests from clientside JavaScript, you can still protect against CSRF attacks by validating the origin of such a request (when it is an AJAX request) or perform the default action of validating the anti-CSRF token (for POST form requests).

I modified the below code from https://stackoverflow.com/questions/35085507/set-validateantiforgerytoken-attribute-to-get-post-for-same-action-mvc5#answer-35085970 or ARCHIVE

using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Web.Helpers;
using System.Linq;
using System.Collections.Generic;
using System.Configuration;
namespace System.Web.Mvc
{
    /// <summary>
    /// Cross-Site Request Forgery (CSRF) Prevention Filter for WebAPI and Normal MVC Controllers
    /// Normal POST operations = token is checked
    /// Normal controller GET operations = ignored
    /// WebAPI requests = check to make sure they were initiated by an AJAX request from a trusted origin
    /// </summary>    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public sealed class ValidateAntiForgeryTokenPOSTOrAJAXOrigin : FilterAttribute, IAuthorizationFilter
    {
        private string _salt;        

        public ValidateAntiForgeryTokenAttribute2() : this(AntiForgery.Validate)
        {
        }        

        internal ValidateAntiForgeryTokenAttribute2(Action validateAction)
        {
            Debug.Assert(validateAction != null);
            ValidateAction = validateAction;
        }
        
        [SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "AdditionalDataProvider", Justification = "API name.")]
        [SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "AntiForgeryConfig", Justification = "API name.")]
        [Obsolete("The 'Salt' property is deprecated. To specify custom data to be embedded within the token, use the static AntiForgeryConfig.AdditionalDataProvider property.", error: true)]
        [EditorBrowsable(EditorBrowsableState.Never)]
        
        public string Salt
        {
            get { return _salt; }
            set
            {
                if (!String.IsNullOrEmpty(value))
                {
                    throw new NotSupportedException("The 'Salt' property is deprecated. To specify custom data to be embedded within the token, use the static AntiForgeryConfig.AdditionalDataProvider property.");
                }
                _salt = value;
            }
        }

        internal Action ValidateAction { get; private set; }        
        
        public void OnAuthorization(AuthorizationContext filterContext)
        {
            string validOrigins = ConfigurationManager.AppSettings["AllowedEnvironments"]; // Example in web.config <add key="AllowedEnvironments" value="https://testurl.com:4443,https://testurl.com,https://testurl2.com" />
            bool skipCheck = false;
            
            if(ConfigurationManager.AppSettings["LocalDevMode"] == "1")
            {
                skipCheck = true;
            }
            
            // In AJAX requests, the origin header is always sent (UNLESS IT'S COMING FROM THE SAME ORIGIN), so we can validate that it comes from a trusted location to prevent CSRF attacks - but if one isn't sent, we won't do anything (assume trusted)
            // In which case, we don't need to do any token checking either 🙂
            if(!skipCheck && !string.IsNullOrEmpty(validOrigins))
            {
                List<string> validOriginURLs = validOrigins.Split(',').ToList();
                if(filterContext.HttpContext.Request.Headers["Origin"] != null && !string.IsNullOrEmpty(filterContext.HttpContext.Request.Headers["Origin"].ToString()))
                {
                    string origin = filterContext.HttpContext.Request.Headers["Origin"];
                    if(!validOriginURLs.Contains(origin))
                    {
                        filterContext.Result = new RedirectResult("~/Home/InvalidRequest");
                        skipCheck = true; // Still set to true to prevent additional validation
                    }
                    else
                    {
                        skipCheck = true;
                    }
                }
            }
        
            if(!skipCheck){
                var request = filterContext.HttpContext.Request.HttpMethod;
                if (request != "GET" && (!filterContext.HttpContext.Request.IsAjaxRequest() || (filterContext.HttpContext.Request.IsAjaxRequest() && (filterContext.HttpContext.Request.Headers["X-Request-With"] == null || filterContext.HttpContext.Request.Headers["X-Requested-With"] != "XMLHttpRequest"))))
                {
                    // Do normal form POST antiforgery token check
                    if (filterContext == null)
                    {
                        throw new ArgumentNullException("filterContext");
                    }                    try
                    {
                        ValidateAction();
                    }
                    catch(Exception e)
                    {
                        filterContext.Result = new RedirectResult("~/Home/InvalidRequest");
                    }
                }
            }
        }
    }
}