From 09a8f95a70d168ca8c163dd9127489cd56a18e5c Mon Sep 17 00:00:00 2001 From: George Date: Wed, 23 Sep 2015 00:58:27 +0100 Subject: [PATCH] Added rate limiting to RESTful API Added token bucket and timer to degrade token bucket Modified REST API rate limiting Changed limiting to only be on token create and v2 token create Added config options to choose time limits Passed HttpContext to the execute method of endpoints Made blocking failed API logins optional Changed error codes on failed login to be ambiguous Added config to decide whether all or failed logins are limited Changed config variable names Cleaned up duplicate code in REST rate limiting Fixed my typo Changed error 431 to 403 --- CHANGELOG.md | 2 + TShockAPI/ConfigFile.cs | 9 +++ TShockAPI/Rest/Rest.cs | 54 +++++++++++++----- TShockAPI/Rest/RestCommand.cs | 10 ++-- TShockAPI/Rest/RestManager.cs | 8 +-- TShockAPI/Rest/SecureRest.cs | 102 ++++++++++++++++++++++++---------- 6 files changed, 135 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d86d5bc0..9683cd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Other notable changes include: * Fixed /invade martian (@Wolfje) * Fixed target dummies not working properly (@WhiteXZ) * Added a config option (DisableSecondUpdateLogs) to prevent log spam from OnSecondUpdate() (@WhiteXZ) +* Added RESTful API login rate limiting (@George) +* Added config options (MaximumRequestsPerInterval, RequestBucketDecreaseIntervalMinutes, LimitOnlyFailedLoginRequests) for rate limiting (@George) * **DEPRECATION**: Deprecated Disable(string, bool) and added Disable(string, DisableFlags). Please update your plugins accordingly (@WhiteXZ) diff --git a/TShockAPI/ConfigFile.cs b/TShockAPI/ConfigFile.cs index fc2c697a..e7136286 100755 --- a/TShockAPI/ConfigFile.cs +++ b/TShockAPI/ConfigFile.cs @@ -436,6 +436,15 @@ namespace TShockAPI [Description("The minimum password length for new user accounts. Minimum value is 4.")] public int MinimumPasswordLength = 4; + [Description("The maximum REST requests in the bucket before denying requests. Minimum value is 5.")] + public int RESTMaximumRequestsPerInterval = 5; + + [Description("How often in minutes the REST requests bucket is decreased by one. Minimum value is 1 minute.")] + public int RESTRequestBucketDecreaseIntervalMinutes = 1; + + [Description("Whether we should limit only the max failed login requests, or all login requests")] + public bool RESTLimitOnlyFailedLoginRequests = true; + [Obsolete("This is being removed in future versions of TShock due to Terraria fixes.")] [Description("Enable the DCU. Very dangerous; can destroy world without consequence.")] public bool VeryDangerousDoNotChangeEnableDrillContainmentUnit = true; diff --git a/TShockAPI/Rest/Rest.cs b/TShockAPI/Rest/Rest.cs index 0504c657..567cbc04 100644 --- a/TShockAPI/Rest/Rest.cs +++ b/TShockAPI/Rest/Rest.cs @@ -22,6 +22,7 @@ using System.ComponentModel; using System.Net; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Reflection; using HttpServer; using HttpServer.Headers; @@ -44,21 +45,24 @@ namespace Rests public IParameterCollection Parameters { get; private set; } public IRequest Request { get; private set; } public SecureRest.TokenData TokenData { get; private set; } + public IHttpContext Context { get; private set; } - public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request) + public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request, IHttpContext context) { Verbs = verbs; Parameters = param; Request = request; TokenData = SecureRest.TokenData.None; + Context = context; } - public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request, SecureRest.TokenData tokenData) + public RestRequestArgs(RestVerbs verbs, IParameterCollection param, IRequest request, SecureRest.TokenData tokenData, IHttpContext context) { Verbs = verbs; Parameters = param; Request = request; TokenData = tokenData; + Context = context; } } public class Rest : IDisposable @@ -66,6 +70,8 @@ namespace Rests private readonly List commands = new List(); private HttpListener listener; private StringHeader serverHeader; + public Dictionary tokenBucket = new Dictionary(); + private Timer tokenBucketTimer; public IPAddress Ip { get; set; } public int Port { get; set; } @@ -84,6 +90,11 @@ namespace Rests listener = HttpListener.Create(Ip, Port); listener.RequestReceived += OnRequest; listener.Start(int.MaxValue); + tokenBucketTimer = new Timer((e) => + { + DegradeBucket(); + }, null, TimeSpan.Zero, TimeSpan.FromMinutes(Math.Max(TShock.Config.RESTRequestBucketDecreaseIntervalMinutes, 1))); + } catch (Exception ex) { @@ -122,6 +133,23 @@ namespace Rests commands.Add(com); } + private void DegradeBucket() + { + var _bucket = new List(tokenBucket.Keys); // Duplicate the keys so we can modify tokenBucket whilst iterating + foreach(string key in _bucket) + { + int tokens = tokenBucket[key]; + if(tokens > 0) + { + tokenBucket[key] -= 1; + } + if(tokens <= 0) + { + tokenBucket.Remove(key); + } + } + } + #region Event public class RestRequestEventArgs : HandledEventArgs { @@ -194,7 +222,7 @@ namespace Rests continue; } - var obj = ExecuteCommand(com, verbs, e.Request.Parameters, e.Request); + var obj = ExecuteCommand(com, verbs, e.Request.Parameters, e.Request, e.Context); if (obj != null) return obj; } @@ -202,21 +230,21 @@ namespace Rests catch (Exception exception) { return new RestObject("500") - { - {"error", "Internal server error."}, - {"errormsg", exception.Message}, - {"stacktrace", exception.StackTrace}, - }; + { + {"error", "Internal server error."}, + {"errormsg", exception.Message}, + {"stacktrace", exception.StackTrace}, + }; } return new RestObject("404") - { - {"error", "Specified API endpoint doesn't exist. Refer to the documentation for a list of valid endpoints."} - }; + { + {"error", "Specified API endpoint doesn't exist. Refer to the documentation for a list of valid endpoints."} + }; } - protected virtual object ExecuteCommand(RestCommand cmd, RestVerbs verbs, IParameterCollection parms, IRequest request) + protected virtual object ExecuteCommand(RestCommand cmd, RestVerbs verbs, IParameterCollection parms, IRequest request, IHttpContext context) { - object result = cmd.Execute(verbs, parms, request); + object result = cmd.Execute(verbs, parms, request, context); if (cmd.DoLog && TShock.Config.LogRest) { TShock.Log.ConsoleInfo("Anonymous requested REST endpoint: " + BuildRequestUri(cmd, verbs, parms, false)); diff --git a/TShockAPI/Rest/RestCommand.cs b/TShockAPI/Rest/RestCommand.cs index 681d7cbf..7debe22f 100644 --- a/TShockAPI/Rest/RestCommand.cs +++ b/TShockAPI/Rest/RestCommand.cs @@ -65,9 +65,9 @@ namespace Rests get { return UriVerbs.Length > 0; } } - public virtual object Execute(RestVerbs verbs, IParameterCollection parameters, IRequest request) + public virtual object Execute(RestVerbs verbs, IParameterCollection parameters, IRequest request, IHttpContext context) { - return callback(new RestRequestArgs(verbs, parameters, request)); + return callback(new RestRequestArgs(verbs, parameters, request, context)); } } @@ -90,17 +90,17 @@ namespace Rests { } - public override object Execute(RestVerbs verbs, IParameterCollection parameters, IRequest request) + public override object Execute(RestVerbs verbs, IParameterCollection parameters, IRequest request, IHttpContext context) { return new RestObject("401") { Error = "Not authorized. The specified API endpoint requires a token." }; } - public object Execute(RestVerbs verbs, IParameterCollection parameters, SecureRest.TokenData tokenData, IRequest request) + public object Execute(RestVerbs verbs, IParameterCollection parameters, SecureRest.TokenData tokenData, IRequest request, IHttpContext context) { if (tokenData.Equals(SecureRest.TokenData.None)) return new RestObject("401") { Error = "Not authorized. The specified API endpoint requires a token." }; - return callback(new RestRequestArgs(verbs, parameters, request, tokenData)); + return callback(new RestRequestArgs(verbs, parameters, request, tokenData, context)); } } } \ No newline at end of file diff --git a/TShockAPI/Rest/RestManager.cs b/TShockAPI/Rest/RestManager.cs index 7c3056e4..445da0c8 100644 --- a/TShockAPI/Rest/RestManager.cs +++ b/TShockAPI/Rest/RestManager.cs @@ -109,10 +109,10 @@ namespace TShockAPI } else { - Rest.Register(new RestCommand("/v2/server/status", (a) => this.ServerStatusV2(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None)))); - Rest.Register(new RestCommand("/status", (a) => this.ServerStatus(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None)))); - Rest.Register(new RestCommand("/v3/server/motd", (a) => this.ServerMotd(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None)))); - Rest.Register(new RestCommand("/v3/server/rules", (a) => this.ServerRules(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None)))); + Rest.Register(new RestCommand("/v2/server/status", (a) => this.ServerStatusV2(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None, a.Context)))); + Rest.Register(new RestCommand("/status", (a) => this.ServerStatus(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None, a.Context)))); + Rest.Register(new RestCommand("/v3/server/motd", (a) => this.ServerMotd(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None, a.Context)))); + Rest.Register(new RestCommand("/v3/server/rules", (a) => this.ServerRules(new RestRequestArgs(a.Verbs, a.Parameters, a.Request, SecureRest.TokenData.None, a.Context)))); } Rest.Register(new SecureRestCommand("/v2/server/broadcast", ServerBroadcast)); diff --git a/TShockAPI/Rest/SecureRest.cs b/TShockAPI/Rest/SecureRest.cs index ee33e5a6..082a1f7b 100644 --- a/TShockAPI/Rest/SecureRest.cs +++ b/TShockAPI/Rest/SecureRest.cs @@ -36,14 +36,14 @@ namespace Rests public string UserGroupName { get; set; } } - public Dictionary Tokens { get; protected set; } - public Dictionary AppTokens { get; protected set; } + public Dictionary Tokens { get; protected set; } + public Dictionary AppTokens { get; protected set; } public SecureRest(IPAddress ip, int port) : base(ip, port) { Tokens = new Dictionary(); - AppTokens = new Dictionary(); + AppTokens = new Dictionary(); Register(new RestCommand("/token/create/{username}/{password}", NewToken) { DoLog = false }); Register(new RestCommand("/v2/token/create/{password}", NewTokenV2) { DoLog = false }); @@ -55,10 +55,10 @@ namespace Rests AppTokens.Add(t.Key, t.Value); } - foreach (KeyValuePair t in TShock.Config.ApplicationRestTokens) - { - AppTokens.Add(t.Key, t.Value); - } + foreach (KeyValuePair t in TShock.Config.ApplicationRestTokens) + { + AppTokens.Add(t.Key, t.Value); + } // TODO: Get rid of this when the old REST permission model is removed. if (TShock.Config.RestApiEnabled && !TShock.Config.RestUseNewPermissionModel) @@ -84,6 +84,18 @@ namespace Rests } } + private void AddTokenToBucket(string ip) + { + if (tokenBucket.ContainsKey(ip)) + { + tokenBucket[ip] += 1; + } + else + { + tokenBucket.Add(ip, 1); + } + } + private object DestroyToken(RestRequestArgs args) { var token = args.Verbs["token"]; @@ -94,10 +106,10 @@ namespace Rests catch (Exception) { return new RestObject("400") - { Error = "The specified token queued for destruction failed to be deleted." }; + { Error = "The specified token queued for destruction failed to be deleted." }; } return new RestObject() - { Response = "Requested token was successfully destroyed." }; + { Response = "Requested token was successfully destroyed." }; } private object DestroyAllTokens(RestRequestArgs args) @@ -105,42 +117,73 @@ namespace Rests Tokens.Clear(); return new RestObject() - { Response = "All tokens were successfully destroyed." }; + { Response = "All tokens were successfully destroyed." }; } private object NewTokenV2(RestRequestArgs args) { var user = args.Parameters["username"]; var pass = args.Verbs["password"]; + var context = args.Context; - return this.NewTokenInternal(user, pass); + return this.NewTokenInternal(user, pass, context); } private object NewToken(RestRequestArgs args) { var user = args.Verbs["username"]; var pass = args.Verbs["password"]; + var context = args.Context; - RestObject response = this.NewTokenInternal(user, pass); + RestObject response = this.NewTokenInternal(user, pass, context); response["deprecated"] = "This endpoint is depracted and will be removed in the future."; return response; } - private RestObject NewTokenInternal(string username, string password) + private RestObject NewTokenInternal(string username, string password, IHttpContext context) { + int tokens = 0; + if (tokenBucket.TryGetValue(context.RemoteEndPoint.Address.ToString(), out tokens)) + { + if (tokens >= Math.Max(TShock.Config.RESTMaximumRequestsPerInterval, 5)) + { + TShock.Log.ConsoleError("A REST login from {0} was blocked as it currently has {1} tokens", context.RemoteEndPoint.Address.ToString(), tokens); + tokenBucket[context.RemoteEndPoint.Address.ToString()] += 1; // Tokens over limit, increment by one and reject request + return new RestObject("403") + { + Error = "Username or password may be incorrect or this account may not have sufficient privileges." + }; + } + if (!TShock.Config.RESTLimitOnlyFailedLoginRequests) + tokenBucket[context.RemoteEndPoint.Address.ToString()] += 1; // Tokens under limit, increment by one and process request + } + else + { + if (!TShock.Config.RESTLimitOnlyFailedLoginRequests) + tokenBucket.Add(context.RemoteEndPoint.Address.ToString(), 1); // First time request, set to one and process request + } + User userAccount = TShock.Users.GetUserByName(username); - if (userAccount == null) - return new RestObject("401") { Error = "Invalid username/password combination provided. Please re-submit your query with a correct pair." }; - + if (userAccount == null) + { + AddTokenToBucket(context.RemoteEndPoint.Address.ToString()); + return new RestObject("403") { Error = "Username or password may be incorrect or this account may not have sufficient privileges." }; + } + if (!userAccount.VerifyPassword(password)) - return new RestObject("401") - { Error = "Invalid username/password combination provided. Please re-submit your query with a correct pair." }; + { + AddTokenToBucket(context.RemoteEndPoint.Address.ToString()); + return new RestObject("403") { Error = "Username or password may be incorrect or this account may not have sufficient privileges." }; + } Group userGroup = TShock.Utils.GetGroup(userAccount.Group); if (!userGroup.HasPermission(RestPermissions.restapi) && userAccount.Group != "superadmin") + { + AddTokenToBucket(context.RemoteEndPoint.Address.ToString()); return new RestObject("403") - { Error = "Although your account was successfully found and identified, your account lacks the permission required to use the API. (restapi)" }; - + { Error = "Username or password may be incorrect or this account may not have sufficient privileges." }; + } + string tokenHash; var rand = new Random(); var randbytes = new byte[32]; @@ -152,29 +195,32 @@ namespace Rests Tokens.Add(tokenHash, new TokenData { Username = userAccount.Name, UserGroupName = userGroup.Name }); + AddTokenToBucket(context.RemoteEndPoint.Address.ToString()); + RestObject response = new RestObject() { Response = "Successful login" }; response["token"] = tokenHash; return response; } - protected override object ExecuteCommand(RestCommand cmd, RestVerbs verbs, IParameterCollection parms, IRequest request) + protected override object ExecuteCommand(RestCommand cmd, RestVerbs verbs, IParameterCollection parms, IRequest request, IHttpContext context) { if (!cmd.RequiresToken) - return base.ExecuteCommand(cmd, verbs, parms, request); - + return base.ExecuteCommand(cmd, verbs, parms, request, context); + var token = parms["token"]; if (token == null) return new RestObject("401") - { Error = "Not authorized. The specified API endpoint requires a token." }; + { Error = "Not authorized. The specified API endpoint requires a token." }; SecureRestCommand secureCmd = (SecureRestCommand)cmd; TokenData tokenData; - if (!Tokens.TryGetValue(token, out tokenData) && !AppTokens.TryGetValue(token, out tokenData)) + if (!Tokens.TryGetValue(token, out tokenData) && !AppTokens.TryGetValue(token, out tokenData)) return new RestObject("403") { Error = "Not authorized. The specified API endpoint requires a token, but the provided token was not valid." }; // TODO: Get rid of this when the old REST permission model is removed. - if (TShock.Config.RestUseNewPermissionModel) { + if (TShock.Config.RestUseNewPermissionModel) + { Group userGroup = TShock.Groups.GetGroupByName(tokenData.UserGroupName); if (userGroup == null) { @@ -191,10 +237,10 @@ namespace Rests } } - object result = secureCmd.Execute(verbs, parms, tokenData, request); + object result = secureCmd.Execute(verbs, parms, tokenData, request, context); if (cmd.DoLog && TShock.Config.LogRest) TShock.Utils.SendLogs(string.Format( - "\"{0}\" requested REST endpoint: {1}", tokenData.Username, this.BuildRequestUri(cmd, verbs, parms, false)), + "\"{0}\" requested REST endpoint: {1}", tokenData.Username, this.BuildRequestUri(cmd, verbs, parms, false)), Color.PaleVioletRed); return result;