mirror of
				https://github.com/mail-in-a-box/mailinabox.git
				synced 2025-10-30 18:50:53 +00:00 
			
		
		
		
	Merge pull request #798 from mail-in-a-box/fail2banjails
add fail2ban jails for ownCloud, postfix submission, roundcube, and the Mail-in-a-Box management daemon
This commit is contained in:
		
						commit
						8844a9185f
					
				| @ -9,6 +9,10 @@ Mail: | ||||
| * Roundcube is updated to version 1.2.0. | ||||
| * SSLv3 and RC4 are now no longer supported in incoming and outgoing mail (SMTP port 25). | ||||
| 
 | ||||
| System: | ||||
| 
 | ||||
| * fail2ban jails added for SMTP submission, Roundcube, ownCloud, the control panel, and munin. | ||||
| 
 | ||||
| v0.18c (June 2, 2016) | ||||
| --------------------- | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										12
									
								
								conf/fail2ban/filter.d/miab-management-daemon.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								conf/fail2ban/filter.d/miab-management-daemon.conf
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| # Fail2Ban filter Mail-in-a-Box management daemon | ||||
| 
 | ||||
| [INCLUDES] | ||||
| 
 | ||||
| before = common.conf | ||||
| 
 | ||||
| [Definition] | ||||
| 
 | ||||
| _daemon = mailinabox | ||||
| 
 | ||||
| failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip <HOST> - timestamp .* | ||||
| ignoreregex = | ||||
							
								
								
									
										7
									
								
								conf/fail2ban/filter.d/miab-munin.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								conf/fail2ban/filter.d/miab-munin.conf
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| [INCLUDES] | ||||
| 
 | ||||
| before = common.conf | ||||
| 
 | ||||
| [Definition] | ||||
| failregex=<HOST> - .*GET /admin/munin/.* HTTP/1.1\" 401.* | ||||
| ignoreregex = | ||||
							
								
								
									
										7
									
								
								conf/fail2ban/filter.d/miab-owncloud.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								conf/fail2ban/filter.d/miab-owncloud.conf
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| [INCLUDES] | ||||
| 
 | ||||
| before = common.conf | ||||
| 
 | ||||
| [Definition] | ||||
| failregex=Login failed: .*Remote IP: '<HOST>[\)'] | ||||
| ignoreregex = | ||||
							
								
								
									
										7
									
								
								conf/fail2ban/filter.d/miab-postfix-submission.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								conf/fail2ban/filter.d/miab-postfix-submission.conf
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| [INCLUDES] | ||||
| 
 | ||||
| before = common.conf | ||||
| 
 | ||||
| [Definition] | ||||
| failregex=postfix/submission/smtpd.*warning.*\[<HOST>\]: .* authentication (failed|aborted) | ||||
| ignoreregex = | ||||
							
								
								
									
										9
									
								
								conf/fail2ban/filter.d/miab-roundcube.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								conf/fail2ban/filter.d/miab-roundcube.conf
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| [INCLUDES] | ||||
| 
 | ||||
| before = common.conf | ||||
| 
 | ||||
| [Definition] | ||||
| 
 | ||||
| failregex = IMAP Error: Login failed for .*? from <HOST>\. AUTHENTICATE.* | ||||
| 
 | ||||
| ignoreregex =  | ||||
| @ -1,4 +1,5 @@ | ||||
| # Fail2Ban configuration file for Mail-in-a-Box | ||||
| # Fail2Ban configuration file for Mail-in-a-Box. Do not edit. | ||||
| # This file is re-generated on updates. | ||||
| 
 | ||||
| [DEFAULT] | ||||
| # Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks | ||||
| @ -6,24 +7,52 @@ | ||||
| # ours too. The string is substituted during installation. | ||||
| ignoreip = 127.0.0.1/8 PUBLIC_IP | ||||
| 
 | ||||
| # JAILS | ||||
| 
 | ||||
| [ssh] | ||||
| maxretry = 7 | ||||
| bantime = 3600 | ||||
| 
 | ||||
| [ssh-ddos] | ||||
| enabled  = true | ||||
| 
 | ||||
| [sasl] | ||||
| enabled  = true | ||||
| 
 | ||||
| [dovecot] | ||||
| enabled = true | ||||
| filter  = dovecotimap | ||||
| logpath = /var/log/mail.log | ||||
| findtime = 30 | ||||
| maxretry = 20 | ||||
| 
 | ||||
| [miab-management] | ||||
| enabled = true | ||||
| filter = miab-management-daemon | ||||
| port = http,https | ||||
| logpath = /var/log/syslog | ||||
| maxretry = 20 | ||||
| findtime = 30 | ||||
| 
 | ||||
| [miab-munin] | ||||
| enabled  = true | ||||
| port     = http,https | ||||
| filter   = miab-munin | ||||
| logpath  = /var/log/nginx/access.log | ||||
| maxretry = 20 | ||||
| findtime = 30 | ||||
| 
 | ||||
| [miab-owncloud] | ||||
| enabled  = true | ||||
| port     = http,https | ||||
| filter   = miab-owncloud | ||||
| logpath  = STORAGE_ROOT/owncloud/owncloud.log | ||||
| maxretry = 20 | ||||
| findtime = 120 | ||||
| 
 | ||||
| [miab-postfix587] | ||||
| enabled  = true | ||||
| port     = 587 | ||||
| filter   = miab-postfix-submission | ||||
| logpath  = /var/log/mail.log | ||||
| maxretry = 20 | ||||
| findtime = 30 | ||||
| 
 | ||||
| [miab-roundcube] | ||||
| enabled  = true | ||||
| port     = http,https | ||||
| filter   = miab-roundcube | ||||
| logpath  = /var/log/roundcubemail/errors | ||||
| maxretry = 20 | ||||
| findtime = 30 | ||||
| 
 | ||||
| [recidive] | ||||
| enabled  = true | ||||
| @ -39,3 +68,13 @@ action   = iptables-allports[name=recidive] | ||||
| # By default we don't configure this address and no action is required from the admin anyway. | ||||
| # So the notification is ommited. This will prevent message appearing in the mail.log that mail | ||||
| # can't be delivered to fail2ban@$HOSTNAME. | ||||
| 
 | ||||
| [sasl] | ||||
| enabled  = true | ||||
| 
 | ||||
| [ssh] | ||||
| maxretry = 7 | ||||
| bantime = 3600 | ||||
| 
 | ||||
| [ssh-ddos] | ||||
| enabled  = true | ||||
| @ -1,7 +1,8 @@ | ||||
| #!/usr/bin/python3 | ||||
| 
 | ||||
| import os, os.path, re, json | ||||
| import os, os.path, re, json, time | ||||
| import subprocess | ||||
| 
 | ||||
| from functools import wraps | ||||
| 
 | ||||
| from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response | ||||
| @ -45,6 +46,9 @@ def authorized_personnel_only(viewfunc): | ||||
| 			privs = [] | ||||
| 			error = "Incorrect username or password" | ||||
| 
 | ||||
| 			# Write a line in the log recording the failed login | ||||
| 			log_failed_login(request) | ||||
| 
 | ||||
| 		# Authorized to access an API view? | ||||
| 		if "admin" in privs: | ||||
| 			# Call view func. | ||||
| @ -117,6 +121,9 @@ def me(): | ||||
| 	try: | ||||
| 		email, privs = auth_service.authenticate(request, env) | ||||
| 	except ValueError as e: | ||||
| 		# Log the failed login | ||||
| 		log_failed_login(request) | ||||
| 
 | ||||
| 		return json_response({ | ||||
| 			"status": "invalid", | ||||
| 			"reason": "Incorrect username or password", | ||||
| @ -583,6 +590,22 @@ def munin_cgi(filename): | ||||
| 		app.logger.warning("munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s", env['PATH_INFO']) | ||||
| 	return response | ||||
| 
 | ||||
| def log_failed_login(request): | ||||
| 	# We need to figure out the ip to list in the message, all our calls are routed | ||||
| 	# through nginx who will put the original ip in X-Forwarded-For. | ||||
| 	# During setup we call the management interface directly to determine the user | ||||
| 	# status. So we can't always use X-Forwarded-For because during setup that header | ||||
| 	# will not be present. | ||||
| 	if request.headers.getlist("X-Forwarded-For"): | ||||
| 		ip = request.headers.getlist("X-Forwarded-For")[0] | ||||
| 	else: | ||||
| 		ip = request.remote_addr | ||||
| 
 | ||||
| 	# We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate" | ||||
| 	# message. | ||||
| 	app.logger.warning( "Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s" % (ip, time.time())) | ||||
| 
 | ||||
| 
 | ||||
| # APP | ||||
| 
 | ||||
| if __name__ == '__main__': | ||||
|  | ||||
| @ -92,7 +92,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then | ||||
| 	mkdir -p $STORAGE_ROOT/owncloud | ||||
| 
 | ||||
| 	# Create an initial configuration file. | ||||
| 	TIMEZONE=$(cat /etc/timezone) | ||||
| 	instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1) | ||||
| 	cat > $STORAGE_ROOT/owncloud/config.php <<EOF; | ||||
| <?php | ||||
| @ -125,7 +124,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then | ||||
|   'mail_smtppassword' => '', | ||||
|   'mail_from_address' => 'owncloud', | ||||
|   'mail_domain' => '$PRIMARY_HOSTNAME', | ||||
|   'logtimezone' => '$TIMEZONE', | ||||
| ); | ||||
| ?> | ||||
| EOF | ||||
| @ -163,7 +161,11 @@ fi | ||||
| #   so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so | ||||
| #   this will make sure it has the right value. | ||||
| # * Some settings weren't included in previous versions of Mail-in-a-Box. | ||||
| # * We need to set the timezone to the system timezone to allow fail2ban to ban | ||||
| #   users within the proper timeframe | ||||
| # * We need to set the logdateformat to something that will work correctly with fail2ban | ||||
| # Use PHP to read the settings file, modify it, and write out the new settings array. | ||||
| TIMEZONE=$(cat /etc/timezone) | ||||
| CONFIG_TEMP=$(/bin/mktemp) | ||||
| php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php; | ||||
| <?php | ||||
| @ -175,6 +177,9 @@ include("$STORAGE_ROOT/owncloud/config.php"); | ||||
| \$CONFIG['overwrite.cli.url'] = '/cloud'; | ||||
| \$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address | ||||
| 
 | ||||
| \$CONFIG['logtimezone'] = '$TIMEZONE'; | ||||
| \$CONFIG['logdateformat'] = 'Y-m-d H:i:s'; | ||||
| 
 | ||||
| echo "<?php\n\\\$CONFIG = "; | ||||
| var_export(\$CONFIG); | ||||
| echo ";"; | ||||
|  | ||||
| @ -291,10 +291,12 @@ restart_service resolvconf | ||||
| 
 | ||||
| # ### Fail2Ban Service | ||||
| 
 | ||||
| # Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh | ||||
| cat conf/fail2ban/jail.local \ | ||||
| # Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix, ssh, etc. | ||||
| rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore | ||||
| cat conf/fail2ban/jails.conf \ | ||||
| 	| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \ | ||||
| 	> /etc/fail2ban/jail.local | ||||
| cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf | ||||
| 	| sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \ | ||||
| 	> /etc/fail2ban/jail.d/mailinabox.conf | ||||
| cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/ | ||||
| 
 | ||||
| restart_service fail2ban | ||||
|  | ||||
							
								
								
									
										195
									
								
								tests/fail2ban.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								tests/fail2ban.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,195 @@ | ||||
| # Test that a box's fail2ban setting are working | ||||
| # correctly by attempting a bunch of failed logins. | ||||
| # Specify SSH login information the command line - | ||||
| # we use that to reset fail2ban after each test, | ||||
| # and we extract the hostname from that to open | ||||
| # connections to. | ||||
| ###################################################################### | ||||
| 
 | ||||
| import sys, os, time, functools | ||||
| 
 | ||||
| # parse command line | ||||
| 
 | ||||
| if len(sys.argv) < 2: | ||||
| 	print("Usage: tests/fail2ban.py user@hostname") | ||||
| 	sys.exit(1) | ||||
| 
 | ||||
| ssh_user, hostname = sys.argv[1].split("@", 1) | ||||
| 
 | ||||
| # define some test types | ||||
| 
 | ||||
| import socket | ||||
| socket.setdefaulttimeout(10) | ||||
| 
 | ||||
| class IsBlocked(Exception): | ||||
| 	"""Tests raise this exception when it appears that a fail2ban | ||||
| 	jail is in effect, i.e. on a connection refused error.""" | ||||
| 	pass | ||||
| 
 | ||||
| def smtp_test(): | ||||
| 	import smtplib | ||||
| 
 | ||||
| 	try: | ||||
| 		server = smtplib.SMTP(hostname, 587) | ||||
| 	except ConnectionRefusedError: | ||||
| 		# looks like fail2ban worked | ||||
| 		raise IsBlocked() | ||||
| 	server.starttls() | ||||
| 	server.ehlo_or_helo_if_needed() | ||||
| 
 | ||||
| 	try: | ||||
| 		server.login("fakeuser", "fakepassword") | ||||
| 		raise Exception("authentication didn't fail") | ||||
| 	except smtplib.SMTPAuthenticationError: | ||||
| 		# athentication should fail | ||||
| 		pass | ||||
| 
 | ||||
| 	try: | ||||
| 		server.quit() | ||||
| 	except: | ||||
| 		# ignore errors here | ||||
| 		pass | ||||
| 
 | ||||
| def imap_test(): | ||||
| 	import imaplib | ||||
| 
 | ||||
| 	try: | ||||
| 		M = imaplib.IMAP4_SSL(hostname) | ||||
| 	except ConnectionRefusedError: | ||||
| 		# looks like fail2ban worked | ||||
| 		raise IsBlocked() | ||||
| 
 | ||||
| 	try: | ||||
| 		M.login("fakeuser", "fakepassword") | ||||
| 		raise Exception("authentication didn't fail") | ||||
| 	except imaplib.IMAP4.error: | ||||
| 		# authentication should fail | ||||
| 		pass | ||||
| 	finally: | ||||
| 		M.logout() # shuts down connection, has nothing to do with login() | ||||
| 
 | ||||
| def http_test(url, expected_status, postdata=None, qsargs=None, auth=None): | ||||
| 	import urllib.parse | ||||
| 	import requests | ||||
| 	from requests.auth import HTTPBasicAuth | ||||
| 
 | ||||
| 	# form request | ||||
| 	url = urllib.parse.urljoin("https://" + hostname, url) | ||||
| 	if qsargs: url += "?" + urllib.parse.urlencode(qsargs) | ||||
| 	urlopen = requests.get if not postdata else requests.post | ||||
| 
 | ||||
| 	try: | ||||
| 		# issue request | ||||
| 		r = urlopen( | ||||
| 			url, | ||||
| 			auth=HTTPBasicAuth(*auth) if auth else None, | ||||
| 			data=postdata, | ||||
| 			headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'}, | ||||
| 			timeout=8) | ||||
| 	except requests.exceptions.ConnectTimeout as e: | ||||
| 		raise IsBlocked() | ||||
| 	except requests.exceptions.ConnectionError as e: | ||||
| 		if "Connection refused" in str(e): | ||||
| 			raise IsBlocked() | ||||
| 		raise # some other unexpected condition | ||||
| 
 | ||||
| 	# return response status code | ||||
| 	if r.status_code != expected_status: | ||||
| 		r.raise_for_status() # anything but 200 | ||||
| 		raise IOError("Got unexpected status code %s." % r.status_code) | ||||
| 
 | ||||
| # define how to run a test | ||||
| 
 | ||||
| def restart_fail2ban_service(final=False): | ||||
| 	# Log in over SSH to restart fail2ban. | ||||
| 	command = "sudo fail2ban-client reload" | ||||
| 	if not final: | ||||
| 		# Stop recidive jails during testing. | ||||
| 		command += " && sudo fail2ban-client stop recidive" | ||||
| 	os.system("ssh %s@%s \"%s\"" % (ssh_user, hostname, command)) | ||||
| 
 | ||||
| def testfunc_runner(i, testfunc, *args): | ||||
| 	print(i+1, end=" ", flush=True) | ||||
| 	testfunc(*args) | ||||
| 
 | ||||
| def run_test(testfunc, args, count, within_seconds, parallel): | ||||
| 	# Run testfunc count times in within_seconds seconds (and actually | ||||
| 	# within a little less time so we're sure we're under the limit). | ||||
| 	# | ||||
| 	# Because some services are slow, like IMAP, we can't necessarily | ||||
| 	# run testfunc sequentially and still get to count requests within | ||||
| 	# the required time. So we split the requests across threads. | ||||
| 
 | ||||
| 	import requests.exceptions | ||||
| 	from multiprocessing import Pool | ||||
| 
 | ||||
| 	restart_fail2ban_service() | ||||
| 
 | ||||
| 	# Log. | ||||
| 	print(testfunc.__name__, " ".join(str(a) for a in args), "...") | ||||
| 
 | ||||
| 	# Record the start time so we can know how to evenly space our | ||||
| 	# calls to testfunc. | ||||
| 	start_time = time.time() | ||||
| 
 | ||||
| 	with Pool(parallel) as p: | ||||
| 		# Distribute the requests across the pool. | ||||
| 		asyncresults = [] | ||||
| 		for i in range(count): | ||||
| 			ar = p.apply_async(testfunc_runner, [i, testfunc] + list(args)) | ||||
| 			asyncresults.append(ar) | ||||
| 
 | ||||
| 		# Wait for all runs to finish. | ||||
| 		p.close() | ||||
| 		p.join() | ||||
| 
 | ||||
| 		# Check for errors. | ||||
| 		for ar in asyncresults: | ||||
| 			try: | ||||
| 				ar.get() | ||||
| 			except IsBlocked: | ||||
| 				print("Test machine prematurely blocked!") | ||||
| 				return False | ||||
| 
 | ||||
| 	# Did we make enough requests within the limit? | ||||
| 	if (time.time()-start_time) > within_seconds: | ||||
| 		raise Exception("Test failed to make %s requests in %d seconds." % (count, within_seconds)) | ||||
| 
 | ||||
| 	# Wait a moment for the block to be put into place. | ||||
| 	time.sleep(4) | ||||
| 
 | ||||
| 	# The next call should fail. | ||||
| 	print("*", end=" ", flush=True) | ||||
| 	try: | ||||
| 		testfunc(*args) | ||||
| 	except IsBlocked: | ||||
| 		# Success -- this one is supposed to be refused. | ||||
| 		print("blocked [OK]") | ||||
| 		return True # OK | ||||
| 
 | ||||
| 	print("not blocked!") | ||||
| 	return False | ||||
| 
 | ||||
| ###################################################################### | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
| 	# run tests | ||||
| 
 | ||||
| 	# SMTP bans at 10 even though we say 20 in the config because we get | ||||
| 	# doubled-up warnings in the logs, we'll let that be for now | ||||
| 	run_test(smtp_test, [], 10, 30, 8) | ||||
| 
 | ||||
| 	# IMAP | ||||
| 	run_test(imap_test, [], 20, 30, 4) | ||||
| 
 | ||||
| 	# Mail-in-a-Box control panel | ||||
| 	run_test(http_test, ["/admin/me", 200], 20, 30, 1) | ||||
| 
 | ||||
| 	# Munin via the Mail-in-a-Box control panel | ||||
| 	run_test(http_test, ["/admin/munin/", 401], 20, 30, 1) | ||||
| 
 | ||||
| 	# ownCloud | ||||
| 	run_test(http_test, ["/cloud/remote.php/webdav", 401, None, None, ["aa", "aa"]], 20, 120, 1) | ||||
| 
 | ||||
| 	# restart fail2ban so that this client machine is no longer blocked | ||||
| 	restart_fail2ban_service(final=True) | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user