Merge pull request #1120 from Celant/general-devel
Added rate limiting to RESTful API
This commit is contained in:
commit
44575db64e
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