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;