Merge pull request #1120 from Celant/general-devel

Added rate limiting to RESTful API
This commit is contained in:
Lucas Nicodemus 2015-09-24 11:29:47 -06:00
commit 44575db64e
6 changed files with 135 additions and 50 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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));

View file

@ -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));
}
}
}

View file

@ -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));

View file

@ -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;