diff --git a/README.md b/README.md index 1faef8c..6daf859 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ All values type are `string`. | LOG_LEVEL | `INFO` | `INFO`, `WARNING`, `ERROR` | Logger level. | `DEBUG` | | LOG_FORMAT | `TEXT` | `TEXT`, `JSON` | Output format of the logger. | `JSON` | | LDAP_HTTPS_SUPPORT | `disabled`| `enabled`, `disabled` | Enabled or disabled HTTPS support with self signed certificate. | | +| BRUTE_FORCE_PROTECTION | `disabled`| `enabled`, `disabled` | Enabled or disabled Brute force protection per IP. | | +| BRUTE_FORCE_EXPIRATION | `10`| | Brute force expiration time in seconds per IP. | | +| BRUTE_FORCE_FAILURES | `3`| | Number of failures before the IP is blocked. | | ### HTTP request headers The variables send via HTTP headers take precedence over environment variables. @@ -162,6 +165,10 @@ spec: servicePort: 80 ``` +## Brute Force protection +Brute force protection is blocking user IP, please read this article to know the limitations about blocking IPs +- https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks + ## Known limitations - Parameters via headers need to be escaped, for example, you can not send parameters such as `$1` or `$test` because Nginx is applying variable expansion. diff --git a/files/bruteforce.py b/files/bruteforce.py new file mode 100644 index 0000000..0fc85f6 --- /dev/null +++ b/files/bruteforce.py @@ -0,0 +1,68 @@ +from datetime import datetime, timedelta +from logs import Logs + +class BruteForce: + def __init__(self, enabled:bool, expirationSeconds:int, blockAfterFailures:int): + self.database = {} + self.enabled = enabled + self.expirationSeconds = expirationSeconds + self.blockAfterFailures = blockAfterFailures + self.logs = Logs(self.__class__.__name__) + + def addFailure(self, ip:str): + ''' + Increase IP failure + ''' + # Check if brute force protection is enabled + if not self.enabled: + return False + + # Check if this is the first time that the IP will be in the database + if ip not in self.database: + self.logs.info({'message':'Start IP failure counter.', 'ip': ip, 'failures': '1'}) + blockUntil = datetime.now() + timedelta(seconds=self.expirationSeconds) + self.database[ip] = {'counter': 1, 'blockUntil': blockUntil} + else: + # Check if the IP expire and renew the database for that IP + if self.database[ip]['blockUntil'] < datetime.now(): + self.logs.info({'message':'IP failure counter expired, removing IP...', 'ip': ip}) + del self.database[ip] + self.addFailure(ip) + return False + + # The IP is already in the database, increase the failure counter + self.database[ip]['counter'] = self.database[ip]['counter'] + 1 + self.logs.info({'message':'Increase IP failure counter.', 'ip': ip, 'failures': str(self.database[ip]['counter'])}) + + # The IP already match the amount of failures, block the IP + if self.database[ip]['counter'] >= self.blockAfterFailures: + self.database[ip]['blockUntil'] = datetime.now() + timedelta(seconds=self.expirationSeconds) + self.logs.warning({'message':'IP blocked.', 'ip': ip, 'blockUntil': str(self.database[ip]['blockUntil'])}) + + return False + + def isIpBlocked(self, ip:str) -> bool: + ''' + Returns True if the IP is blocked, False otherwise + ''' + # Check if brute force protection is enabled + if not self.enabled: + return False + + if ip not in self.database: + self.logs.info({'message':'The IP is not in the database and is not blocked.', 'ip': ip}) + return False + + # The IP is on the database, check the amount of failures + if self.database[ip]['counter'] >= self.blockAfterFailures: + self.logs.warning({'message':'The IP is blocked.', 'ip': ip, 'blockUntil': str(self.database[ip]['blockUntil'])}) + + # Check if the IP expire and remove from the database + if self.database[ip]['blockUntil'] < datetime.now(): + self.logs.warning({'message':'Removing IP from the database, lucky guy, time expired.', 'ip': ip}) + del self.database[ip] + return False + return True + + self.logs.info({'message':'The IP is not blocked.', 'ip': ip}) + return False diff --git a/files/cache.py b/files/cache.py index 4c9f037..ea06795 100644 --- a/files/cache.py +++ b/files/cache.py @@ -2,7 +2,6 @@ import hashlib import re from itertools import repeat -from typing import Union from logs import Logs class Cache: def __init__(self, expirationMinutes:int): diff --git a/files/main.py b/files/main.py index d074e84..15deb4f 100644 --- a/files/main.py +++ b/files/main.py @@ -6,6 +6,7 @@ from aldap import Aldap from cache import Cache from logs import Logs +from bruteforce import BruteForce # --- Parameters -------------------------------------------------------------- # Enable or disable SSL self-signed certificate @@ -18,11 +19,26 @@ if "FLASK_SECRET_KEY" in environ: FLASK_SECRET_KEY = str(environ["FLASK_SECRET_KEY"]) -# Expiration in minutes +# Cache expiration in minutes CACHE_EXPIRATION = 5 if "CACHE_EXPIRATION" in environ: CACHE_EXPIRATION = int(environ["CACHE_EXPIRATION"]) +# Brute force enable or disable +BRUTE_FORCE_PROTECTION = False +if "BRUTE_FORCE_PROTECTION" in environ: + BRUTE_FORCE_PROTECTION = (environ["BRUTE_FORCE_PROTECTION"] == "enabled") + +# Brute force expiration in seconds +BRUTE_FORCE_EXPIRATION = 10 +if "BRUTE_FORCE_EXPIRATION" in environ: + BRUTE_FORCE_EXPIRATION = int(environ["BRUTE_FORCE_EXPIRATION"]) + +# Brute force amount of failures +BRUTE_FORCE_FAILURES = 3 +if "BRUTE_FORCE_FAILURES" in environ: + BRUTE_FORCE_FAILURES = int(environ["BRUTE_FORCE_FAILURES"]) + # --- Functions --------------------------------------------------------------- def cleanMatchingUsers(item:str): item = item.strip() @@ -40,12 +56,18 @@ def setRegister(username:str, matchedGroups:list): def getRegister(key): return g.get(key) +def getUserIp(): + return request.remote_addr + # --- Logging ----------------------------------------------------------------- logs = Logs('main') # --- Cache ------------------------------------------------------------------- cache = Cache(CACHE_EXPIRATION) +# --- Brute Force ------------------------------------------------------------- +bruteForce = BruteForce(BRUTE_FORCE_PROTECTION, BRUTE_FORCE_EXPIRATION, BRUTE_FORCE_FAILURES) + # --- Flask ------------------------------------------------------------------- app = Flask(__name__) auth = HTTPBasicAuth() @@ -56,6 +78,9 @@ def login(username, password): logs.error({'message': 'Username or password empty.'}) return False + if bruteForce.isIpBlocked(getUserIp()): + return False + try: if "Ldap-Endpoint" in request.headers: LDAP_ENDPOINT = request.headers.get("Ldap-Endpoint") @@ -141,6 +166,7 @@ def login(username, password): if aldap.authenticateUser(username, password): cache.addUser(username, password) else: + bruteForce.addFailure(getUserIp()) return False # Validate user via matching users @@ -182,6 +208,13 @@ def index(path): headers = [('x-username', getRegister('username')),('x-groups', getRegister('matchedGroups'))] return msg, code, headers +# Overwrite response +@app.after_request +def remove_header(response): + # Change "Server:" header to avoid display server properties + response.headers['Server'] = '' + return response + # Main if __name__ == '__main__': app.secret_key = FLASK_SECRET_KEY diff --git a/files/requirements.txt b/files/requirements.txt index f8ad628..7f889e6 100644 --- a/files/requirements.txt +++ b/files/requirements.txt @@ -1,4 +1,4 @@ -flask==2.0.0 +flask==2.0.1 Flask-HTTPAuth==4.4.0 python-ldap==3.3.1 pyopenssl==20.0.1