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 /invade martian (@Wolfje)
* Fixed target dummies not working properly (@WhiteXZ) * Fixed target dummies not working properly (@WhiteXZ)
* Added a config option (DisableSecondUpdateLogs) to prevent log spam from OnSecondUpdate() (@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) * **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.")] [Description("The minimum password length for new user accounts. Minimum value is 4.")]
public int MinimumPasswordLength = 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.")] [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 [Description("Enable the DCU. Very dangerous; can destroy world without consequence.")] public bool
VeryDangerousDoNotChangeEnableDrillContainmentUnit = true; VeryDangerousDoNotChangeEnableDrillContainmentUnit = true;

View file

@ -22,6 +22,7 @@ using System.ComponentModel;
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Reflection; using System.Reflection;
using HttpServer; using HttpServer;
using HttpServer.Headers; using HttpServer.Headers;
@ -44,21 +45,24 @@ namespace Rests
public IParameterCollection Parameters { get; private set; } public IParameterCollection Parameters { get; private set; }
public IRequest Request { get; private set; } public IRequest Request { get; private set; }
public SecureRest.TokenData TokenData { 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; Verbs = verbs;
Parameters = param; Parameters = param;
Request = request; Request = request;
TokenData = SecureRest.TokenData.None; 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; Verbs = verbs;
Parameters = param; Parameters = param;
Request = request; Request = request;
TokenData = tokenData; TokenData = tokenData;
Context = context;
} }
} }
public class Rest : IDisposable public class Rest : IDisposable
@ -66,6 +70,8 @@ namespace Rests
private readonly List<RestCommand> commands = new List<RestCommand>(); private readonly List<RestCommand> commands = new List<RestCommand>();
private HttpListener listener; private HttpListener listener;
private StringHeader serverHeader; private StringHeader serverHeader;
public Dictionary<string, int> tokenBucket = new Dictionary<string, int>();
private Timer tokenBucketTimer;
public IPAddress Ip { get; set; } public IPAddress Ip { get; set; }
public int Port { get; set; } public int Port { get; set; }
@ -84,6 +90,11 @@ namespace Rests
listener = HttpListener.Create(Ip, Port); listener = HttpListener.Create(Ip, Port);
listener.RequestReceived += OnRequest; listener.RequestReceived += OnRequest;
listener.Start(int.MaxValue); listener.Start(int.MaxValue);
tokenBucketTimer = new Timer((e) =>
{
DegradeBucket();
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(Math.Max(TShock.Config.RESTRequestBucketDecreaseIntervalMinutes, 1)));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -122,6 +133,23 @@ namespace Rests
commands.Add(com); 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 #region Event
public class RestRequestEventArgs : HandledEventArgs public class RestRequestEventArgs : HandledEventArgs
{ {
@ -194,7 +222,7 @@ namespace Rests
continue; 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) if (obj != null)
return obj; return obj;
} }
@ -202,21 +230,21 @@ namespace Rests
catch (Exception exception) catch (Exception exception)
{ {
return new RestObject("500") return new RestObject("500")
{ {
{"error", "Internal server error."}, {"error", "Internal server error."},
{"errormsg", exception.Message}, {"errormsg", exception.Message},
{"stacktrace", exception.StackTrace}, {"stacktrace", exception.StackTrace},
}; };
} }
return new RestObject("404") 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) if (cmd.DoLog && TShock.Config.LogRest)
{ {
TShock.Log.ConsoleInfo("Anonymous requested REST endpoint: " + BuildRequestUri(cmd, verbs, parms, false)); 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; } 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." }; 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)) if (tokenData.Equals(SecureRest.TokenData.None))
return new RestObject("401") { Error = "Not authorized. The specified API endpoint requires a token." }; 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 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("/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)))); 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)))); 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)))); 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)); Rest.Register(new SecureRestCommand("/v2/server/broadcast", ServerBroadcast));

View file

@ -36,14 +36,14 @@ namespace Rests
public string UserGroupName { get; set; } public string UserGroupName { get; set; }
} }
public Dictionary<string,TokenData> Tokens { get; protected set; } public Dictionary<string, TokenData> Tokens { get; protected set; }
public Dictionary<string, TokenData> AppTokens { get; protected set; } public Dictionary<string, TokenData> AppTokens { get; protected set; }
public SecureRest(IPAddress ip, int port) public SecureRest(IPAddress ip, int port)
: base(ip, port) : base(ip, port)
{ {
Tokens = new Dictionary<string, TokenData>(); 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("/token/create/{username}/{password}", NewToken) { DoLog = false });
Register(new RestCommand("/v2/token/create/{password}", NewTokenV2) { DoLog = false }); Register(new RestCommand("/v2/token/create/{password}", NewTokenV2) { DoLog = false });
@ -55,10 +55,10 @@ namespace Rests
AppTokens.Add(t.Key, t.Value); AppTokens.Add(t.Key, t.Value);
} }
foreach (KeyValuePair<string, TokenData> t in TShock.Config.ApplicationRestTokens) foreach (KeyValuePair<string, TokenData> t in TShock.Config.ApplicationRestTokens)
{ {
AppTokens.Add(t.Key, t.Value); AppTokens.Add(t.Key, t.Value);
} }
// TODO: Get rid of this when the old REST permission model is removed. // TODO: Get rid of this when the old REST permission model is removed.
if (TShock.Config.RestApiEnabled && !TShock.Config.RestUseNewPermissionModel) 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) private object DestroyToken(RestRequestArgs args)
{ {
var token = args.Verbs["token"]; var token = args.Verbs["token"];
@ -94,10 +106,10 @@ namespace Rests
catch (Exception) catch (Exception)
{ {
return new RestObject("400") 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() return new RestObject()
{ Response = "Requested token was successfully destroyed." }; { Response = "Requested token was successfully destroyed." };
} }
private object DestroyAllTokens(RestRequestArgs args) private object DestroyAllTokens(RestRequestArgs args)
@ -105,42 +117,73 @@ namespace Rests
Tokens.Clear(); Tokens.Clear();
return new RestObject() return new RestObject()
{ Response = "All tokens were successfully destroyed." }; { Response = "All tokens were successfully destroyed." };
} }
private object NewTokenV2(RestRequestArgs args) private object NewTokenV2(RestRequestArgs args)
{ {
var user = args.Parameters["username"]; var user = args.Parameters["username"];
var pass = args.Verbs["password"]; 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) private object NewToken(RestRequestArgs args)
{ {
var user = args.Verbs["username"]; var user = args.Verbs["username"];
var pass = args.Verbs["password"]; 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."; response["deprecated"] = "This endpoint is depracted and will be removed in the future.";
return response; 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); User userAccount = TShock.Users.GetUserByName(username);
if (userAccount == null) if (userAccount == null)
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." };
}
if (!userAccount.VerifyPassword(password)) 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); Group userGroup = TShock.Utils.GetGroup(userAccount.Group);
if (!userGroup.HasPermission(RestPermissions.restapi) && userAccount.Group != "superadmin") if (!userGroup.HasPermission(RestPermissions.restapi) && userAccount.Group != "superadmin")
{
AddTokenToBucket(context.RemoteEndPoint.Address.ToString());
return new RestObject("403") 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; string tokenHash;
var rand = new Random(); var rand = new Random();
var randbytes = new byte[32]; var randbytes = new byte[32];
@ -152,29 +195,32 @@ namespace Rests
Tokens.Add(tokenHash, new TokenData { Username = userAccount.Name, UserGroupName = userGroup.Name }); Tokens.Add(tokenHash, new TokenData { Username = userAccount.Name, UserGroupName = userGroup.Name });
AddTokenToBucket(context.RemoteEndPoint.Address.ToString());
RestObject response = new RestObject() { Response = "Successful login" }; RestObject response = new RestObject() { Response = "Successful login" };
response["token"] = tokenHash; response["token"] = tokenHash;
return response; 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) if (!cmd.RequiresToken)
return base.ExecuteCommand(cmd, verbs, parms, request); return base.ExecuteCommand(cmd, verbs, parms, request, context);
var token = parms["token"]; var token = parms["token"];
if (token == null) if (token == null)
return new RestObject("401") 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; SecureRestCommand secureCmd = (SecureRestCommand)cmd;
TokenData tokenData; 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") return new RestObject("403")
{ Error = "Not authorized. The specified API endpoint requires a token, but the provided token was not valid." }; { 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. // 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); Group userGroup = TShock.Groups.GetGroupByName(tokenData.UserGroupName);
if (userGroup == null) 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) if (cmd.DoLog && TShock.Config.LogRest)
TShock.Utils.SendLogs(string.Format( 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); Color.PaleVioletRed);
return result; return result;