Dealing with Direct Object References in ASP MVC

If you are not familiar with OWASP site, I highly encourage you take a look at it.  I think everyone can learn something by reading this site.  More specifically, I wanted to concentrate on one of the top 10 mistakes, Insecure Direct Object References.

If you take a look at most of ASP.NET MVC sample applications, you will notice that they are subject to this mistake.  So, if you create a sample application, then add Entity Framework code first context with a table that is using integer primary key, then create new controller with CRUD actions from the add new controller window, then run the app, you will immediately notice that your edit, details and delete links have that primary key as part of the URI.  For example, if you follow Edit link, you will see something like the following in your URI: http://localhost:12345/Products/Edit/1, where 1 is primary key.  If you are creating an intranet site that hosts homogenous data in Product table, you are probably OK.  However, imagine you write a multitenant  internet application, IDs 1 and 2 could belong to two different tenants of you application.  As a result, tenant 1 can see data from tenant 2 by simply incrementing a primary key in the URI.  Now, you have a serious problem on your hands. 

Some of the solutions you might use are

  • Replace integer primary keys with GUIDs
  • Somehow compute hash of the key, then decipher what it is in your controller

I am not a giant fan of first solution, and neither is 99.9 percent of DBAs out there.  You can add a candidate key that is a GUID and use it, but again you make your database less efficient.  What I am going to describe in this post is second solution. 

What I want to do is make sure that the solution will require minimum effort for me to implement and is robust enough.  I am going with encryption now, using a class I described here as a base.  What I am going to do is encrypt this integer primary key when I build the list of products as follows:

@using MvcValidation.Helpers
@model IEnumerable<MvcValidation.Data.Product>
@{
    ViewBag.Title = "Index";
}
<h2>
    Index</h2>
<p>
    @Html.ActionLink("Create New", "Create")
</p>
<table>
    <tr>
        <th>
            Name
        </th>
        <th>
            Start Date
        </th>
        <th>
            End Date
        </th>
        <th>
            Minimum Quantity
        </th>
        <th>
            Maximum Quantity
        </th>
        <th>
        </th>
    </tr>
    @foreach (var item in Model)
    {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Name)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.BeginDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EndDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.BeginQuantity)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EndQuantity)
            </td>
            <td>
                @Html.ActionLink("Edit", "Edit", new { id = 
                    EncryptionUtility.Encrypt(item.Id.ToString(), ViewData["Password"].ToString(), true) })
                @Html.ActionLink("Details", "Details", new { id = 
                    EncryptionUtility.Encrypt(item.Id.ToString(), ViewData["Password"].ToString(), true) })
                @Html.ActionLink("Delete", "Delete", new { id = 
                    EncryptionUtility.Encrypt(item.Id.ToString(), ViewData["Password"].ToString(), true) })
            </td>
        </tr>
    }
</table>

As you can see this code is very simple.  I did however had to modify my utility to make it URI friendly.  Because it is using Base 64 strings, it will potentially result in characters such as “/” or “+”, which will break URIs.  So, I update the utility to take another parameter to take care of this little problem.

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace MvcValidation.Helpers
{
    public static class EncryptionUtility
    {

        /// <summary>
        /// Encrypt the data
        /// </summary>
        /// <param name="input">String to encrypt</param>
        /// <param name="password">The password.</param>
        /// <param name="uriFriendly">if set to <c>true</c> produce URI friendly output.</param>
        /// <returns>
        /// Encrypted string
        /// </returns>
        public static string Encrypt(string input, string password, bool uriFriendly = false)
        {
            if (password.Length < 8)
            {
                password = (password + "zzzzzzzz").Substring(0, 8);
            }
            byte[] utfData = Encoding.UTF8.GetBytes(input);
            byte[] saltBytes = Encoding.UTF8.GetBytes(password);
            string encryptedString;
            using (var aes = new AesManaged())
            {
                var rfc = new Rfc2898DeriveBytes(password, saltBytes);

                aes.BlockSize = aes.LegalBlockSizes[0].MaxSize;
                aes.KeySize = aes.LegalKeySizes[0].MaxSize;
                aes.Key = rfc.GetBytes(aes.KeySize / 8);
                aes.IV = rfc.GetBytes(aes.BlockSize / 8);

                using (var encryptTransform = aes.CreateEncryptor())
                {
                    using (var encryptedStream = new MemoryStream())
                    {
                        using (var encryptor =
                            new CryptoStream(encryptedStream, encryptTransform, CryptoStreamMode.Write))
                        {
                            encryptor.Write(utfData, 0, utfData.Length);
                            encryptor.Flush();
                            encryptor.Close();

                            byte[] encryptBytes = encryptedStream.ToArray();
                            encryptedString = Convert.ToBase64String(encryptBytes);
                        }
                    }
                }
            }
            if (uriFriendly)
            {
                encryptedString = encryptedString.Replace("+", "-").Replace("/", "_");
            }
            return encryptedString;
        }

        /// <summary>
        /// Decrypt a string
        /// </summary>
        /// <param name="input">Input string in base 64 format</param>
        /// <param name="password">The password.</param>
        /// <param name="uriFriendly">if set to <c>true</c> produce URI friendly output.</param>
        /// <returns>
        /// Decrypted string
        /// </returns>
        public static string Decrypt(string input, string password, bool uriFriendly = false)
        {
            if (password.Length < 8)
            {
                password = (password + "zzzzzzzz").Substring(0, 8);
            }
            if (uriFriendly)
            {
                input = input.Replace("-", "+").Replace("_", "/");
            }
            byte[] encryptedBytes = Convert.FromBase64String(input);
            byte[] saltBytes = Encoding.UTF8.GetBytes(password);
            string decryptedString;
            using (var aes = new AesManaged())
            {
                var rfc = new Rfc2898DeriveBytes(password, saltBytes);
                aes.BlockSize = aes.LegalBlockSizes[0].MaxSize;
                aes.KeySize = aes.LegalKeySizes[0].MaxSize;
                aes.Key = rfc.GetBytes(aes.KeySize / 8);
                aes.IV = rfc.GetBytes(aes.BlockSize / 8);

                using (var decryptTransform = aes.CreateDecryptor())
                {
                    using (var decryptedStream = new MemoryStream())
                    {
                        var decryptor =
                            new CryptoStream(decryptedStream, decryptTransform, CryptoStreamMode.Write);
                        decryptor.Write(encryptedBytes, 0, encryptedBytes.Length);
                        decryptor.Flush();
                        decryptor.Close();

                        byte[] decryptBytes = decryptedStream.ToArray();
                        decryptedString =
                            Encoding.UTF8.GetString(decryptBytes, 0, decryptBytes.Length);
                    }
                }
            }

            return decryptedString;
        }
    }
}

This code for URI friendliness is pretty simple.  I also altered the code to make sure it will automatically handle password (salt) that is smaller than 8 characters.  You will kno0w shortly as to why I did that.  Because the utility produces same results with the same input and password, I will end up with the following issue.  If I have products and categories controllers, both will produce the same URI for the same integer ID.  I want to make it a bit more secure, so I am going to use controller name as the key.  So, the password will be set by controller as follows:

using System.Data;
using System.Linq;
using System.Web.Mvc;
using MvcValidation.Data;

namespace MvcValidation.Controllers
{
    public class ProductController : Controller
    {
        private readonly ProductContext _db = new ProductContext();

        //
        // GET: /Product/

        public ViewResult Index()
        {
            ViewData.Add("Password", GetType().Name.Replace("Controller", "").ToLower());
            return View(_db.Products.ToList());
        }

I could probably use full class name as well, but I want to avoid writing decryption code in controllers, thus I want to use MVC routing to decrypt the data.  I am going to write a custom route that does exactly that.

using System.Web;
using System.Web.Routing;
using MvcValidation.Helpers;

namespace MvcValidation.Routing
{
    public class RouteWithEncryption : RouteBase
    {
        private readonly RouteBase _inner;

        /// <summary>
        /// Initializes a new instance of the <see cref="RouteWithEncryption"/> class.
        /// </summary>
        /// <param name="routeToWrap">The route to wrap.</param>
        public RouteWithEncryption(RouteBase routeToWrap)
        {
            _inner = routeToWrap;
        }

        /// <summary>
        /// When overridden in a derived class, returns route information about the request.
        /// </summary>
        /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param>
        /// <returns>
        /// An object that contains the values from the route definition if the route matches the current request, or null if the route does not match the request.
        /// </returns>
        public override RouteData GetRouteData(HttpContextBase httpContext)
        {
            var routeData = _inner.GetRouteData(httpContext);
            if (routeData != null)
            {
                if (routeData.Values.ContainsKey("id") && routeData.Values.ContainsKey("controller") && routeData.Values.ContainsKey("action"))
                {
                    var id = routeData.Values["id"].ToString();
                    if (!string.IsNullOrEmpty(id))
                    {
                        var password = routeData.Values["controller"].ToString().ToLower();
                        int test;
                        if (!int.TryParse(id, out test))
                        {
                            var replacement = EncryptionUtility.Decrypt(id, password, true);
                            routeData.Values["id"] = replacement;
                        }
                    }
                }
            }
            return routeData;
        }

        /// <summary>
        /// When overridden in a derived class, checks whether the route matches the specified values, and if so, generates a URL and retrieves information about the route.
        /// </summary>
        /// <param name="requestContext">An object that encapsulates information about the requested route.</param>
        /// <param name="values">An object that contains the parameters for a route.</param>
        /// <returns>
        /// An object that contains the generated URL and information about the route, or null if the route does not match <paramref name="values"/>.
        /// </returns>
        public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
        {
            return _inner.GetVirtualPath(requestContext, values);
        }
    }
}

As you can see, I am pulling controller name and ID from route values collection and decrypting the ID using controller name as password.  Voila, now you know why I use controller name.  Now, I am going to update all routes to use my encrypting route in Global.asax.cs:

using System.Web.Mvc;
using System.Web.Routing;
using MvcValidation.Routing;

namespace MvcValidation
{
    // Note: For instructions on enabling IIS6 or IIS7 classic mode, 
    // visit http://go.microsoft.com/?LinkId=9394801

    public class MvcApplication : System.Web.HttpApplication
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
        }

        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                "Default", // Route name
                "{controller}/{action}/{id}", // URL with parameters
                new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
            );

            for (var i = 0; i < routes.Count; i++)
            {
                routes[i] = new RouteWithEncryption(routes[i]);
            }
        }

That is it, now we have hidden primary keys from users.  You can download entire solution here.

Thanks.