We’ve all been there before, needing to bridge the gap between a legacy system and our modern ASP.NET Core MVC application. I recently had an office hour with a great developer named Ramakrishna that needed to support a legacy security format. A legacy application encrypted all the values in a single query string parameter then made an HTTP request to an ASP.NET Core MVC endpoint.

This post will see how we can leverage Action Filters in ASP.NET Core MVC to make the development experience in our action methods the same one we’ve been using all this time.

The Problem

Legacy systems may not have access to all the protocols and security patterns we have today, so they might have had to do some creative problem-solving. In our case, we have a query string parameter that is encrypted using TripleDES, and we expect the receiver to decrypt the value.

/?secret=1w58y%2BC60jon25f%2F4VvVHUOX%2FIxs%2FEVx

This technique is likely because secrets in URLs might end up in logs. Whatever the reason, we have a problem to solve.

Our receiving endpoint will have two parameters of number and name that we expect to have values by the time we’re in the context of our action.

[Route(""), HttpPost]
public IActionResult IndexPost(int number, string name)
{
    return View("Index",
        new IndexModel
        {
            Number = number,
            Name = name
        });
}

We have a straightforward solution to this problem, and it involves Action Filters.

A Solution Using Action Filters and Action Arguments

Action Filters is a great way to intercept incoming requests and enhance the execution pipeline. In our case, we want to transform an existing query string parameter (secret), decrypt its values, and set the parameters on the action method.

Our final usage will use an implemented EncryptedParameters attribute, which we’ll decorate on applicable methods.

[Route(""), HttpPost]
[EncryptedParameters("secret")]
public IActionResult IndexPost(int number, string name)
{
    return View("Index",
        new IndexModel
        {
            Number = number,
            Name = name
        });
}

Let’s look at the implementation of EncryptedParameters.

using System;
using System.Globalization;
using System.Linq;
using System.Web;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Secrets.Models;

namespace Secrets.Controllers
{
    public class EncryptedParameters : ActionFilterAttribute
    {
        public string ParameterName { get; }

        public EncryptedParameters(string parameterName = "secret")
        {
            ParameterName = parameterName;
        }

        public override void OnActionExecuting(ActionExecutingContext context)
        {
            var config = context.HttpContext.RequestServices.GetRequiredService<IOptions<CryptoEngine.Secrets>>();
            var encrypted = context.HttpContext.Request.Query[ParameterName].FirstOrDefault();

            // decrypt secret
            var decrypted = CryptoEngine.Decrypt(encrypted, config.Value.Key);
            var collection = HttpUtility.ParseQueryString(decrypted);
            var actionParameters = context.ActionDescriptor.Parameters;

            foreach (var parameter in actionParameters)
            {
                try
                {
                    var value = collection[parameter.Name];

                    if (value == null)
                        continue;

                    // set the action arguments to the values 
                    // from the encrypted parameter
                    context.ActionArguments[parameter.Name] =
                        ConvertToType(value, parameter.ParameterType);
                }
                catch (Exception e)
                {
                    context.ModelState.TryAddModelException(parameter.Name, e);
                }
            }
        }

        private static object? ConvertToType(string value, Type type)
        {
            var underlyingType = Nullable.GetUnderlyingType(type);

            if (value.Length > 0)
            {
                if (type == typeof(DateTimeOffset) || underlyingType == typeof(DateTimeOffset))
                {
                    return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
                }

                if (type == typeof(DateTime) || underlyingType == typeof(DateTime))
                {
                    return DateTime.Parse(value, CultureInfo.InvariantCulture);
                }

                if (type == typeof(Guid) || underlyingType == typeof(Guid))
                {
                    return new Guid(value);
                }

                if (type == typeof(Uri) || underlyingType == typeof(Uri))
                {
                    if (Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var uri))
                    {
                        return uri;
                    }

                    return null;
                }
            }
            else
            {
                if (type == typeof(Guid))
                {
                    return default(Guid);
                }

                if (underlyingType != null)
                {
                    return null;
                }
            }

            if (underlyingType is not null)
            {
                return Convert.ChangeType(value, underlyingType);
            }

            return Convert.ChangeType(value, type);
        }
    }
}

Using the ActionExecutingContext, we have access to the parameters of our target endpoint. Then, it’s only a matter of decrypting our secret, and then setting the ActionArguments using our known parameters.

public override void OnActionExecuting(ActionExecutingContext context)
{
    var config = context.HttpContext.RequestServices.GetRequiredService<IOptions<CryptoEngine.Secrets>>();
    var encrypted = context.HttpContext.Request.Query[ParameterName].FirstOrDefault();

    // decrypt secret
    var decrypted = CryptoEngine.Decrypt(encrypted, config.Value.Key);
    var collection = HttpUtility.ParseQueryString(decrypted);
    var actionParameters = context.ActionDescriptor.Parameters;

    foreach (var parameter in actionParameters)
    {
        try
        {
            var value = collection[parameter.Name];

            if (value == null)
                continue;

            // set the action arguments to the values 
            // from the encrypted parameter
            context.ActionArguments[parameter.Name] =
                ConvertToType(value, parameter.ParameterType);
        }
        catch (Exception e)
        {
            context.ModelState.TryAddModelException(parameter.Name, e);
        }
    }
}

The conversion of our types handles primitives and nullable types but does not handle complex models. Feel free to modify this code to meet your particular needs.

For folks interested in the CryptoEngine code, here it is, but I do not consider myself a cryptography expert.

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

namespace Secrets.Models
{
    /// <summary>
    /// modified from the following post
    /// https://dotnetcodr.com/2015/10/23/encrypt-and-decrypt-plain-string-with-triple-des-in-c/
    /// </summary>
    public static class CryptoEngine
    {
        public class Secrets
        {
            public string Key { get; set; }
        }

        public static string Encrypt(string source, string key)
        {
            var byteHash = MD5.HashData(Encoding.UTF8.GetBytes(key));
            var tripleDes = new TripleDESCryptoServiceProvider
            {
                Key = byteHash, 
                Mode = CipherMode.ECB
            };
            
            var byteBuff = Encoding.UTF8.GetBytes(source);
            return Convert.ToBase64String(tripleDes.CreateEncryptor()
                .TransformFinalBlock(byteBuff, 0, byteBuff.Length));
        }

        public static string Decrypt(string encodedText, string key)
        {
            var byteHash = MD5.HashData(Encoding.UTF8.GetBytes(key));
            var tripleDes = new TripleDESCryptoServiceProvider
            {
                Key = byteHash, 
                Mode = CipherMode.ECB
            };
            var byteBuff = Convert.FromBase64String(encodedText);
            return Encoding.UTF8.GetString(
                tripleDes
                    .CreateDecryptor()
                    .TransformFinalBlock(byteBuff, 0, byteBuff.Length));
        }
    }
}

In Practice

I created a simple ASP.NET Core MVC application to demonstrate this action filter in use. Starting with the view, we’ll encrypt some values and post them to our endpoint that uses the EncryptedParameters attribute.

@model IndexModel
@inject Microsoft.Extensions.Options.IOptions<CryptoEngine.Secrets> config
@{
    ViewData["Title"] = "Home Page";
    var values = "name=Khalid&number=57";
    var secret = CryptoEngine.Encrypt(values, config.Value.Key);
    var url = Url.Action("IndexPost", new {secret});
}

<div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>

    <p>Click this link to transmit secret values via Querystring</p>
    <form action="@url" method="POST">
        <p>@url</p>
        <button type="submit">Submit</button>
    </form>
</div>

@if (Model != null)
{
    <section style="margin-top: 1em">
        <div class="text-center">
            <h2>Secrets</h2>
            <div>
                <label asp-for="Name"></label>
                @Model.Name
            </div>
            <div>
                <label asp-for="Number"></label>
                @Model.Number
            </div>
        </div>
    </section>
}

When we submit the form, we receive our secrets on the page.

showing the asp.net core mvc decrypted results of the previous request.

Conclusion

There you have it! We can use this technique to transform any incoming query string into any other set of parameters. This technique is powerful and can be applied to any MVC action or can be applied globally.

As always, use this code as a starting point and change it to meet your particular needs.

If you want to play with a working sample, you can find the code at my GitHub repository.

As always, thanks for reading, and please follow me on Twitter @buhakmeh.