Skip to main content

Decorates an ASP.NET MVC route that needs to have client requests limited over time. Uses the current System.Web.Caching.Cache to store each client request to the decorated route.

using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Caching;
using System.Net;
using System.Web.Routing;
using System.Globalization;

//
// Usage:
// [AllowXRequestsEveryXSeconds(Name = "CtReporting", ContentName = "TooManyRequests", Requests = 15, Seconds = 60)]
//

namespace SecurityEssentials.Core.Attributes
{
    /// <summary>
    /// SECURE: Decorates any MVC route that needs to have client requests limited over time.
    /// </summary>
    /// <remarks>
    /// Uses the current System.Web.Caching.Cache to store each client request to the decorated route.
    /// </remarks>
    [AttributeUsage(AttributeTargets.Method)]
    public class AllowXRequestsEveryXSecondsAttribute : ActionFilterAttribute
    {
        /// <summary>
        /// A unique name for this Throttle.
        /// </summary>
        /// <remarks>
        /// We'll be inserting a Cache record based on this name and client IP, e.g. "Name-192.168.0.1"
        /// </remarks>
        public string Name { get; set; }

        /// <summary>
        /// The number of seconds clients must wait before executing this decorated route again.
        /// </summary>
        public int Seconds { get; set; }

        /// <summary>
        /// The number of requests to allow per client in the given number of seconds
        /// </summary>
        public int Requests { get; set; }

        /// <summary>
        /// A text message (not themed) that will be sent to the client upon throttling.  You can include the token {n} to
        /// show this.Seconds in the message, e.g. "You have performed this action more than {x} times in the last {n} seconds.".
        /// </summary>
        public string Message { get; set; }

        /// <summary>
        /// The content name (themed and from SiteContent) to show upon throttling.  If this is present, the Message parameter will not be used.
        /// </summary>
        public string ContentName { get; set; }

        // Used to get around weird cache behavior with value types
        public class Int32Value
        {
            public Int32Value()
            {
                Value = 1;
            }
            public int Value { get; set; }
        }

        public override void OnActionExecuting(ActionExecutingContext c)
        {
            if (c == null) { throw new ArgumentException("ActionExecutingContext not spcecified"); }
            var key = string.Concat("AllowXRequestsEveryXSeconds-", Name, "-", c.HttpContext.Request.UserHostAddress);
            var allowExecute = false;

            var currentCacheValue = HttpRuntime.Cache[key];
            if (currentCacheValue == null)
            {
                HttpRuntime.Cache.Add(key,
                                      new Int32Value(),
                                      null, // no dependencies
                                      DateTime.Now.AddSeconds(Seconds), // absolute expiration
                                      Cache.NoSlidingExpiration,
                                      CacheItemPriority.Low,
                                      null); // no callback

                allowExecute = true;
            }
            else
            {
                var value = (Int32Value)currentCacheValue;
                value.Value++;
                if (value.Value <= Requests)
                {
                    allowExecute = true;
                }
            }

            if (!allowExecute)
            {
                if (String.IsNullOrEmpty(Message))
                {
                    Message = "You have performed this action more than {x} times in the last {n} seconds.";
                }

                if (!string.IsNullOrEmpty(ContentName))
                {
                    //use SiteContent
                    c.Result = new RedirectToRouteResult(new RouteValueDictionary { { "Controller", "WebPageContent" }, { "Action", ContentName } });
                }
                else
                {
                    //just send a message (not themed)
                    c.Result = new ContentResult { Content = Message.Replace("{x}", Requests.ToString(CultureInfo.CurrentCulture)).Replace("{n}", Seconds.ToString(CultureInfo.CurrentCulture)) };
                }

                // see 409 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
                c.HttpContext.Response.TrySkipIisCustomErrors = true; //to prevent iis from showing default 409 page
                c.HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict;
            }
        }
    }
}