Fixing the Remote validation attribute

Using the [Remote] validation attribute in MVC does something cool – it will generate some AJAX for you to call your server-side code for validation. This is great, but what it doesn’t do is call this code on the server side upon postback – unlike the other attributes do. This sucks!

In other words, tagging a property with [StringLength(3)] means that the javascript validation will stop the user from entering a string longer than 3 characters. If a sneaky user came along and turned off javascript, when they submit the form the MVC framework will still validate the string and ensure it’s not any longer than 3 characters. This is cool.

However, if you are using the [Remote] attribute to do some validation that is slightly more clever, like validating that some data entered isn’t already in the database, if the user turns off javascript, the validation will not run automatically on the server. This is deceptively dangerous, because you really expect it to work like the other ones do. You want it to work like this:

better mvc3 remote validation

A simple fix would be “remember to call the same validate logic in your controller too” – but as we all know, developers forget things. Talk about a sucky security hole.

So instead, I’ve written some code that calls the server side remote validation for you within the Model Binding. This means it’s all done seamlessly, all you need to do is check ModelState.IsValid like you normally would. If you screw up your parameters, it’ll fail silently, something that I’m not crazy about, but hey at least it’s consistent with how the javascript validation works (it fails silently too).

The first step is to subclass the [RemoteAttribute] class:

public class CustomRemoteAttribute: RemoteAttribute
{
  public CustomRemoteAttribute(string action, string controller)
    : base(action, controller)
  {
    Action = action;
    Controller = controller;
  }
  public string Action { get; set; }
  public string Controller { get; set; }
}

Strictly speaking this shouldn’t be neccesary. According to this, the RouteData class should be public. But on my machine (MVC3 I think) it’s private. So this is my workaround.

The next step is to use this attribute within my ViewModel:

public class TestPage1ViewModel
{
  [Required(ErrorMessage="Please enter this")]
  [StringLength(3)]
  public string FirstName { get; set; }

  [CustomRemoteAttribute( "ValidateLastName", "Test")]
  public string LastName { get; set; }

  public string Names { get; set; }
}

and then use this viewmodel within my page:

@model MvcApplication3.Models.TestPage1ViewModel

@using (@Html.BeginForm())
{
  @Html.ValidationSummary()

  <fieldset>
    First Name:<br />
    @Html.TextBoxFor(x => x.FirstName)
    @Html.ValidationMessageFor(x => x.FirstName)
    <br /><br />

    Last Name:<br />
    @Html.TextBoxFor(x => x.LastName)
    @Html.ValidationMessageFor(x => x.LastName)
    <br /><br />

    <input type="submit" />
  </fieldset>

  <p>
    Calculated names:
    @Model.Names
  </p>
}

now I need to add the validator code to my controller:

public class TestController : Controller
{
  public ActionResult ViewPage1()
  {
    TestPage1ViewModel vm = new TestPage1ViewModel();
    return View(vm);
  }

  [HttpPost]
  public ActionResult ViewPage1(TestPage1ViewModel vm)
  {
    if (ModelState.IsValid)
    {
      vm.Names = vm.FirstName + " " + vm.LastName;
    }
    return View(vm);
  }

  public JsonResult ValidateLastName(string LastName)
  {
    if (LastName != null)
    {
      if (LastName.ToLower().Trim() == "smith")
      {
        return Json("sorry, I don't like the surname smith",
          JsonRequestBehavior.AllowGet);
      }
    }
    return Json(true, JsonRequestBehavior.AllowGet);
  }
}

And now for the magic! We’re going to define a new custom Model binder that looks for the CustomRemoteAttribute and tries to call the validator using reflection:

public class CustomModelBinder : DefaultModelBinder
{

  /// <summary>
  /// This custom validator checks to see if the property has a
  /// CustomRemoteAttribute on it. If so, it runs the remote
  /// validator function (from here, ie the server side using
  /// reflection) and if it fails it sets the model state error
  /// on the property.
  /// </summary>
  /// <param name="controllerContext"></param>
  /// <param name="bindingContext"></param>
  /// <param name="propertyDescriptor"></param>
  protected override void BindProperty(ControllerContext controllerContext,
    ModelBindingContext bindingContext,
    PropertyDescriptor propertyDescriptor)
  {
    if (propertyDescriptor.PropertyType == typeof(string))
    {
      CustomRemoteAttribute remoteAttribute =
        propertyDescriptor.Attributes.OfType()
          .FirstOrDefault();

      if (remoteAttribute != null)
      {
        List allControllers = GetControllerNames();

        Type controllerType = allControllers.Where(x => x.Name ==
            remoteAttribute.Controller + "Controller").FirstOrDefault();

        if (controllerType != null)
        {
          MethodInfo methodInfo = controllerType.GetMethod(remoteAttribute.Action);

          if (methodInfo != null)
          {
            string validationResponse = callRemoteValidationFunction(
              controllerContext,
              bindingContext,
              propertyDescriptor,
              controllerType,
              methodInfo,
              remoteAttribute.AdditionalFields);

            if (validationResponse != null)
            {
              bindingContext.ModelState.AddModelError(propertyDescriptor.Name,
                validationResponse);
            }
          }
        }
      }
    }

    base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
  }

  /// This function calls the indicated method on a new instance of the supplied
  /// controller type and return the error string. (NULL if not)
  private string callRemoteValidationFunction(
    ControllerContext controllerContext,
    ModelBindingContext bindingContext,
    PropertyDescriptor propertyDescriptor,
    Type ControllerType,
    MethodInfo methodInfo,
    string AdditionalFields)
  {

    string propertyValue = controllerContext.RequestContext.HttpContext.Request.Form[
        bindingContext.ModelName + propertyDescriptor.Name];

    Controller controller = (Controller)Activator.CreateInstance(ControllerType);
    object result = null;
    ParameterInfo[] parameters = methodInfo.GetParameters();
    if (parameters.Length == 0)
    {
      result = methodInfo.Invoke(controller, null);
    }
    else
    {
      List parametersArray = new List();

      parametersArray.Add(propertyValue);

      if (parameters.Length == 1)
      {
        result = methodInfo.Invoke(controller, parametersArray.ToArray());
      }
      else
      {
        if (!string.IsNullOrEmpty(AdditionalFields))
        {
          foreach (var additionalFieldName in AdditionalFields.Split(','))
          {
            string additionalFieldValue =
                controllerContext.RequestContext.HttpContext.Request.Form[
                  bindingContext.ModelName + additionalFieldName];
            parametersArray.Add(additionalFieldValue);
          }

          if (parametersArray.Count == parameters.Length)
          {
            result = methodInfo.Invoke(controller, parametersArray.ToArray());
          }
        }
      }
    }

    if (result != null)
    {
      return (((JsonResult)result).Data as string);
    }
    return null;
  }

  /// Returns a list of all Controller types
  private List<Type> GetControllerNames()
  {
    List<Type> controllerNames = new List<Type>();
    GetSubClasses<Controller>().ForEach(type => controllerNames.Add(type));
    return controllerNames;
  }

  private List<Type> GetSubClasses<T>()
  {
    return Assembly.GetCallingAssembly().GetTypes().Where(
      type => type.IsSubclassOf(typeof(T))).ToList();
  }

}

One last step! Wire up the CustomBinder!

protected void Application_Start()
{
  ...

  ModelBinders.Binders.DefaultBinder = new CustomModelBinder();
}

That should do it! You can now disable javascript, enter “smith” as a surname, and BAM, validation!

You can also use the “AdditionalFields” attribute:

  [CustomRemoteAttribute("ValidateAllNames", "Test",
    AdditionalFields="FirstName,LastName")]
  public string MiddleName { get; set; }

but make sure that you name your fields properly, otherwise it’ll fail SILENTLY !!

This entry was posted in technical. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>