Compare commits
	
		
			15 Commits
		
	
	
		
			main
			...
			542718b956
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 542718b956 | |||
| 34f871ae75 | |||
| f3f8bd5dc6 | |||
| 5971c27a8f | |||
| 47e8208ce2 | |||
| af1fef189c | |||
| c33e63978e | |||
| 963f7e5702 | |||
| 450792b2d2 | |||
| ffe144f6b5 | |||
| a20145b447 | |||
| b5f16f161c | |||
| 7c7734c996 | |||
| bd12b2a18a | |||
| 64523ae8b5 | 
| @ -4,12 +4,14 @@ run tests: | |||||||
|   script: |   script: | ||||||
|     - pip install pytest pytest-cov pytest-mock pytest-flask |     - pip install pytest pytest-cov pytest-mock pytest-flask | ||||||
|     - pip install Flask-HTTPAuth |     - pip install Flask-HTTPAuth | ||||||
|     - coverage run -m pytest |     - coverage run -m pytest --junitxml=report.xml | ||||||
|     - coverage report |     - coverage report | ||||||
|     - coverage xml |     - coverage xml | ||||||
|   coverage: '/^TOTAL.+?(\d+\%)$/' |   coverage: '/^TOTAL.+?(\d+\%)$/' | ||||||
|   artifacts: |   artifacts: | ||||||
|  |     when: always | ||||||
|     reports: |     reports: | ||||||
|       cobertura: coverage.xml |       cobertura: coverage.xml | ||||||
|  |       junit: report.xml | ||||||
|   tags: |   tags: | ||||||
|     - docker |     - docker | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from ipaddress import ip_address | |||||||
| import re | import re | ||||||
| from jail2ban.pfctl import pfctl_table_op, pfctl_cfg_read, pfctl_cfg_write | from jail2ban.pfctl import pfctl_table_op, pfctl_cfg_read, pfctl_cfg_write | ||||||
| from jail2ban.auth import get_users | from jail2ban.auth import get_users | ||||||
|  | from subprocess import CalledProcessError | ||||||
|  |  | ||||||
|  |  | ||||||
| auth = HTTPBasicAuth() | auth = HTTPBasicAuth() | ||||||
| @ -26,15 +27,11 @@ def untaint(pattern, string): | |||||||
|         raise ValueError(f'"{string}" is tainted') |         raise ValueError(f'"{string}" is tainted') | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_app(config=None): | def create_app(): | ||||||
|     app = Flask(__name__, instance_relative_config=True) |     app = Flask(__name__, instance_relative_config=True) | ||||||
|  |  | ||||||
|     if config is None: |     # load the instance config, if it exists, when not testing | ||||||
|         # load the instance config, if it exists, when not testing |     app.config.from_pyfile('config.py', silent=True) | ||||||
|         app.config.from_pyfile('config.py', silent=True) |  | ||||||
|     else: |  | ||||||
|         # load the test config if passed in |  | ||||||
|         app.config.from_pyfile(config, silent=True) |  | ||||||
|  |  | ||||||
|     @auth.verify_password |     @auth.verify_password | ||||||
|     def verify_password(username, password): |     def verify_password(username, password): | ||||||
| @ -58,7 +55,7 @@ def create_app(config=None): | |||||||
|         return jsonify({'anchor': f'f2b-jail/{remote_user}', |         return jsonify({'anchor': f'f2b-jail/{remote_user}', | ||||||
|                         'table': f'f2b-{name}', |                         'table': f'f2b-{name}', | ||||||
|                         'operation': 'flush', |                         'operation': 'flush', | ||||||
|                         'result': res}) |                         'result': [x.decode('ascii') for x in res]}) | ||||||
|  |  | ||||||
|     @app.route("/register", methods=['PUT', 'DELETE']) |     @app.route("/register", methods=['PUT', 'DELETE']) | ||||||
|     @auth.login_required |     @auth.login_required | ||||||
| @ -92,12 +89,11 @@ def create_app(config=None): | |||||||
|                            table=f'f2b-{name}', |                            table=f'f2b-{name}', | ||||||
|                            operation='kill') |                            operation='kill') | ||||||
|         app.logger.info(f'pfctl -a f2b-jail/{remote_user} -f-') |         app.logger.info(f'pfctl -a f2b-jail/{remote_user} -f-') | ||||||
|         return jsonify({'remote_user': remote_user, 'data': data}) |  | ||||||
|         return jsonify({'anchor': f'f2b-jail/{remote_user}', |         return jsonify({'anchor': f'f2b-jail/{remote_user}', | ||||||
|                         'table': f'f2b-{name}', |                         'table': f'f2b-{name}', | ||||||
|                         'action': 'start' if request.method == 'PUT' |                         'action': 'start' if request.method == 'PUT' | ||||||
|                         else 'stop', |                         else 'stop', | ||||||
|                         'result': res}) |                         'result': [x.decode('ascii') for x in res]}) | ||||||
|  |  | ||||||
|     @app.route("/ban", methods=['PUT', 'DELETE']) |     @app.route("/ban", methods=['PUT', 'DELETE']) | ||||||
|     @auth.login_required |     @auth.login_required | ||||||
| @ -135,6 +131,14 @@ def create_app(config=None): | |||||||
|         app.logger.fatal(error) |         app.logger.fatal(error) | ||||||
|         return jsonify({'error': str(error)}), 500 |         return jsonify({'error': str(error)}), 500 | ||||||
|  |  | ||||||
|  |     @app.errorhandler(CalledProcessError) | ||||||
|  |     def subprocess_err(error): | ||||||
|  |         ''' | ||||||
|  |         Show a json parsable error if the value is illegal | ||||||
|  |         ''' | ||||||
|  |         app.logger.fatal(error) | ||||||
|  |         return jsonify({'error': str(error)}), 500 | ||||||
|  |  | ||||||
|     @auth.error_handler |     @auth.error_handler | ||||||
|     def auth_error(): |     def auth_error(): | ||||||
|         app.logger.error('Access Denied') |         app.logger.error('Access Denied') | ||||||
|  | |||||||
| @ -9,12 +9,10 @@ def pfctl_cfg_read(anchor): | |||||||
|     cmd = [_SUDO, _PFCTL, '-a', anchor, '-sr'] |     cmd = [_SUDO, _PFCTL, '-a', anchor, '-sr'] | ||||||
|     logging.info('Running %s', cmd) |     logging.info('Running %s', cmd) | ||||||
|  |  | ||||||
|     res = run(cmd, capture_output=True) |     res = run(cmd, capture_output=True, check=True) | ||||||
|  |  | ||||||
|     if res and res.stdout: |     logging.info('Result: %s', res) | ||||||
|         logging.info('Result: %s', res) |     return res.stdout.splitlines() | ||||||
|         res.check_returncode() |  | ||||||
|         return res.stdout.splitlines() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def pfctl_cfg_write(anchor, cfg): | def pfctl_cfg_write(anchor, cfg): | ||||||
| @ -24,12 +22,11 @@ def pfctl_cfg_write(anchor, cfg): | |||||||
|  |  | ||||||
|     res = run(cmd, |     res = run(cmd, | ||||||
|               input=cfg, |               input=cfg, | ||||||
|  |               check=True, | ||||||
|               capture_output=True) |               capture_output=True) | ||||||
|  |  | ||||||
|     if res: |     logging.info('Result: %s', res) | ||||||
|         logging.info('Result: %s', res) |     return res.stdout.splitlines() | ||||||
|         res.check_returncode() |  | ||||||
|         return res |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def pfctl_table_op(anchor, **kwargs): | def pfctl_table_op(anchor, **kwargs): | ||||||
| @ -40,9 +37,9 @@ def pfctl_table_op(anchor, **kwargs): | |||||||
|  |  | ||||||
|     logging.info('Running %s', cmd) |     logging.info('Running %s', cmd) | ||||||
|  |  | ||||||
|     res = run([x for x in cmd if x is not None], capture_output=True) |     res = run([x for x in cmd if x is not None], | ||||||
|  |               capture_output=True, | ||||||
|  |               check=True) | ||||||
|  |  | ||||||
|     if res: |     logging.debug(res) | ||||||
|         logging.debug(res) |     return res.stdout.splitlines() | ||||||
|         res.check_returncode() |  | ||||||
|         return res.stdout.splitlines() |  | ||||||
|  | |||||||
							
								
								
									
										101
									
								
								tests/test_ban.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								tests/test_ban.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | |||||||
|  | import base64 | ||||||
|  | from types import SimpleNamespace | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_ban_ipv6(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses added.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"name": "sshd", "ip": "2001:db8::abad:cafe"} | ||||||
|  |     response = client.put("/ban", | ||||||
|  |                           json=json_payload, | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['operation'] == 'add' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_ban_ipv4(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses added.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"name": "sshd", "ip": "192.0.2.42"} | ||||||
|  |     response = client.put("/ban", | ||||||
|  |                           json=json_payload, | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['operation'] == 'add' | ||||||
|  |  | ||||||
|  | def test_ban_invalid(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses added.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"name": "sshd", "ip": "not:an::addr:ess"} | ||||||
|  |     response = client.put("/ban", | ||||||
|  |                           json=json_payload, | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['error'] == "'not:an::addr:ess' does not appear to be an IPv4 or IPv6 address" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unban_ipv6(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses deleted.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"name": "sshd", "ip": "2001:db8::abad:cafe"} | ||||||
|  |     response = client.delete("/ban", | ||||||
|  |                              json=json_payload, | ||||||
|  |                              headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['operation'] == 'delete' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unban_ipv4(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses deleted.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"name": "sshd", "ip": "192.0.2.42"} | ||||||
|  |     response = client.delete("/ban", | ||||||
|  |                           json=json_payload, | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['operation'] == 'delete' | ||||||
							
								
								
									
										38
									
								
								tests/test_flush.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								tests/test_flush.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | import base64 | ||||||
|  | from types import SimpleNamespace | ||||||
|  | from subprocess import CalledProcessError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_flush(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = SimpleNamespace() | ||||||
|  |     run_res.stdout = b'' | ||||||
|  |     run_res.stderr = b'1/1 addresses deleted.\n' | ||||||
|  |     run_res.returncode = 0 | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     name = 'sshd' | ||||||
|  |     response = client.get(f"/flush/{name}", | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['operation'] == 'flush' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_flush_nonexistent(client, mocker): | ||||||
|  |  | ||||||
|  |     cmd = ['/usr/local/bin/sudo', '/sbin/pfctl', '-a', 'some/anchor', '-t', 'nonexistent', '-T', 'flush'] | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', | ||||||
|  |                  side_effect=CalledProcessError(255, cmd, output=b'', | ||||||
|  |                                                 stderr=b'pfctl: Table does not exist')) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     name = 'nonexistent' | ||||||
|  |     response = client.get(f"/flush/{name}", | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert 'error' in response.json | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import base64 | import base64 | ||||||
| from types import SimpleNamespace | from subprocess import CompletedProcess | ||||||
|  |  | ||||||
| pfctl_stdout_lines = b''' | pfctl_stdout_lines = b''' | ||||||
| block drop quick proto tcp from <f2b-sendmail-auth> to any port = submission | block drop quick proto tcp from <f2b-sendmail-auth> to any port = submission | ||||||
| @ -10,28 +10,73 @@ block drop quick proto tcp from <f2b-recidive> to any | |||||||
| ''' | ''' | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_request_unauth(client): | def test_register_unauth(client): | ||||||
|     json_payload = {"port": "any port {pop3,pop3s,imap,imaps,submission,465,sieve}", "name": "dovecot", "protocol": "tcp"} |     json_payload = {"port": | ||||||
|  |                     "any port {pop3,pop3s,imap,imaps,submission,465,sieve}", | ||||||
|  |                     "name": "dovecot", "protocol": "tcp"} | ||||||
|     response = client.put("/register", json=json_payload) |     response = client.put("/register", json=json_payload) | ||||||
|  |  | ||||||
|     assert response.json['error'] == 'Access Denied' |     assert response.json['error'] == 'Access Denied' | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_request_example(client, mocker): | def test_register_valid(client, mocker): | ||||||
|     def noop(): |     def noop(): | ||||||
|         pass |         pass | ||||||
|     run_res = SimpleNamespace() |     run_res = CompletedProcess(args=['true'], returncode=0) | ||||||
|     run_res.stdout = pfctl_stdout_lines |     run_res.stdout = pfctl_stdout_lines | ||||||
|     run_res.check_returncode = noop |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|     mocker.patch('jail2ban.pfctl.run', return_value=run_res) |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"port": | ||||||
|  |                     "any port {pop3,pop3s,imap,imaps,submission,465,sieve}", | ||||||
|  |                     "name": "dovecot", "protocol": "tcp"} | ||||||
|  |  | ||||||
|  |     response = client.delete("/register", | ||||||
|  |                              json=json_payload, | ||||||
|  |                              headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['action'] == 'stop' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unregister_valid(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = CompletedProcess(args=['true'], returncode=0) | ||||||
|  |     run_res.stdout = pfctl_stdout_lines | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|     json_payload = {"port": "any port {pop3,pop3s,imap,imaps,submission,465,sieve}", "name": "dovecot", "protocol": "tcp"} |     json_payload = {"port": | ||||||
|  |                     "any port {pop3,pop3s,imap,imaps,submission,465,sieve}", | ||||||
|  |                     "name": "dovecot", "protocol": "tcp"} | ||||||
|  |  | ||||||
|     response = client.put("/register", |     response = client.put("/register", | ||||||
|                           json=json_payload, |                           json=json_payload, | ||||||
|                           headers={"Authorization": "Basic " + valid_credentials}) |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['action'] == 'start' | ||||||
|  |  | ||||||
|     assert response.json['remote_user'] == 'test.example.com' |  | ||||||
|  | def test_register_invalid(client, mocker): | ||||||
|  |     def noop(): | ||||||
|  |         pass | ||||||
|  |     run_res = CompletedProcess(args=['true'], returncode=0) | ||||||
|  |     run_res.stdout = pfctl_stdout_lines | ||||||
|  |     run_res.check_returncode = noop | ||||||
|  |  | ||||||
|  |     mocker.patch('jail2ban.pfctl.run', return_value=run_res) | ||||||
|  |  | ||||||
|  |     valid_credentials = base64.b64encode(b"test.example.com:testpassword").decode("utf-8") | ||||||
|  |     json_payload = {"port": | ||||||
|  |                     "not a pf statement", | ||||||
|  |                     "name": "dovecot", "protocol": "tcp"} | ||||||
|  |  | ||||||
|  |     response = client.put("/register", | ||||||
|  |                           json=json_payload, | ||||||
|  |                           headers={"Authorization": "Basic " + valid_credentials}) | ||||||
|  |  | ||||||
|  |     assert response.json['error'] == '"not a pf statement" is tainted' | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user