Skip to content

Commit

Permalink
Merge pull request #24 from dignajar/refactor/include-brute-force-pro…
Browse files Browse the repository at this point in the history
…tection

Brute force protection
  • Loading branch information
dignajar authored Jun 14, 2021
2 parents ee11e6a + a8e548e commit ef91a43
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 3 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
68 changes: 68 additions & 0 deletions files/bruteforce.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion files/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
35 changes: 34 additions & 1 deletion files/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion files/requirements.txt
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ef91a43

Please sign in to comment.