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
This commit is contained in:
parent
45e762abd2
commit
09a8f95a70
6 changed files with 135 additions and 50 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<RestCommand> commands = new List<RestCommand>();
|
||||
private HttpListener listener;
|
||||
private StringHeader serverHeader;
|
||||
public Dictionary<string, int> tokenBucket = new Dictionary<string, int>();
|
||||
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<string>(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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ namespace Rests
|
|||
public string UserGroupName { get; set; }
|
||||
}
|
||||
|
||||
public Dictionary<string,TokenData> Tokens { get; protected set; }
|
||||
public Dictionary<string, TokenData> AppTokens { get; protected set; }
|
||||
public Dictionary<string, TokenData> Tokens { get; protected set; }
|
||||
public Dictionary<string, TokenData> AppTokens { get; protected set; }
|
||||
|
||||
public SecureRest(IPAddress ip, int port)
|
||||
: base(ip, port)
|
||||
{
|
||||
Tokens = new Dictionary<string, TokenData>();
|
||||
AppTokens = new Dictionary<string, TokenData>();
|
||||
AppTokens = new Dictionary<string, TokenData>();
|
||||
|
||||
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<string, TokenData> t in TShock.Config.ApplicationRestTokens)
|
||||
{
|
||||
AppTokens.Add(t.Key, t.Value);
|
||||
}
|
||||
foreach (KeyValuePair<string, TokenData> 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue