From e1ce591236f699cbe284074338cf5d7499b2fdac Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Tue, 8 Jun 2021 14:13:40 +0200 Subject: [PATCH 01/10] Include Brute force protection --- files/bruteforce.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ files/cache.py | 1 - files/main.py | 21 +++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 files/bruteforce.py diff --git a/files/bruteforce.py b/files/bruteforce.py new file mode 100644 index 0000000..225b38e --- /dev/null +++ b/files/bruteforce.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta +from logs import Logs +from flask import request + +class BruteForce: + def __init__(self, enabled:bool, expirationMinutes:int, blockAfterFailures:int): + self.database = {} + self.enabled = enabled + self.expirationMinutes = expirationMinutes + self.blockAfterFailures = blockAfterFailures + self.logs = Logs(self.__class__.__name__) + + def addFailure(self): + ''' + Increase IP failure + ''' + if not self.enabled: + return False + + ip = request.remote_addr + + if ip not in self.database: + self.logs.info({'message':'Adding IP to the brute force database.', 'ip': ip}) + self.database[ip] = {'counter': 1, 'blockUntil': 0} + else: + self.database[ip]['counter'] = self.database[ip]['counter'] + 1 + self.logs.info({'message':'Increased IP failure counter.', 'ip': ip, 'failures': str(self.database[ip]['counter'])}) + + if self.database[ip]['counter'] >= self.blockAfterFailures: + self.database[ip]['blockUntil'] = datetime.now() + timedelta(minutes=self.expirationMinutes) + self.logs.warning({'message':'IP blocked.', 'ip': ip, 'blockUntil': str(self.database[ip]['blockUntil'])}) + + def isBlocked(self) -> bool: + ''' + Returns True if the IP is blocked, False otherwise + ''' + if not self.enabled: + return False + + ip = request.remote_addr + + 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 + + if self.database[ip]['counter'] >= self.blockAfterFailures: + self.logs.warning({'message':'The IP is blocked.', 'ip': ip, 'blockUntil': str(self.database[ip]['blockUntil'])}) + 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 \ No newline at end of file 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..e379f30 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 @@ -23,6 +24,19 @@ if "CACHE_EXPIRATION" in environ: CACHE_EXPIRATION = int(environ["CACHE_EXPIRATION"]) +# Expiration in minutes +BRUTE_FORCE_EXPIRATION = 2 +if "BRUTE_FORCE_EXPIRATION" in environ: + BRUTE_FORCE_EXPIRATION = int(environ["BRUTE_FORCE_EXPIRATION"]) + +BRUTE_FORCE_FAILURES = 3 +if "BRUTE_FORCE_FAILURES" in environ: + BRUTE_FORCE_FAILURES = int(environ["BRUTE_FORCE_FAILURES"]) + +BRUTE_FORCE_ENABLED = False +if "BRUTE_FORCE_ENABLED" in environ: + BRUTE_FORCE_ENABLED = (environ["BRUTE_FORCE_ENABLED"] == "enabled") + # --- Functions --------------------------------------------------------------- def cleanMatchingUsers(item:str): item = item.strip() @@ -46,6 +60,9 @@ def getRegister(key): # --- Cache ------------------------------------------------------------------- cache = Cache(CACHE_EXPIRATION) +# --- Brute Force ------------------------------------------------------------- +bruteForce = BruteForce(BRUTE_FORCE_ENABLED, BRUTE_FORCE_EXPIRATION, BRUTE_FORCE_FAILURES) + # --- Flask ------------------------------------------------------------------- app = Flask(__name__) auth = HTTPBasicAuth() @@ -56,6 +73,9 @@ def login(username, password): logs.error({'message': 'Username or password empty.'}) return False + if bruteForce.isBlocked(): + return False + try: if "Ldap-Endpoint" in request.headers: LDAP_ENDPOINT = request.headers.get("Ldap-Endpoint") @@ -141,6 +161,7 @@ def login(username, password): if aldap.authenticateUser(username, password): cache.addUser(username, password) else: + bruteForce.addFailure() return False # Validate user via matching users From 5fa6fc95d3d21bc3a24157779c2f1c494b183410 Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Tue, 8 Jun 2021 15:46:42 +0200 Subject: [PATCH 02/10] expire IP and improve comments --- files/bruteforce.py | 29 +++++++++++++++++++++-------- files/main.py | 15 +++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/files/bruteforce.py b/files/bruteforce.py index 225b38e..972bfca 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -10,41 +10,54 @@ def __init__(self, enabled:bool, expirationMinutes:int, blockAfterFailures:int): self.blockAfterFailures = blockAfterFailures self.logs = Logs(self.__class__.__name__) - def addFailure(self): + def addFailure(self, ip:str): ''' Increase IP failure ''' + # Check if brute force protection is enabled if not self.enabled: return False - ip = request.remote_addr - + # 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':'Adding IP to the brute force database.', 'ip': ip}) - self.database[ip] = {'counter': 1, 'blockUntil': 0} + self.logs.info({'message':'Starting IP failure counter.', 'ip': ip, 'failures': '1'}) + blockUntil = datetime.now() + timedelta(minutes=self.expirationMinutes) + 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 expired, removing from database.', 'ip': ip}) + del self.database[ip] + self.addFailure(ip) + + # The IP is already in the database, increase the failure counter self.database[ip]['counter'] = self.database[ip]['counter'] + 1 self.logs.info({'message':'Increased 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(minutes=self.expirationMinutes) self.logs.warning({'message':'IP blocked.', 'ip': ip, 'blockUntil': str(self.database[ip]['blockUntil'])}) - def isBlocked(self) -> bool: + 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 - ip = request.remote_addr - 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] diff --git a/files/main.py b/files/main.py index e379f30..b1974e5 100644 --- a/files/main.py +++ b/files/main.py @@ -33,9 +33,9 @@ if "BRUTE_FORCE_FAILURES" in environ: BRUTE_FORCE_FAILURES = int(environ["BRUTE_FORCE_FAILURES"]) -BRUTE_FORCE_ENABLED = False -if "BRUTE_FORCE_ENABLED" in environ: - BRUTE_FORCE_ENABLED = (environ["BRUTE_FORCE_ENABLED"] == "enabled") +BRUTE_FORCE_PROTECTION = False +if "BRUTE_FORCE_PROTECTION" in environ: + BRUTE_FORCE_PROTECTION = (environ["BRUTE_FORCE_PROTECTION"] == "enabled") # --- Functions --------------------------------------------------------------- def cleanMatchingUsers(item:str): @@ -54,6 +54,9 @@ def setRegister(username:str, matchedGroups:list): def getRegister(key): return g.get(key) +def getUserIp(): + return request.remote_addr + # --- Logging ----------------------------------------------------------------- logs = Logs('main') @@ -61,7 +64,7 @@ def getRegister(key): cache = Cache(CACHE_EXPIRATION) # --- Brute Force ------------------------------------------------------------- -bruteForce = BruteForce(BRUTE_FORCE_ENABLED, BRUTE_FORCE_EXPIRATION, BRUTE_FORCE_FAILURES) +bruteForce = BruteForce(BRUTE_FORCE_PROTECTION, BRUTE_FORCE_EXPIRATION, BRUTE_FORCE_FAILURES) # --- Flask ------------------------------------------------------------------- app = Flask(__name__) @@ -73,7 +76,7 @@ def login(username, password): logs.error({'message': 'Username or password empty.'}) return False - if bruteForce.isBlocked(): + if bruteForce.isIpBlocked(getUserIp()): return False try: @@ -161,7 +164,7 @@ def login(username, password): if aldap.authenticateUser(username, password): cache.addUser(username, password) else: - bruteForce.addFailure() + bruteForce.addFailure(getUserIp()) return False # Validate user via matching users From ffd27348a93df63d7d8cf53ff280156c823749a3 Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Tue, 8 Jun 2021 15:47:27 +0200 Subject: [PATCH 03/10] remove libraries unused --- files/bruteforce.py | 1 - 1 file changed, 1 deletion(-) diff --git a/files/bruteforce.py b/files/bruteforce.py index 972bfca..37973ee 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -1,6 +1,5 @@ from datetime import datetime, timedelta from logs import Logs -from flask import request class BruteForce: def __init__(self, enabled:bool, expirationMinutes:int, blockAfterFailures:int): From 5fc4e4d11b6010af5b3e5af1af20fdde66a2b5f3 Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Tue, 8 Jun 2021 15:49:44 +0200 Subject: [PATCH 04/10] move variables --- files/main.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/files/main.py b/files/main.py index b1974e5..a69abf3 100644 --- a/files/main.py +++ b/files/main.py @@ -19,24 +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"]) -# Expiration in minutes +# 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 minutes BRUTE_FORCE_EXPIRATION = 2 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"]) -BRUTE_FORCE_PROTECTION = False -if "BRUTE_FORCE_PROTECTION" in environ: - BRUTE_FORCE_PROTECTION = (environ["BRUTE_FORCE_PROTECTION"] == "enabled") - # --- Functions --------------------------------------------------------------- def cleanMatchingUsers(item:str): item = item.strip() From e0f5b86b6ba5c1ff7aed251c544718fae5956f6e Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Tue, 8 Jun 2021 16:03:18 +0200 Subject: [PATCH 05/10] update readme --- README.md | 4 ++++ files/bruteforce.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1faef8c..da7e153 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 | `2`| | Brute force expiration time in minutes 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. @@ -164,6 +167,7 @@ spec: ## 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. +- Brute force protection is blocking user IP, please read this article to know the limitations about blocking the IP https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks ## Breaking changes from v1.x to v2.x - `LDAP_REQUIRED_GROUPS` renamed to `LDAP_ALLOWED_USERS` diff --git a/files/bruteforce.py b/files/bruteforce.py index 37973ee..473ff30 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -64,4 +64,4 @@ def isIpBlocked(self, ip:str) -> bool: return True self.logs.info({'message':'The IP is not blocked.', 'ip': ip}) - return False \ No newline at end of file + return False From b47882b777a987359fe74328e2b9d8e366cd8a28 Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Wed, 9 Jun 2021 10:13:47 +0200 Subject: [PATCH 06/10] change brute force protection from minutes to seconds --- README.md | 7 +++++-- files/bruteforce.py | 8 ++++---- files/main.py | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index da7e153..6daf859 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ All values type are `string`. | 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 | `2`| | Brute force expiration time in minutes 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 @@ -165,9 +165,12 @@ 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. -- Brute force protection is blocking user IP, please read this article to know the limitations about blocking the IP https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks ## Breaking changes from v1.x to v2.x - `LDAP_REQUIRED_GROUPS` renamed to `LDAP_ALLOWED_USERS` diff --git a/files/bruteforce.py b/files/bruteforce.py index 473ff30..3580eba 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -2,10 +2,10 @@ from logs import Logs class BruteForce: - def __init__(self, enabled:bool, expirationMinutes:int, blockAfterFailures:int): + def __init__(self, enabled:bool, expirationSeconds:int, blockAfterFailures:int): self.database = {} self.enabled = enabled - self.expirationMinutes = expirationMinutes + self.expirationSeconds = expirationSeconds self.blockAfterFailures = blockAfterFailures self.logs = Logs(self.__class__.__name__) @@ -20,7 +20,7 @@ def addFailure(self, ip:str): # 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':'Starting IP failure counter.', 'ip': ip, 'failures': '1'}) - blockUntil = datetime.now() + timedelta(minutes=self.expirationMinutes) + 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 @@ -35,7 +35,7 @@ def addFailure(self, ip:str): # 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(minutes=self.expirationMinutes) + 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 diff --git a/files/main.py b/files/main.py index a69abf3..725af7d 100644 --- a/files/main.py +++ b/files/main.py @@ -29,8 +29,8 @@ if "BRUTE_FORCE_PROTECTION" in environ: BRUTE_FORCE_PROTECTION = (environ["BRUTE_FORCE_PROTECTION"] == "enabled") -# Brute force expiration in minutes -BRUTE_FORCE_EXPIRATION = 2 +# Brute force expiration in seconds +BRUTE_FORCE_EXPIRATION = 10 if "BRUTE_FORCE_EXPIRATION" in environ: BRUTE_FORCE_EXPIRATION = int(environ["BRUTE_FORCE_EXPIRATION"]) From 6aef6616eef3dc7a1151edf10a906ce78ba88997 Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Wed, 9 Jun 2021 10:27:04 +0200 Subject: [PATCH 07/10] Owerwrite Server header --- files/main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/files/main.py b/files/main.py index 725af7d..15deb4f 100644 --- a/files/main.py +++ b/files/main.py @@ -208,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 From 8ca3c3bb1f472a4e5628726a79ca596f140684fb Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Wed, 9 Jun 2021 10:33:25 +0200 Subject: [PATCH 08/10] bugfix for ip expiration --- files/bruteforce.py | 1 + 1 file changed, 1 insertion(+) diff --git a/files/bruteforce.py b/files/bruteforce.py index 3580eba..e9ee203 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -28,6 +28,7 @@ def addFailure(self, ip:str): self.logs.info({'message':'IP expired, removing from database.', '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 From 6e291c3240d120301fa32e69504521f2ed7aad6f Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Wed, 9 Jun 2021 10:39:24 +0200 Subject: [PATCH 09/10] update flask --- files/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a8e548ebe19c009f14d5c4bf261a27f6b46dbd1e Mon Sep 17 00:00:00 2001 From: Diego Najar Date: Thu, 10 Jun 2021 11:56:19 +0200 Subject: [PATCH 10/10] Change log phrases --- files/bruteforce.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files/bruteforce.py b/files/bruteforce.py index e9ee203..0fc85f6 100644 --- a/files/bruteforce.py +++ b/files/bruteforce.py @@ -19,20 +19,20 @@ def addFailure(self, ip:str): # 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':'Starting IP failure counter.', 'ip': ip, 'failures': '1'}) + 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 expired, removing from database.', 'ip': ip}) + 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':'Increased IP failure counter.', 'ip': ip, 'failures': str(self.database[ip]['counter'])}) + 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: