Skip to content

Commit

Permalink
IPBanningModule (#420)
Browse files Browse the repository at this point in the history
* IPBanningModule

* Adding AccessAttempts

* Extensions and constructors
Basic test

* IP Parsing

* Min fix IP Parser

* Performance improvements

* FailRegex as static ConcurrentDictionary

* Extension methods for init.
Xmldoc.

* Unit Tests

* More unit test

* Code Style

* Fix extension methods

* IPParserTest

* Code Style
  • Loading branch information
k3z0 authored and geoperez committed Dec 19, 2019
1 parent fc4eb94 commit d368b1d
Show file tree
Hide file tree
Showing 10 changed files with 831 additions and 3 deletions.
5 changes: 2 additions & 3 deletions EmbedIO.sln
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26730.16
# Visual Studio Version 16
VisualStudioVersion = 16.0.29609.76
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{97BC259A-4E78-4BA8-8F4D-2656BC78BB34}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{73F25F81-0412-412E-89C9-BAD33E9BCCDE}"
ProjectSection(SolutionItems) = preProject
.travis.yml = .travis.yml
appveyor.yml = appveyor.yml
LICENSE = LICENSE
README.md = README.md
Expand Down
9 changes: 9 additions & 0 deletions src/EmbedIO.Samples/Program.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using EmbedIO.Actions;
using EmbedIO.Files;
using EmbedIO.Security;
using EmbedIO.WebApi;
using Swan;
using Swan.Logging;
Expand Down Expand Up @@ -60,6 +62,13 @@ private static WebServer CreateWebServer(string url)
var server = new WebServer(o => o
.WithUrlPrefix(url)
.WithMode(HttpListenerMode.EmbedIO))
.WithIPBanning(o => o
.WithWhitelist(
"",
"172.16.16.124",
"172.16.17.1/24",
"192.168.1-2.2-5")
.WithRules("(404 Not Found)+"), 5,5)
.WithLocalSessionManager()
.WithCors(
// Origins, separated by comma without last slash
Expand Down
25 changes: 25 additions & 0 deletions src/EmbedIO/Security/BannedInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Net;

namespace EmbedIO.Security
{
/// <summary>
/// Represents the info af a banned IP address.
/// </summary>
public class BannedInfo
{
/// <summary>
/// Gets or sets the banned IP address.
/// </summary>
public IPAddress IPAddress { get; set; }

/// <summary>
/// Gets or sets until when the IP will remain ban.
/// </summary>
public long BanUntil { get; set; }

/// <summary>
/// Gets or sets a value indicating whether this instance was explicitly banned by user.
/// </summary>
public bool IsExplicit { get; set; }
}
}
301 changes: 301 additions & 0 deletions src/EmbedIO/Security/IPBanningModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
using EmbedIO.Utilities;
using Swan;
using Swan.Logging;
using Swan.Threading;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace EmbedIO.Security
{
/// <summary>
/// A module for ban IPs that show the malicious signs, based on scanning log messages.
/// </summary>
/// <seealso cref="WebModuleBase" />
public class IPBanningModule : WebModuleBase, ILogger
{
/// <summary>
/// The default ban time, in minutes.
/// </summary>
public const int DefaultBanTime = 30;

/// <summary>
/// The default maximum retries per minute.
/// </summary>
public const int DefaultMaxRetry = 10;

private static readonly ConcurrentDictionary<IPAddress, ConcurrentBag<long>> AccessAttempts = new ConcurrentDictionary<IPAddress, ConcurrentBag<long>>();
private static readonly ConcurrentDictionary<IPAddress, BannedInfo> Blacklist = new ConcurrentDictionary<IPAddress, BannedInfo>();
private static readonly ConcurrentDictionary<string, Regex> FailRegex = new ConcurrentDictionary<string, Regex>();
private static readonly PeriodicTask? Purger;

private readonly List<IPAddress> _whitelist = new List<IPAddress>();
private readonly int _banTime;
private readonly int _maxRetry;
private bool _disposedValue;

static IPBanningModule()
{
Purger = new PeriodicTask(TimeSpan.FromMinutes(1), ct =>
{
PurgeBlackList();
PurgeAccessAttempts();
return Task.CompletedTask;
});
}

/// <summary>
/// Initializes a new instance of the <see cref="IPBanningModule"/> class.
/// </summary>
/// <param name="baseRoute">The base route.</param>
/// <param name="failRegex">A collection of regex to match the log messages against.</param>
/// <param name="banTime">The time that an IP will remain ban, in minutes.</param>
/// <param name="maxRetry">The maximum number of failed attempts before banning an IP.</param>
public IPBanningModule(string baseRoute,
IEnumerable<string> failRegex,
int banTime = DefaultBanTime,
int maxRetry = DefaultMaxRetry)
: this(baseRoute, failRegex, null, banTime, maxRetry)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="IPBanningModule"/> class.
/// </summary>
/// <param name="baseRoute">The base route.</param>
/// <param name="failRegex">A collection of regex to match the log messages against.</param>
/// <param name="whitelist">A collection of valid IPs that never will be banned.</param>
/// <param name="banTime">The time that an IP will remain ban, in minutes.</param>
/// <param name="maxRetry">The maximum number of failed attempts before banning an IP.</param>
public IPBanningModule(string baseRoute,
IEnumerable<string>? failRegex = null,
IEnumerable<string>? whitelist = null,
int banTime = DefaultBanTime,
int maxRetry = DefaultMaxRetry)
: base(baseRoute)
{
if (failRegex != null)
AddRules(failRegex);

_banTime = banTime;
_maxRetry = maxRetry;
AddToWhitelist(whitelist);
Logger.RegisterLogger(this);
}

/// <inheritdoc />
public override bool IsFinalHandler => false;

/// <inheritdoc />
public LogLevel LogLevel => LogLevel.Trace;

private IPAddress? ClientAddress { get; set; }

/// <summary>
/// Gets the list of current banned IPs.
/// </summary>
/// <returns>A collection of <see cref="BannedInfo"/> in the blacklist.</returns>
public static IEnumerable<BannedInfo> GetBannedIPs() =>
Blacklist.Values.ToList();

/// <summary>
/// Tries to ban an IP explicitly.
/// </summary>
/// <param name="address">The IP address to ban.</param>
/// <param name="minutes">The time in minutes that the IP will remain ban.</param>
/// <param name="isExplicit">if set to <c>true</c> [is explicit].</param>
/// <returns>
/// <c>true</c> if the IP was added to the blacklist; otherwise, <c>false</c>.
/// </returns>
public static bool TryBanIP(IPAddress address, int minutes, bool isExplicit = true) =>
TryBanIP(address, DateTime.Now.AddMinutes(minutes), isExplicit);

/// <summary>
/// Tries to ban an IP explicitly.
/// </summary>
/// <param name="address">The IP address to ban.</param>
/// <param name="banTime">An <see cref="TimeSpan"/> that sets the time the IP will remain ban.</param>
/// <param name="isExplicit">if set to <c>true</c> [is explicit].</param>
/// <returns>
/// <c>true</c> if the IP was added to the blacklist; otherwise, <c>false</c>.
/// </returns>
public static bool TryBanIP(IPAddress address, TimeSpan banTime, bool isExplicit = true) =>
TryBanIP(address, DateTime.Now.Add(banTime), isExplicit);

/// <summary>
/// Tries to ban an IP explicitly.
/// </summary>
/// <param name="address">The IP address to ban.</param>
/// <param name="banUntil">A <see cref="DateTime"/> that sets until when the IP will remain ban.</param>
/// <param name="isExplicit">if set to <c>true</c> [is explicit].</param>
/// <returns>
/// <c>true</c> if the IP was added to the blacklist; otherwise, <c>false</c>.
/// </returns>
public static bool TryBanIP(IPAddress address, DateTime banUntil, bool isExplicit = true)
{
if (Blacklist.ContainsKey(address))
{
var bannedInfo = Blacklist[address];
bannedInfo.BanUntil = banUntil.Ticks;
bannedInfo.IsExplicit = isExplicit;

return true;
}

return Blacklist.TryAdd(address, new BannedInfo()
{
IPAddress = address,
BanUntil = banUntil.Ticks,
IsExplicit = isExplicit,
});
}

/// <summary>
/// Tries to unban an IP explicitly.
/// </summary>
/// <param name="address">The IP address.</param>
/// <returns>
/// <c>true</c> if the IP was removed from the blacklist; otherwise, <c>false</c>.
/// </returns>
public static bool TryUnbanIP(IPAddress address) =>
Blacklist.TryRemove(address, out _);

/// <inheritdoc />
public void Log(LogMessageReceivedEventArgs logEvent)
{
// Process Log
if (string.IsNullOrWhiteSpace(logEvent.Message) ||
ClientAddress == null ||
!FailRegex.Any() ||
_whitelist.Contains(ClientAddress) ||
Blacklist.ContainsKey(ClientAddress))
return;

foreach (var regex in FailRegex.Values)
{
try
{
if (!regex.IsMatch(logEvent.Message)) continue;

// Add to list
AddAccessAttempt(ClientAddress);
UpdateBlackList();
break;
}
catch (RegexMatchTimeoutException ex)
{
$"Timeout trying to match '{ex.Input}' with pattern '{ex.Pattern}'.".Error(nameof(IPBanningModule));
}
}
}

/// <inheritdoc />
public void Dispose() =>
Dispose(true);

internal void AddRules(IEnumerable<string> patterns)
{
foreach (var pattern in patterns)
AddRule(pattern);
}

internal void AddRule(string pattern)
{
try
{
FailRegex.TryAdd(pattern, new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(500)));
}
catch (Exception ex)
{
ex.Log(nameof(IPBanningModule), $"Invalid regex - '{pattern}'.");
}
}

internal void AddToWhitelist(IEnumerable<string> whitelist) =>
AddToWhitelistAsync(whitelist).GetAwaiter().GetResult();

internal async Task AddToWhitelistAsync(IEnumerable<string> whitelist)
{
if (whitelist?.Any() != true)
return;

foreach (var address in whitelist)
{
var addressees = await IPParser.Parse(address).ConfigureAwait(false);
_whitelist.AddRange(addressees.Where(x => !_whitelist.Contains(x)));
}
}

/// <inheritdoc />
protected override Task OnRequestAsync(IHttpContext context)
{
ClientAddress = context.Request.RemoteEndPoint.Address;
if (!Blacklist.ContainsKey(ClientAddress))
return Task.CompletedTask;

context.SetHandled();
throw HttpException.Forbidden();
}

/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposedValue) return;
if (disposing)
{
_whitelist.Clear();
}

_disposedValue = true;
}

private static void AddAccessAttempt(IPAddress address)
{
if (AccessAttempts.ContainsKey(address))
AccessAttempts[address].Add(DateTime.Now.Ticks);
else
AccessAttempts.TryAdd(address, new ConcurrentBag<long>() { DateTime.Now.Ticks });
}

private static void PurgeBlackList()
{
foreach (var k in Blacklist.Keys)
{
if (DateTime.Now.Ticks > Blacklist[k].BanUntil)
Blacklist.TryRemove(k, out _);
}
}

private static void PurgeAccessAttempts()
{
var banDate = DateTime.Now.AddMinutes(-1).Ticks;

foreach (var k in AccessAttempts.Keys)
{
var recentAttempts = new ConcurrentBag<long>(AccessAttempts[k].Where(x => x >= banDate));
if (!recentAttempts.Any())
AccessAttempts.TryRemove(k, out _);
else
Interlocked.Exchange(ref recentAttempts, AccessAttempts[k]);
}
}

private void UpdateBlackList()
{
var time = DateTime.Now.AddMinutes(-1).Ticks;
if ((AccessAttempts[ClientAddress]?.Where(x => x >= time).Count() >= _maxRetry))
{
TryBanIP(ClientAddress, _banTime, false);
}
}
}
}
Loading

0 comments on commit d368b1d

Please sign in to comment.