Tamper-proof hidden fields in ASP.NET MVC

A common practice a lot of web developers employ is storing random referential data in hidden fields in the views. More often than not, this includes identifiers to database records. Any relatively knowledgeable user can alter these fields with an in-browser DOM editor. Hence, a good rule of thumb is to always validate every piece of data received from the user and make sure they're persisting all data as they should.

What I'd like to show is a simple way to help mitigate the tampering of the data in hidden fields by generating an encrypted token of the original value and then validating that the value still matches the token when it's submitted by the user. This works very similar to the AntiForgeryToken mechanism already implemented in MVC. Just to make it clear, this trick doesn't alleviate the need for data validation. It is merely a way to provide extra layer of protection.

Let's start by creating a new set of HTML helpers to spin-up protected hidden fields. Each helper will generate two hidden fields - one with the original value and one with the encrypted Base64 string of the original value (the token). If the attacker tries to modify a protected hidden field, they'd also have to provide the correct encrypted token.

public static class HtmlExtensions  
{
    public static MvcHtmlString SecureHidden(this HtmlHelper htmlHelper, string name)
    {
        return SecureHidden(htmlHelper, name, value: null, htmlAttributes: null);
    }

    public static MvcHtmlString SecureHidden(this HtmlHelper htmlHelper, string name, object value)
    {
        return SecureHidden(htmlHelper, name, value, htmlAttributes: null);
    }

    public static MvcHtmlString SecureHidden(this HtmlHelper htmlHelper, string name, object value, object htmlAttributes)
    {
        return SecureHidden(htmlHelper, name, value, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
    }

    public static MvcHtmlString SecureHidden(
        this HtmlHelper htmlHelper,
        string name,
        object value,
        IDictionary<string, object> htmlAttributes)
    {
        return SecureHiddenHelper(htmlHelper, value: value, name: name, htmlAttributes: htmlAttributes);
    }

    public static MvcHtmlString SecureHiddenFor<TModel, TProperty>(
        this HtmlHelper<TModel> htmlHelper,
        Expression<Func<TModel, TProperty>> expression)
    {
        return SecureHiddenFor(htmlHelper, expression, null);
    }

    public static MvcHtmlString SecureHiddenFor<TModel, TProperty>(
        this HtmlHelper<TModel> htmlHelper,
        Expression<Func<TModel, TProperty>> expression,
        object htmlAttributes)
    {
        return SecureHiddenFor(htmlHelper, expression, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
    }

    public static MvcHtmlString SecureHiddenFor<TModel, TProperty>(
        this HtmlHelper<TModel> htmlHelper,
        Expression<Func<TModel, TProperty>> expression,
        IDictionary<string, object> htmlAttributes)
    {
        var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);

        return SecureHiddenHelper(htmlHelper, metadata.Model, ExpressionHelper.GetExpressionText(expression), htmlAttributes);
    }

    public static MvcHtmlString DisableIf(this MvcHtmlString instance, Func<bool> expression)
    {
        const string Disabled = "\"disabled\"";

        if (expression.Invoke())
        {
            var html = instance.ToString();
            html = html.Insert(html.IndexOf(">", StringComparison.Ordinal), " disabled= " + Disabled);
            return new MvcHtmlString(html);
        }

        return instance;
    }

    private static MvcHtmlString SecureHiddenHelper(
        HtmlHelper htmlHelper,
        object value,
        string name,
        IDictionary<string, object> htmlAttributes)
    {
        var binaryValue = value as Binary;

        if (binaryValue != null)
        {
            value = binaryValue.ToArray();
        }

        var byteArrayValue = value as byte[];

        if (byteArrayValue != null)
        {
            value = Convert.ToBase64String(byteArrayValue);
        }

        return InputHelper(htmlHelper, name, value, setId: true, format: null, htmlAttributes: htmlAttributes);
    }

    private static MvcHtmlString InputHelper(
        HtmlHelper htmlHelper,
        string name,
        object value,
        bool setId,
        string format,
        IDictionary<string, object> htmlAttributes)
    {
        var fullName = htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);

        if (string.IsNullOrEmpty(fullName))
        {
            throw new ArgumentException("name");
        }

        var inputItemBuilder = new StringBuilder();

        var hiddenInput = new TagBuilder("input");
        hiddenInput.MergeAttributes(htmlAttributes);
        hiddenInput.MergeAttribute("type", HtmlHelper.GetInputTypeString(InputType.Hidden));
        hiddenInput.MergeAttribute("name", fullName, true);
        hiddenInput.MergeAttribute("value", htmlHelper.FormatValue(value, format));

        var hiddenInputHash = new TagBuilder("input");
        hiddenInputHash.MergeAttribute("type", HtmlHelper.GetInputTypeString(InputType.Hidden));
        hiddenInputHash.MergeAttribute("name", string.Format("__{0}Token", fullName), true);

        var identity = htmlHelper.ViewContext.HttpContext.User.Identity;

        if (!string.IsNullOrEmpty(identity.Name))
        {
            value = string.Format("{0}_{1}", identity.Name, value);
        }

        var encodedValue = Encoding.Unicode.GetBytes(htmlHelper.FormatValue(value, format));

        hiddenInputHash.MergeAttribute(
            "value",
            Convert.ToBase64String(MachineKey.Protect(encodedValue, "Protected Hidden Input Token")));

        if (setId)
        {
            hiddenInput.GenerateId(fullName);
            hiddenInputHash.GenerateId(string.Format("__{0}Token", fullName));
        }

        inputItemBuilder.Append(hiddenInput.ToString(TagRenderMode.SelfClosing));
        inputItemBuilder.Append(hiddenInputHash.ToString(TagRenderMode.SelfClosing));

        return MvcHtmlString.Create(inputItemBuilder.ToString());
    }
}

The class above provides multiple overloads of SecureHidden and SecureHiddenFor, which allows the consumer to generate a protected hidden for a property of a view model or an arbitrary form field name and value. The InputHelper method does all of the grunt work. I'm using MachineKey for all encryption purposes. This is the same encryption mechanism used internally by the framework to encrypt authentication cookies and view state.

In addition, I'm relying on the current user identity to provide user-specific encryption by combining username with the original value. This makes it more difficult to use the encrypted token across accounts. However, this limits the use of the helper to authenticated scenarious. You could probably use session ID here or something similar. In fact, if I remember correctly, the AntiForgeryToken implementation uses session for this purpose.

The next step is to register these helpers with Razor. Head on over to your Views folder and open Web.config. In the <namespace> node of <system.web.webPages.razor> reference the namespace containing your new helpers. You should now be able to use your helpers as you would any other native helper in MVC.

@Html.SecureHiddenFor(model => model.SomeProperty)

The above should result in generated HTML similar to the one below.

<input id="SomeProperty" name="SomeProperty" type="hidden" value="I love lamp">  
<input name="__SomePropertyToken" type="hidden" value="zqb7MIL2Y5F3jL96ncdSZOmetL8g8RAWZP8Y/w/jUAKJ89GcUViRWOZ/XtQhtICMFZb4sQtZLOpqK/WyC0TFP0B6r+3nObFGDjb0U459yzQbadC4+DLIsTmhyYeT+ZT+bnW1AEP2fgVyXXSduYIf5vns7g9nhRWTgJo8xF6NQyT6kNgyl5puq+BYc8dfhMXn">  

Finally, we need to validate all protected hidden fields. This is done by implementing a filter attribute for controller actions, which would specify the names of the hidden fields which need to be validated. The validation process is very trivial. We'll just decrypt the token and compare it to the current username and hidden field value combination. If they do not match we'll throw an exception. Otherwise everything continues as it should.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class ValidateSecureHiddenInputsAttribute : FilterAttribute, IAuthorizationFilter  
{
    private readonly string[] properties;

    public ValidateSecureHiddenInputsAttribute(params string[] properties)
    {
        if (properties == null || !properties.Any())
        {
            throw new ArgumentException("properties");
        }

        this.properties = properties;
    }

    public void OnAuthorization(AuthorizationContext filterContext)
    {
        if (filterContext == null)
        {
            throw new ArgumentNullException("filterContext");
        }

        this.properties.ToList().ForEach(property => Validate(filterContext, property));
    }

    private static void Validate(AuthorizationContext filterContext, string property)
    {
        var protectedValue = filterContext.HttpContext.Request.Form[string.Format("__{0}Token", property)];
        var decodedValue = Convert.FromBase64String(protectedValue);

        var decryptedValue = MachineKey.Unprotect(decodedValue, "Protected Hidden Input Token");

        if (decryptedValue == null)
        {
            throw new HttpSecureHiddenInputException("A required security token was not supplied or was invalid.");
        }

        protectedValue = Encoding.Unicode.GetString(decryptedValue);

        var originalValue = filterContext.HttpContext.Request.Form[property];

        var identity = filterContext.HttpContext.User.Identity;

        if (!string.IsNullOrEmpty(identity.Name))
        {
            originalValue = string.Format("{0}_{1}", identity.Name, originalValue);
        }

        if (!protectedValue.Equals(originalValue))
        {
            throw new HttpSecureHiddenInputException("A required security token was not supplied or was invalid.");
        }
    }
}

public class HttpSecureHiddenInputException : Exception  
{
    public HttpSecureHiddenInputException(string message) : base(message)
    {
    }
}

With all pieces in place and assuming you created a protected hidden field...

@Html.SecureHiddenFor(model => model.SomeProperty)

You can validate it by decorating your target action.

[ValidateSecureHiddenInputs("SomeProperty")]
public ActionResult Index(MyViewModel model)  
{
    return View();
}

Happy coding!