Technology
Episerver Development

How to Manually Implement AntiForgeryToken on Episerver Forms

Sep 17, 2020
Victor Bauz

The Issue

Usually, whenever we're displaying a form, we would like to make sure it's protected from malicious attacks as much as possible. In .netFramework, we can easily use the [ValidateAntiForgeryToken] decorator on the Action method and make a call to @Html.AntiForgeryToken() in the view we're displaying. However, in certain situations this would not be possible. In this case when we're using a webhook to send the data to the backend, Episerver's context can override the context we're working with, and get rid of certain data we need to mark our view model as valid. Therefore, we will need to manually implement a way to mark the data we're posting as valid.

The Fix

First things first: we will need to create the block that will contain the hidden token we will generate to validate.

AntiforgeryTokenElementBlock
[ContentType(
    GUID = "{DD088FD8-895E-47EF-9497-5B7A6700F4A6}",
    GroupName = EPiServer.Forms.Constants.FormElementGroup_Container,
    Order = 4000, DisplayName = "AntiForgeryToken")]
public class AntiforgeryTokenElementBlock : ElementBlockBase
{
    public virtual string AntiForgeryToken { get; set; }
 
    public override void SetDefaultValues(ContentType contentType)
    {
        base.SetDefaultValues(contentType);
    }
}

In order to keep things organized, and as loose coupled as possible, we will delegate the content creation to a factory easily registrable as a dependency in Episerver. This factory implementation will make use of a couple more components like an Encryption handler and a storage handler. We can use a simple implementation that Episerver has to offer. Here we will take a GUID as a string and encode it using two different seeds. One of this encoded strings will be rendered on a hidden field and the second one will have to be stored, either using the IStateStorage interface or our custom storage implementation like a database or a cache service like Redis.

AntiForgeryFactory
public class AntiForgeryFactory : IAntiForgeryFactory
    {
        private const string SessionGuidSeed = "70e83b85-9135-42f1-a48d-4e1c5349b412";
        private const string FieldGuidSeed = "0b108811-3e25-40fb-8f97-403012b0f618";
        private readonly EncryptionHandler _sessionEncryptDecrypt;
        private readonly EncryptionHandler _fieldEncryptDecrypt;
        private const string TokenName = "_tokensession";
        private readonly IStateStorage _storage;
 
        public AntiForgeryFactory(IStateStorage storage)
        {
            _storage = storage;
            _fieldEncryptDecrypt = new EncryptionHandler(FieldGuidSeed);
            _sessionEncryptDecrypt = new EncryptionHandler(SessionGuidSeed);
        }
 
        public string CreateToken(string token)
        {
            var field = _fieldEncryptDecrypt.Encrypt(token);
            var session = _sessionEncryptDecrypt.Encrypt(token);
            _storage.Save($"{TokenName}:{token}", session);
            return field;
        }
 
        public bool CheckToken(string field)
        {
            var fieldToken = _fieldEncryptDecrypt.Decrypt(field);
            var key = $"{TokenName}:{fieldToken}";
            var session = _storage.Load(key).ToString();
            if (session == string.Empty) return false;
            _storage.Delete(key);
            var sessionToken = _sessionEncryptDecrypt.Decrypt(session);
            return sessionToken.Equals(fieldToken);
        }
    }
InMemoryStateStorage
[InitializableModule]
    internal class InMemoryStateStorage : IStateStorage, IConfigurableModule
    {
        IDictionary<string, string> _states = new Dictionary<string, string>();
 
        public bool IsAvailable => true;
        public object Load(string key)
        {
            _states.TryGetValue(key, out var value);
            return value ?? string.Empty;
        }
 
        public void Save(string key, object value)
        {
            _states[key] = (string)value;
        }
 
        public void Delete(string key)
        {
            _states.Remove(key);
        }
 
        public void ConfigureContainer(ServiceConfigurationContext context)
        {
            context.Services.Add<VisitorGroupOptions>((s) => new VisitorGroupOptions(), ServiceInstanceScope.Singleton);
            context.Services.Configure<VisitorGroupOptions>(s => s.EnableSession = false);
            context.Services.Add<IStateStorage>(s => new InMemoryStateStorage(), ServiceInstanceScope.Singleton);
        }
 
        public void Initialize(InitializationEngine context) { }
        public void Uninitialize(InitializationEngine context) { }
    }

Now, since the token will need to be renewed every time we load the form, we need a controller to assign the GUID mentioned previously.

AntiforgeryTokenElementBlockController
public class AntiforgeryTokenElementBlockController : PartialContentController<AntiforgeryTokenElementBlock>
    {
        public static string ViewPath = "~/Views/Forms/ElementBlocks/AntiforgeryTokenElementBlock.cshtml";
        private readonly IAntiForgeryFactory _antiForgery;
 
        public AntiforgeryTokenElementBlockController(IAntiForgeryFactory antiForgery)
        {
            _antiForgery = antiForgery;
        }
 
        public override ActionResult Index(AntiforgeryTokenElementBlock currentBlock)
        {
            var model = currentBlock.CreateWritableClone() as AntiforgeryTokenElementBlock ?? new AntiforgeryTokenElementBlock();
            var field = _antiForgery.CreateToken(Guid.NewGuid().ToString());
            model.AntiForgeryToken = field;
            return PartialView(ViewPath, model);
        }
    }

After that we will need to render the encrypted token in a hidden field. This is easily achieved by rendering the block previously created.

AntiForgeryTokenElementBlock.cshtml
@using EPiServer.Forms.Helpers.Internal
@model AlloyTraining.Models.ElementBlocks.AntiforgeryTokenElementBlock
@{
    var formElement = Model.FormElement;
}
 
@using (Html.BeginElement(Model, new { }))
{
    <input name="@formElement.ElementName" id="@formElement.Guid" type="hidden" class="Form__Input"
           aria-describedby="@Util.GetAriaDescribedByElementName(formElement.ElementName)"
           value="@Model.AntiForgeryToken" @Model.AttributesString data-f-datainput />
}

Now that we have created the custom block and handled the token creation and rendering, we will need to build our Episerver Form. We just need a couple of fields: our custom block(which can be found on the left side under Form Element Blocks) and a submit button.



We will also need to enter the Webhook URL of the endpoint listening. In this case: http://localhost:52271/Forms/NewsFeedSubscription

Webhook URL

Now that the webhook will trigger a Post request to the url we have set, we need to configure the actual endpoint on the Episerver backend. It's a common practice to name it FormsController.

FormsController
public class FormsController : Controller
{
    private readonly IAntiForgeryFactory _antiForgery;
    private readonly ILog _logger = LogManager.GetLogger(typeof(FormsController));
     
    public FormsController(IAntiForgeryFactory antiForgery)
    {
        _antiForgery = antiForgery;
    }
 
    [HttpPost]
    public JsonResult NewsFeedSubscription(NewsFeedSubscriptionRequestVM model)
    {
        var valid = _antiForgery.CheckToken(model.AntiforgeryTokenElementBlock);
        _logger.Error($"Anti forgery validation for NewsFeedSubscription: {valid}");
        return new JsonResult();
    }
}

The model this endpoint is expecting needs to match the Name attribute we set during the fields creation because this will be mapped accordingly.

NewsFeedSubscriptionRequestVM
public class NewsFeedSubscriptionRequestVM : AntiForgeryTokenVM
{
    public string Email { get; set; }
    public string FullName { get; set; }
}

Again, in order to keep our code as loose coupled as possible, we will have to use a nice interface to get the token property without interfering with the business logic stablished inside our view model.

AntiForgeryTokenVM
public class AntiForgeryTokenVM
{
    public string AntiforgeryTokenElementBlock { get; set; }
}

However, in order to reach this endpoint, we need to register the default route in our global.asax file like this:

Global.asax route
protected override void RegisterRoutes(RouteCollection routes)
{
    base.RegisterRoutes(routes);
    routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { action = "Index", id = UrlParameter.Optional });
}

We can check if the form has rendered our hidden field with the encrypted token as the field value.


Now that's all set we can actually test our form.

As expected, our breakpoint was hit with the data we've sent using the Episerver form and the webhook.


Now we can decode the field string back into a GUID and use this to look for the second encoded string we had previously stored, so we can decode the second string and compare them both.

As we can see the GUIDs will match returning a true as validation therefore allowing a single submit per token.


We can even develop a small decorator that can handle this validation for us:

CustomAntiForgeryTokenValidation
public class CustomAntiForgeryTokenValidation : ActionFilterAttribute
    {
        private readonly IAntiForgeryFactory _antiForgery;
 
        public CustomAntiForgeryTokenValidation()
        {
            _antiForgery = ServiceLocator.Current.GetInstance<IAntiForgeryFactory>();
        }
 
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var token = filterContext.ActionParameters["model"] as AntiForgeryTokenVM;
            var result = _antiForgery.CheckToken(token.AntiforgeryTokenElementBlock);
 
            if (!result)
            {
                filterContext.Result = new JsonResult { Data = new { Message = "Error validating the token", Result = false } };
            }
        }
    }

This way we can submit another set of data, and catch it inside the decorator instead of the endpoint itself. And since this token is valid, the next breakpoint hit will be the endpoint. We can proceed and remove the antiforgery validation here now.



However if we were to refresh the browser page and re-submit the form, this will not be validated, and therefore not execute the call to the endpoint.



Happy coding!


Related:

Session handling in visitor group criteria