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.

11 Comments

  1. Hi i am getting an error while RedirectoRoute…. its throwing exception that called method is not present in route table… please let me know the possible issue . thanks in advance

    Best Regards’,
    H.S.

  2. @Hemant
    You need to double check your route table and make sure you are not replacing something in the route values that would prevent the match against an existing route. Maybe you are encrypting incorrect value?

  3. Hi Sergery… Thanks for your quick reply…
    still getting the same error –
    route named ‘myroutename’ could not be found in the route collection. Parameter name: name
    what i figured out is –
    RouteWithEncryption changes the location of elements present in System.Web.Routing.Route to one level down in the _inner. but for reading the same we havn’t done any changes so if i use
    return RedirectToAction it is giving me error –
    The resource cannot be found.
    and if i use RedirecttoRoute – route name not found error.
    I tried to download and run the attached code but the soultion is not also opening… it is also giving me error… cant load one or more project 🙂 …
    this encrypting is giving me hard time .. getting error on every single step …

  4. I am not sure why you cannot open my solution. I am using VS 2010 with MVC 3. There should not be any reason why this does not work for you. There are a few checks you need to do. First of all, unblock the zip file in Windows before you unzip it. VS does not like solutions downloaded from the internet. Also, make sure to run VS as administrator, as this may cause issues as well. Check output windows after you open the solution and see what errors you are seeing. We can go from there. If you want to put a sample app, I can try to take a look for you, although I think if you open my solution, you will likely figure it out on your own, I believe.
    Thanks
    Sergey.

  5. What’s the value in using an encryption layer like this? The key is deterministic and based on non-secret data (the controller name), so all it really is is a form of obfuscation rather than true encryption.

  6. The value, just as I mentioned, is to avoid direct object references, which is one of top 10 in OWASP. If you just have 1 or 2 in the query string, any user can just start typing 3, 4, etc…, possibly viewing information he/she is not entitled to.

  7. Well, I don’t think this is really going to fix that security hole. If anyone get to know those URLs, s/he can still view information. Correct way to do this, as I normally I used to do, is having check in Action Method for logged in user – productService.CanViewThis(id).

  8. @Just Sunny,
    I think you are speaking of authorization, which technically has nothing to do with Direct Object References. The solution I describe is there to remove that specific problem, not address authorization issues. You need both, that is correct.
    Thanks.

  9. Hey Sergery,

    This is an excellent post, looking for this for ages! After following your method to secure the keys, I was stuck with ‘Areas’ creating issues while routing, I had to ignore encrypting the Areas to solve the issues. Anyways, thanks for this and hope MS includes these kind of security pattern in new versions of MVC.

  10. Pingback: Dealing with Direct Object References in Web Api and Angular | Sergey Barskiy's Blog

Leave a Reply to Hemant Cancel reply

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