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:
George 2015-09-23 00:58:27 +01:00
parent 45e762abd2
commit 09a8f95a70
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;