diff --git a/.gitignore b/.gitignore index 5cc5438ab..7e1587db1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ tests_resume.json tests_log.log *.pyc *~ -.vscode/* \ No newline at end of file +.vscode/* +ECDSA/ +RSA/ +certgen.sh diff --git a/frang/__init__.py b/frang/__init__.py deleted file mode 100644 index edce530dc..000000000 --- a/frang/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["test_http_resp_code_block", "test_http_conn_limits"] - -# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/frang/test_host_required.py b/frang/test_host_required.py deleted file mode 100644 index 9e9e2e68f..000000000 --- a/frang/test_host_required.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -Tests for Frang directive `http_host_required'. -""" - -from framework import tester -from helpers import dmesg - - -class HostHeader(tester.TempestaTest): - """ - Tests for non-TLS related checks in 'http_host_required' directive. See - TLSMatchHostSni test for other cases. - """ - - clients = [{"id": "client", "type": "deproxy", "addr": "${tempesta_ip}", "port": "80"}] - - backends = [ - { - "id": "0", - "type": "deproxy", - "port": "8000", - "response": "static", - "response_content": "HTTP/1.1 200 OK\r\n" - "Content-Length: 0\r\n" - "Connection: keep-alive\r\n\r\n", - } - ] - - tempesta = { - "config": """ - cache 0; - listen 80; - - frang_limits { - http_host_required; - } - - server ${server_ip}:8000; - - """ - } - - WARN_OLD_PROTO = "Warning: frang: Host header field in protocol prior to HTTP/1.1" - WARN_UNKNOWN = "Warning: frang: Request authority is unknown" - WARN_DIFFER = "Warning: frang: Request authority in URI differs from host header" - WARN_IP_ADDR = "Warning: frang: Host header field contains IP address" - - def start_all(self): - self.start_all_servers() - self.start_tempesta() - self.deproxy_manager.start() - srv = self.get_server("0") - self.assertTrue(srv.wait_for_connections(timeout=1)) - - def test_host_good(self): - """Host header is provided, and has the same value as URI in absolute - form. - """ - self.start_all() - - requests = ( - "GET / HTTP/1.1\r\n" - "Host: tempesta-tech.com\r\n" - "\r\n" - "GET / HTTP/1.1\r\n" - "Host: tempesta-tech.com \r\n" - "\r\n" - "GET http://tempesta-tech.com/ HTTP/1.1\r\n" - "Host: tempesta-tech.com\r\n" - "\r\n" - "GET http://user@tempesta-tech.com/ HTTP/1.1\r\n" - "Host: tempesta-tech.com\r\n" - "\r\n" - ) - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(4, len(deproxy_cl.responses)) - self.assertFalse(deproxy_cl.connection_is_closed()) - - def test_host_empty(self): - """Host header has empty value. Restricted by Tempesta security rules.""" - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET / HTTP/1.1\r\n" "Host: \r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_UNKNOWN), 1, "Frang limits warning is not shown") - - def test_host_missing(self): - """Host header is missing, but required.""" - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET / HTTP/1.1\r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_UNKNOWN), 1, "Frang limits warning is not shown") - - def test_host_old_proto(self): - """Host header in http request below http/1.1. Restricted by - Tempesta security rules. - """ - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET / HTTP/1.0\r\n" "Host: tempesta-tech.com\r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual( - klog.warn_count(self.WARN_OLD_PROTO), 1, "Frang limits warning is not shown" - ) - - def test_host_mismatch(self): - """Host header and authority in uri has different values.""" - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET http://user@tempesta-tech.com/ HTTP/1.1\r\n" "Host: example.com\r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_DIFFER), 1, "Frang limits warning is not shown") - - def test_host_mismatch_empty(self): - """Host header is empty, only authority in uri points to specific - virtual host. Not allowed by RFC. - """ - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET http://user@tempesta-tech.com/ HTTP/1.1\r\n" "Host: \r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_UNKNOWN), 1, "Frang limits warning is not shown") - - def test_host_ip(self): - """Host header in IP address form. Restricted by Tempesta security - rules. - """ - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET / HTTP/1.1\r\n" "Host: 127.0.0.1\r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_IP_ADDR), 1, "Frang limits warning is not shown") - - def test_host_ip6(self): - """Host header in IP address form. Restricted by Tempesta security - rules. - """ - self.start_all() - klog = dmesg.DmesgFinder(ratelimited=False) - - requests = "GET / HTTP/1.1\r\n" "Host: [::1]:80\r\n" "\r\n" - deproxy_cl = self.get_client("client") - deproxy_cl.start() - deproxy_cl.make_requests(requests) - deproxy_cl.wait_for_response() - - self.assertEqual(0, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertEqual(klog.warn_count(self.WARN_IP_ADDR), 1, "Frang limits warning is not shown") - - -class AuthorityHeader(tester.TempestaTest): - """Test for 'http_host_required' directive, h2 protocol version, - Curl is not flexible as Deproxy, so we cant set headers as we want to, - so only basic tests are done here. Key `=H "host:..."` sets authority - header for h2, not Host header. - """ - - clients = [ - { - "id": "curl-ip", - "type": "external", - "binary": "curl", - "cmd_args": ( - "-kf " "https://${tempesta_ip}/ " # Set non-null return code on 4xx-5xx responses. - ), - }, - { - "id": "curl-dns", - "type": "external", - "binary": "curl", - "cmd_args": ( - "-kf " # Set non-null return code on 4xx-5xx responses. - "https://${tempesta_ip}/ " - '-H "host: tempesta-test.com"' - ), - }, - ] - - backends = [ - { - "id": "0", - "type": "deproxy", - "port": "8000", - "response": "static", - "response_content": "HTTP/1.1 200 OK\r\n" - "Content-Length: 0\r\n" - "Connection: keep-alive\r\n\r\n", - } - ] - - tempesta = { - "config": """ - cache 0; - listen 443 proto=h2; - - tls_certificate ${tempesta_workdir}/tempesta.crt; - tls_certificate_key ${tempesta_workdir}/tempesta.key; - - frang_limits { - http_host_required; - } - - server ${server_ip}:8000; - - """ - } - - WARN_IP_ADDR = "Warning: frang: Host header field contains IP address" - - def test_pass(self): - """Authority header contains host in DNS form, request is allowed.""" - curl = self.get_client("curl-dns") - - self.start_all_servers() - self.start_tempesta() - srv = self.get_server("0") - self.deproxy_manager.start() - self.assertTrue(srv.wait_for_connections(timeout=1)) - klog = dmesg.DmesgFinder(ratelimited=False) - - curl.start() - self.wait_while_busy(curl) - self.assertEqual( - 0, curl.returncode, msg=("Curl return code is not 0 (%d)." % (curl.returncode)) - ) - self.assertEqual( - klog.warn_count(self.WARN_IP_ADDR), 0, "Frang limits warning is incorrectly shown" - ) - curl.stop() - - def test_block(self): - """Authority header contains name in IP address form, request is - rejected. - """ - curl = self.get_client("curl-ip") - - self.start_all_servers() - self.start_tempesta() - srv = self.get_server("0") - self.deproxy_manager.start() - self.assertTrue(srv.wait_for_connections(timeout=1)) - klog = dmesg.DmesgFinder(ratelimited=False) - - curl.start() - self.wait_while_busy(curl) - self.assertEqual( - 1, curl.returncode, msg=("Curl return code is not 1 (%d)." % (curl.returncode)) - ) - self.assertEqual(klog.warn_count(self.WARN_IP_ADDR), 1, "Frang limits warning is not shown") - curl.stop() diff --git a/frang/test_http_conn_limits.py b/frang/test_http_conn_limits.py deleted file mode 100644 index c61a9fb13..000000000 --- a/frang/test_http_conn_limits.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Functional tests for connection_rate and connection_burst. -If the client creates too many connections, block them. -""" - -from framework import tester -from helpers import dmesg - -__author__ = "Tempesta Technologies, Inc." -__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." -__license__ = "GPL2" - - -class HttpConnBase(tester.TempestaTest): - clients = [ - { - "id": "ab", - "type": "external", - "binary": "ab", - "cmd_args": ( - "-c 2 -n 2 " + "-H 'Host: ' -H 'Connection: close' " + "http://${tempesta_ip}/" - ), - } - ] - - backends = [ - { - "id": "0", - "type": "deproxy", - "port": "8000", - "response": "static", - "response_content": "HTTP/1.0 200 OK\r\n" "Content-Length: 0\r\n\r\n", - } - ] - - def do(self): - klog = dmesg.DmesgFinder(ratelimited=False) - clients = [self.get_client(x["id"]) for x in self.clients] - - self.start_all_servers() - self.start_tempesta() - - self.deproxy_manager.start() - - for cl in clients: - cl.start() - - for cl in clients: - self.wait_while_busy(cl) - - self.warn_count += klog.warn_count(self.WARN_IP_ADDR) - - for cl in clients: - cl.stop() - - -class HttpConnRateBlock(HttpConnBase): - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - connection_rate 1; -} -""", - } - - WARN_IP_ADDR = "Warning: frang: new connections rate exceeded" - - def test(self): - self.warn_count = 0 - self.do() - self.assertGreater(self.warn_count, 0, "Frang limits warning is incorrectly shown") - - -class HttpConnBurstBlock(HttpConnBase): - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - connection_burst 1; -} -""", - } - - WARN_IP_ADDR = "Warning: frang: new connections burst exceeded" - - def test(self): - self.warn_count = 0 - self.do() - self.assertGreater(self.warn_count, 0, "Frang limits warning is incorrectly shown") - - -class HttpConnRateUnblock(HttpConnBase): - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - connection_rate 4; -} -""", - } - - WARN_IP_ADDR = "Warning: frang: new connections rate exceeded" - - def test(self): - self.warn_count = 0 - self.do() - self.assertEqual(self.warn_count, 0, "Frang limits warning is incorrectly shown") - - -class HttpConnBurstUnblock(HttpConnBase): - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - connection_burst 4; -} -""", - } - - WARN_IP_ADDR = "Warning: frang: new connections burst exceeded" - - def test(self): - self.warn_count = 0 - self.do() - self.assertEqual(self.warn_count, 0, "Frang limits warning is incorrectly shown") diff --git a/frang/test_http_resp_code_block.py b/frang/test_http_resp_code_block.py deleted file mode 100644 index 43e6d5351..000000000 --- a/frang/test_http_resp_code_block.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Functional tests for http_resp_code_block. -If your web application works with user accounts, then typically it requires -a user authentication. If you implement the user authentication on your web -site, then an attacker may try to use a brute-force password cracker to get -access to accounts of your users. The second case is much harder to detect. -It's worth mentioning that unsuccessful authorization requests typically -produce error HTTP responses. - -Tempesta FW provides http_resp_code_block for efficient blocking of all types of -password crackers -""" - -from framework import tester - -__author__ = "Tempesta Technologies, Inc." -__copyright__ = "Copyright (C) 2019 Tempesta Technologies, Inc." -__license__ = "GPL2" - - -class HttpRespCodeBlockBase(tester.TempestaTest): - backends = [ - { - "id": "nginx", - "type": "nginx", - "status_uri": "http://${server_ip}:8000/nginx_status", - "config": """ -pid ${pid}; -worker_processes auto; - -events { - worker_connections 1024; - use epoll; -} - -http { - keepalive_timeout ${server_keepalive_timeout}; - keepalive_requests 10; - sendfile on; - tcp_nopush on; - tcp_nodelay on; - - open_file_cache max=1000; - open_file_cache_valid 30s; - open_file_cache_min_uses 2; - open_file_cache_errors off; - - # [ debug | info | notice | warn | error | crit | alert | emerg ] - # Fully disable log errors. - error_log /dev/null emerg; - - # Disable access log altogether. - access_log off; - - server { - listen ${server_ip}:8000; - - location /uri1 { - return 404; - } - location /uri2 { - return 200; - } - location /nginx_status { - stub_status on; - } - } -} -""", - } - ] - - clients = [ - { - "id": "deproxy", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - "interface": True, - "rps": 6, - }, - { - "id": "deproxy2", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - "interface": True, - "rps": 5, - }, - ] - - -class HttpRespCodeBlock(HttpRespCodeBlockBase): - """Blocks an attacker's IP address if a protected web application return - 5 error responses with codes 404 within 2 seconds. This is 2,5 per second. - """ - - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - http_resp_code_block 404 5 2; -} -""", - } - - """Two clients. One client sends 12 requests by 6 per second during - 2 seconds. Of these, 6 requests by 3 per second give 404 responses and - should be blocked after 10 responses (5 with code 200 and 5 with code 404). - The second client sends 20 requests by 5 per second during 4 seconds. - Of these, 10 requests by 2.5 per second give 404 responses and should not be - blocked. - """ - - def test(self): - requests = ( - "GET /uri1 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" - "GET /uri2 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" * 6 - ) - requests2 = ( - "GET /uri1 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" - "GET /uri2 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" * 10 - ) - nginx = self.get_server("nginx") - nginx.start() - self.start_tempesta() - - deproxy_cl = self.get_client("deproxy") - deproxy_cl.start() - - deproxy_cl2 = self.get_client("deproxy2") - deproxy_cl2.start() - - self.deproxy_manager.start() - self.assertTrue(nginx.wait_for_connections(timeout=1)) - - deproxy_cl.make_requests(requests) - deproxy_cl2.make_requests(requests2) - - deproxy_cl.wait_for_response(timeout=2) - deproxy_cl2.wait_for_response(timeout=4) - - self.assertEqual(10, len(deproxy_cl.responses)) - self.assertEqual(20, len(deproxy_cl2.responses)) - - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertFalse(deproxy_cl2.connection_is_closed()) - - -class HttpRespCodeBlockWithReply(HttpRespCodeBlockBase): - """Tempesta must return appropriate error status if a protected web - application return more 5 error responses with codes 404 within 2 seconds. - This is 2,5 per second. - """ - - tempesta = { - "config": """ -server ${server_ip}:8000; - -frang_limits { - http_resp_code_block 404 5 2; -} - -block_action attack reply; -""", - } - - """Two clients. One client sends 12 requests by 6 per second during - 2 seconds. Of these, 6 requests by 3 per second give 404 responses. - Should be get 11 responses (5 with code 200, 5 with code 404 and - 1 with code 403). - The second client sends 20 requests by 5 per second during 4 seconds. - Of these, 10 requests by 2.5 per second give 404 responses. All requests - should be get responses. - """ - - def test(self): - requests = ( - "GET /uri1 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" - "GET /uri2 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" * 6 - ) - requests2 = ( - "GET /uri1 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" - "GET /uri2 HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" * 10 - ) - nginx = self.get_server("nginx") - nginx.start() - self.start_tempesta() - - deproxy_cl = self.get_client("deproxy") - deproxy_cl.start() - - deproxy_cl2 = self.get_client("deproxy2") - deproxy_cl2.start() - - self.deproxy_manager.start() - self.assertTrue(nginx.wait_for_connections(timeout=1)) - - deproxy_cl.make_requests(requests) - deproxy_cl2.make_requests(requests2) - - deproxy_cl.wait_for_response(timeout=2) - deproxy_cl2.wait_for_response(timeout=4) - - self.assertEqual(11, len(deproxy_cl.responses)) - self.assertEqual(20, len(deproxy_cl2.responses)) - - self.assertEqual("403", deproxy_cl.responses[-1].status, "Unexpected response status code") - - self.assertTrue(deproxy_cl.connection_is_closed()) - self.assertFalse(deproxy_cl2.connection_is_closed()) diff --git a/t_frang/__init__.py b/t_frang/__init__.py new file mode 100644 index 000000000..b45aec41f --- /dev/null +++ b/t_frang/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "test_connection_rate_burst", + "test_header_cnt", + "test_host_required", + "test_http_resp_code_block", + "test_ip_block", + "test_length", + "test_request_rate_burst", + "test_tls_incomplete", + "test_http_method_override_allowed", + "test_client_body_and_header_timeout", +] + +# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/t_frang/frang_test_case.py b/t_frang/frang_test_case.py new file mode 100644 index 000000000..ffc8dee4a --- /dev/null +++ b/t_frang/frang_test_case.py @@ -0,0 +1,97 @@ +"""Basic file for frang functional tests.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +import time + +from framework import tester +from framework.deproxy_client import DeproxyClient +from helpers import dmesg + +DELAY = 0.125 + + +class FrangTestCase(tester.TempestaTest): + """Base class for frang tests.""" + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + ] + + tempesta_template = { + "config": """ +cache 0; +listen 80; +frang_limits { + %(frang_config)s + ip_block off; +} +server ${server_ip}:8000; +block_action attack reply; +""", + } + + backends = [ + { + "id": "deproxy", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": ( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n" + ), + }, + ] + + timeout = 0.5 + + def setUp(self): + super().setUp() + self.klog = dmesg.DmesgFinder(ratelimited=False) + self.assert_msg = "Expected nums of warnings in `journalctl`: {exp}, but got {got}" + + def set_frang_config(self, frang_config: str): + self.tempesta["config"] = self.tempesta_template["config"] % { + "frang_config": frang_config, + } + self.setUp() + self.start_all_services(client=False) + + def base_scenario(self, frang_config: str, requests: list) -> DeproxyClient: + self.set_frang_config(frang_config) + + client = self.get_client("deproxy-1") + client.parsing = False + client.start() + for request in requests: + client.make_request(request) + client.wait_for_response(1) + return client + + def check_response(self, client, status_code: str, warning_msg: str): + time.sleep(self.timeout) + + for response in client.responses: + + self.assertIsNotNone(response, "Deproxy client has lost response.") + self.assertEqual(response.status, status_code, "HTTP response status codes mismatch.") + + if status_code == "200": + self.assertFalse(client.connection_is_closed()) + self.assertFrangWarning(warning=warning_msg, expected=0) + else: + self.assertTrue(client.connection_is_closed()) + self.assertFrangWarning(warning=warning_msg, expected=1) + + def assertFrangWarning(self, warning: str, expected: int): + warning_count = self.klog.warn_count(warning) + self.assertEqual( + warning_count, expected, self.assert_msg.format(exp=expected, got=warning_count) + ) diff --git a/t_frang/test_client_body_and_header_timeout.py b/t_frang/test_client_body_and_header_timeout.py new file mode 100644 index 000000000..223a22049 --- /dev/null +++ b/t_frang/test_client_body_and_header_timeout.py @@ -0,0 +1,62 @@ +"""Functional tests for `client_body_timeout` and `client_header_timeout` in Tempesta config.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +import time + +from t_frang.frang_test_case import FrangTestCase + +TIMEOUT = 1 + + +class TestTimeoutBase(FrangTestCase): + request_segment_1: str + request_segment_2: str + error: str + frang_config: str + + def send_request_with_sleep(self, sleep: float): + client = self.get_client("deproxy-1") + client.parsing = False + client.start() + + client.make_request(self.request_segment_1) + if sleep < TIMEOUT: + time.sleep(sleep) + client.make_request(self.request_segment_2) + client.valid_req_num = 1 + client.wait_for_response(sleep + 1) + + +class ClientBodyTimeout(TestTimeoutBase): + + request_segment_1 = ( + "POST / HTTP/1.1\r\n" + "Host: debian\r\n" + "Content-Type: text/html\r\n" + "Content-Length: 5\r\n" + "\r\n" + "te" + ) + request_segment_2 = "sts" + error = "Warning: frang: client body timeout exceeded" + frang_config = f"client_body_timeout {TIMEOUT};" + + def test_timeout_ok(self): + self.set_frang_config(frang_config=self.frang_config) + self.send_request_with_sleep(sleep=TIMEOUT / 2) + self.check_response(self.get_client("deproxy-1"), "200", self.error) + + def test_timeout_invalid(self): + self.set_frang_config(frang_config=self.frang_config) + self.send_request_with_sleep(sleep=TIMEOUT * 1.5) + self.check_response(self.get_client("deproxy-1"), "403", self.error) + + +class ClientHeaderTimeout(ClientBodyTimeout): + request_segment_1 = "POST / HTTP/1.1\r\nHost: debian\r\n" + request_segment_2 = "Content-Type: text/html\r\nContent-Length: 0\r\n\r\n" + error = "Warning: frang: client header timeout exceeded" + frang_config = f"client_header_timeout {TIMEOUT};" diff --git a/t_frang/test_concurrent_connections.py b/t_frang/test_concurrent_connections.py new file mode 100644 index 000000000..119639dd7 --- /dev/null +++ b/t_frang/test_concurrent_connections.py @@ -0,0 +1,192 @@ +"""Functional tests for `concurrent_connections` in Tempesta config.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2019-2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +import time + +from t_frang.frang_test_case import FrangTestCase + +ERROR = "Warning: frang: connections max num. exceeded" + + +class ConcurrentConnections(FrangTestCase): + tempesta_template = { + "config": """ +server ${server_ip}:8000; + +frang_limits { + %(frang_config)s +} + +""", + } + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-3", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-interface-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + { + "id": "deproxy-interface-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + { + "id": "deproxy-interface-3", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + { + "id": "parallel-curl", + "type": "curl", + "uri": "/[1-2]", + "parallel": 2, + "headers": { + "Connection": "close", + "Host": "debian", + }, + "cmd_args": " --verbose", + }, + ] + + def _base_scenario(self, clients: list, responses: int): + for client in clients: + client.start() + + for client in clients: + client.make_request("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + + for client in clients: + client.wait_for_response(timeout=2) + + time.sleep(self.timeout) + + if responses == 0: + for client in clients: + self.assertEqual(0, len(client.responses)) + self.assertTrue(client.connection_is_closed()) + elif responses == 2: + self.assertEqual(1, len(clients[0].responses)) + self.assertEqual(1, len(clients[1].responses)) + self.assertEqual(0, len(clients[2].responses)) + self.assertFalse(clients[0].connection_is_closed()) + self.assertFalse(clients[1].connection_is_closed()) + self.assertTrue(clients[2].connection_is_closed()) + elif responses == 3: + self.assertEqual(1, len(clients[0].responses)) + self.assertEqual(1, len(clients[1].responses)) + self.assertEqual(1, len(clients[2].responses)) + self.assertFalse(clients[0].connection_is_closed()) + self.assertFalse(clients[1].connection_is_closed()) + self.assertFalse(clients[2].connection_is_closed()) + + def test_three_clients_same_ip(self): + """ + For three clients with same IP and concurrent_connections 2, ip_block off: + - Tempesta serves only two clients. + """ + self.set_frang_config(frang_config="concurrent_connections 2;\n\tip_block off;") + + self._base_scenario( + clients=[ + self.get_client("deproxy-1"), + self.get_client("deproxy-2"), + self.get_client("deproxy-3"), + ], + responses=2, + ) + + self.assertFrangWarning(warning=ERROR, expected=1) + + def test_three_clients_different_ip(self): + """ + For three clients with different IP and concurrent_connections 2: + - Tempesta serves three clients. + """ + self.set_frang_config(frang_config="concurrent_connections 2;\n\tip_block off;") + self._base_scenario( + clients=[ + self.get_client("deproxy-interface-1"), + self.get_client("deproxy-interface-2"), + self.get_client("deproxy-interface-3"), + ], + responses=3, + ) + + self.assertFrangWarning(warning=ERROR, expected=0) + + def test_three_clients_same_ip_with_block_ip(self): + """ + For three clients with same IP and concurrent_connections 2, ip_block on: + - Tempesta does not serve clients. + """ + self.set_frang_config(frang_config="concurrent_connections 2;\n\tip_block on;") + self._base_scenario( + clients=[ + self.get_client("deproxy-1"), + self.get_client("deproxy-2"), + self.get_client("deproxy-3"), + ], + responses=0, + ) + + self.assertFrangWarning(warning=ERROR, expected=1) + + def test_clear_client_connection_stats(self): + """ + Establish connections for many clients with same IP, then close them. + Check that Tempesta cleared client connection stats and + new connections are established. + """ + self.set_frang_config(frang_config="concurrent_connections 2;\n\tip_block on;") + + client = self.get_client("parallel-curl") + + client.start() + self.wait_while_busy(client) + client.stop() + + time.sleep(self.timeout) + + self.assertFrangWarning(warning=ERROR, expected=0) + self.assertIn("Closing connection 1", client.last_response.stderr) + self.assertIn("Closing connection 0", client.last_response.stderr) + + time.sleep(1) + + client.start() + self.wait_while_busy(client) + client.stop() + + time.sleep(self.timeout) + + self.assertFrangWarning(warning=ERROR, expected=0) + self.assertIn("Closing connection 1", client.last_response.stderr) + self.assertIn("Closing connection 0", client.last_response.stderr) diff --git a/t_frang/test_connection_rate_burst.py b/t_frang/test_connection_rate_burst.py new file mode 100644 index 000000000..02cab9892 --- /dev/null +++ b/t_frang/test_connection_rate_burst.py @@ -0,0 +1,472 @@ +"""Tests for Frang directive `connection_rate` and 'connection_burst'.""" +import time + +from t_frang.frang_test_case import DELAY, FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +ERROR = "Warning: frang: new connections {0} exceeded for" +ERROR_TLS = "Warning: frang: new TLS connections {0} exceeded for" + + +class FrangTlsRateBurstTestCase(FrangTestCase): + """Tests for 'tls_connection_burst' and 'tls_connection_rate'.""" + + clients = [ + { + "id": "curl-1", + "type": "curl", + "ssl": True, + "addr": "${tempesta_ip}:443", + "cmd_args": "-v", + "headers": { + "Connection": "close", + "Host": "localhost", + }, + }, + ] + + tempesta_template = { + "config": """ + frang_limits { + %(frang_config)s + } + + listen 443 proto=https; + + srv_group default { + server ${server_ip}:8000; + } + + vhost tempesta-cat { + proxy_pass default; + } + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + cache_fulfill * *; + block_action attack reply; + + http_chain { + -> tempesta-cat; + } + """, + } + + burst_warning = ERROR_TLS.format("burst") + rate_warning = ERROR_TLS.format("rate") + burst_config = "tls_connection_burst 5;\n\ttls_connection_rate 20;" + rate_config = "tls_connection_burst 2;\n\ttls_connection_rate 4;" + + def _base_burst_scenario(self, connections: int): + """ + Create several client connections and send request. + If number of connections is more than 3 they will be blocked. + """ + curl = self.get_client("curl-1") + curl.uri += f"[1-{connections}]" + curl.parallel = connections + + self.set_frang_config(self.burst_config) + + curl.start() + self.wait_while_busy(curl) + curl.stop() + + time.sleep(self.timeout) + + warning_count = connections - 5 if connections > 5 else 0 # limit burst 5 + + self.assertFrangWarning(warning=self.burst_warning, expected=warning_count) + + if warning_count: + self.assertIn("Failed sending HTTP request", curl.last_response.stderr) + + self.assertFrangWarning(warning=self.rate_warning, expected=0) + + def _base_rate_scenario(self, connections: int): + """ + Create several client connections and send request. + If number of connections is more than 3m they will be blocked. + """ + curl = self.get_client("curl-1") + self.set_frang_config(self.rate_config) + + for step in range(connections): + curl.start() + self.wait_while_busy(curl) + curl.stop() + + time.sleep(DELAY) + + time.sleep(self.timeout) + + # until rate limit is reached + if connections <= 4: # rate limit 4 + self.assertFrangWarning(warning=self.rate_warning, expected=0) + self.assertEqual(curl.last_response.status, 200) + else: + # rate limit is reached + self.assertFrangWarning(warning=self.rate_warning, expected=1) + self.assertIn("Failed sending HTTP request", curl.last_response.stderr) + + self.assertFrangWarning(warning=self.burst_warning, expected=0) + + def test_connection_burst(self): + self._base_burst_scenario(connections=10) + + def test_connection_burst_without_reaching_the_limit(self): + self._base_burst_scenario(connections=2) + + def test_connection_burst_on_the_limit(self): + self._base_burst_scenario(connections=5) + + def test_connection_rate(self): + self._base_rate_scenario(connections=5) + + def test_connection_rate_without_reaching_the_limit(self): + self._base_rate_scenario(connections=2) + + def test_connection_rate_on_the_limit(self): + self._base_rate_scenario(connections=4) + + +class FrangConnectionRateBurstTestCase(FrangTlsRateBurstTestCase): + """Tests for 'connection_burst' and 'connection_rate'.""" + + clients = [ + { + "id": "curl-1", + "type": "curl", + "addr": "${tempesta_ip}:80", + "headers": { + "Connection": "close", + "Host": "localhost", + }, + }, + ] + + tempesta_template = { + "config": """ + frang_limits { + %(frang_config)s + } + + listen 80; + + server ${server_ip}:8000; + + cache 0; + block_action attack reply; + """, + } + + burst_warning = ERROR.format("burst") + rate_warning = ERROR.format("rate") + burst_config = "connection_burst 5;\n\tconnection_rate 20;" + rate_config = "connection_burst 2;\n\tconnection_rate 4;" + + +class FrangConnectionRateDifferentIp(FrangTestCase): + clients = [ + { + "id": "curl-1", + "type": "curl", + "addr": "${tempesta_ip}:80", + "uri": "/[1-3]", + "parallel": 3, + "headers": { + "Connection": "close", + "Host": "debian", + }, + }, + { + "id": "deproxy-interface-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + ] + + tempesta = { + "config": """ + frang_limits { + connection_rate 2; + ip_block on; + } + listen 80; + server ${server_ip}:8000; + block_action attack reply; + """, + } + + request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + + error = ERROR.format("rate") + + def test_two_clients_two_ip(self): + """ + Create 3 client connections for first ip and 1 for second ip. + Only first ip will be blocked. + """ + client_1 = self.get_client("deproxy-interface-1") + client_2 = self.get_client("curl-1") + + self.start_all_services(client=False) + + client_1.start() + client_2.start() + + self.wait_while_busy(client_2) + client_2.stop() + + client_1.send_request(self.request, "200") + + server = self.get_server("deproxy") + self.assertTrue(4 > len(server.requests)) + + time.sleep(self.timeout) + + self.assertFrangWarning(warning=self.error, expected=1) + + +class FrangConnectionBurstDifferentIp(FrangConnectionRateDifferentIp): + tempesta = { + "config": """ + frang_limits { + connection_burst 2; + ip_block on; + } + listen 80; + server ${server_ip}:8000; + block_action attack reply; + """, + } + + error = ERROR.format("burst") + + +class FrangTlsRateDifferentIp(FrangConnectionRateDifferentIp): + clients = [ + { + "id": "curl-1", + "type": "curl", + "addr": "${tempesta_ip}:443", + "uri": "/[1-3]", + "parallel": 3, + "ssl": True, + "headers": { + "Connection": "close", + "Host": "debian", + }, + }, + { + "id": "deproxy-interface-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "443", + "ssl": True, + "interface": True, + }, + ] + + tempesta = { + "config": """ + frang_limits { + tls_connection_rate 2; + ip_block on; + } + + listen 443 proto=https; + + server ${server_ip}:8000; + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + block_action attack reply; + + """, + } + + error = ERROR_TLS.format("rate") + + +class FrangTlsBurstDifferentIp(FrangTlsRateDifferentIp): + tempesta = { + "config": """ + frang_limits { + tls_connection_burst 2; + ip_block on; + } + + listen 443 proto=https; + + server ${server_ip}:8000; + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + block_action attack reply; + + """, + } + + error = ERROR_TLS.format("burst") + + +class FrangTlsAndNonTlsRateBurst(FrangTestCase): + """Tests for tls and non-tls connections 'tls_connection_burst' and 'tls_connection_rate'""" + + clients = [ + { + "id": "curl-https", + "type": "curl", + "ssl": True, + "addr": "${tempesta_ip}:443", + "cmd_args": "-v", + "headers": { + "Connection": "close", + "Host": "localhost", + }, + }, + { + "id": "curl-http", + "type": "curl", + "addr": "${tempesta_ip}:80", + "cmd_args": "-v", + "headers": { + "Connection": "close", + "Host": "localhost", + }, + }, + ] + + tempesta_template = { + "config": """ + frang_limits { + %(frang_config)s + } + + listen 80; + listen 443 proto=https; + + server ${server_ip}:8000; + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + block_action attack reply; + """, + } + + base_client_id = "curl-https" + optional_client_id = "curl-http" + burst_warning = ERROR_TLS.format("burst") + rate_warning = ERROR_TLS.format("rate") + burst_config = "tls_connection_burst 3;" + rate_config = "tls_connection_rate 3;" + + def test_burst(self): + """ + Set `tls_connection_burst 3` and create 4 tls and 4 non-tls connections. + Only tls connections will be blocked. + """ + self.set_frang_config(frang_config=self.burst_config) + + base_client = self.get_client(self.base_client_id) + optional_client = self.get_client(self.optional_client_id) + + # burst limit 3 + limit = 4 + + base_client.uri += f"[1-{limit}]" + optional_client.uri += f"[1-{limit}]" + base_client.parallel = limit + optional_client.parallel = limit + + base_client.start() + optional_client.start() + self.wait_while_busy(base_client, optional_client) + base_client.stop() + optional_client.stop() + + self.assertEqual(len(optional_client.stats), limit, "Client has been unexpectedly blocked.") + for stat in optional_client.stats: + self.assertEqual(stat["response_code"], 200) + time.sleep(self.timeout) + self.assertFrangWarning(warning=self.burst_warning, expected=1) + + def test_rate(self): + """ + Set `tls_connection_rate 3` and create 4 tls and 4 non-tls connections. + Only tls connections will be blocked. + """ + self.set_frang_config(frang_config=self.rate_config) + + base_client = self.get_client(self.base_client_id) + optional_client = self.get_client(self.optional_client_id) + + # limit rate 3 + limit = 4 + + base_client.uri += f"[1-{limit}]" + optional_client.uri += f"[1-{limit}]" + base_client.parallel = limit + optional_client.parallel = limit + + base_client.start() + optional_client.start() + self.wait_while_busy(base_client, optional_client) + base_client.stop() + optional_client.stop() + + self.assertEqual(len(optional_client.stats), limit, "Client has been unexpectedly blocked.") + for stat in optional_client.stats: + self.assertEqual(stat["response_code"], 200) + time.sleep(self.timeout) + self.assertFrangWarning(warning=self.rate_warning, expected=1) + + +class FrangConnectionTlsAndNonTlsRateBurst(FrangTlsAndNonTlsRateBurst): + """Tests for tls and non-tls connections 'connection_burst' and 'connection_rate'""" + + tempesta_template = { + "config": """ + frang_limits { + %(frang_config)s + } + + listen 80; + listen 443 proto=https; + + server ${server_ip}:8000; + + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + block_action attack reply; + """, + } + + base_client_id = "curl-http" + optional_client_id = "curl-https" + burst_warning = ERROR.format("burst") + rate_warning = ERROR.format("rate") + burst_config = "connection_burst 3;" + rate_config = "connection_rate 3;" diff --git a/t_frang/test_header_cnt.py b/t_frang/test_header_cnt.py new file mode 100644 index 000000000..992afec77 --- /dev/null +++ b/t_frang/test_header_cnt.py @@ -0,0 +1,65 @@ +"""Tests for Frang directive `http_header_cnt`.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +from t_frang.frang_test_case import FrangTestCase + +ERROR = "Warning: frang: HTTP headers number exceeded for" + + +class FrangHttpHeaderCountTestCase(FrangTestCase): + """Tests for 'http_header_cnt' directive.""" + + requests = [ + "POST / HTTP/1.1\r\n" + "Host: debian\r\n" + "Content-Type: text/html\r\n" + "Connection: close\r\n" + "Content-Length: 0\r\n\r\n" + ] + + def test_reaching_the_limit(self): + """ + We set up for Tempesta `http_header_cnt 2` and + made request with 4 headers + """ + client = self.base_scenario(frang_config="http_header_cnt 2;", requests=self.requests) + self.check_response(client, status_code="403", warning_msg=ERROR) + + def test_not_reaching_the_limit(self): + """ + We set up for Tempesta `http_header_cnt 4` and + made request with 4 headers + """ + client = self.base_scenario(frang_config="http_header_cnt 4;", requests=self.requests) + self.check_response(client, status_code="200", warning_msg=ERROR) + + def test_not_reaching_the_limit_2(self): + """ + We set up for Tempesta `http_header_cnt 6` and + made request with 4 headers + """ + client = self.base_scenario(frang_config="http_header_cnt 6;", requests=self.requests) + self.check_response(client, status_code="200", warning_msg=ERROR) + + def test_default_http_header_cnt(self): + """ + We set up for Tempesta default `http_header_cnt` and + made request with many headers + """ + client = self.base_scenario( + frang_config="", + requests=[ + "GET / HTTP/1.1\r\n" + "Host: debian\r\n" + "Host1: debian\r\n" + "Host2: debian\r\n" + "Host3: debian\r\n" + "Host4: debian\r\n" + "Host5: debian\r\n" + "\r\n" + ], + ) + self.check_response(client, status_code="200", warning_msg=ERROR) diff --git a/t_frang/test_host_required.py b/t_frang/test_host_required.py new file mode 100644 index 000000000..1cda7f845 --- /dev/null +++ b/t_frang/test_host_required.py @@ -0,0 +1,381 @@ +"""Tests for Frang directive `http_host_required`.""" + +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +WARN_UNKNOWN = "frang: Request authority is unknown" +WARN_DIFFER = "frang: Request authority in URI differs from host header" +WARN_IP_ADDR = "frang: Host header field contains IP address" +WARN_HEADER_FORWARDED = "Request authority in URI differs from forwarded" +WARN_HEADER_FORWARDED2 = "frang: Request authority differs from forwarded" + + +class FrangHostRequiredTestCase(FrangTestCase): + """ + Tests for non-TLS related checks in 'http_host_required' directive. + + See TLSMatchHostSni test for other cases. + """ + + def test_host_header_set_ok(self): + """Test with header `host`, success.""" + requests = [ + "GET / HTTP/1.1\r\nHost: tempesta-tech.com:80\r\n\r\n", + "GET / HTTP/1.1\r\nHost: tempesta-tech.com \r\n\r\n", + "GET http://tempesta-tech.com/ HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n", + "GET http://user@tempesta-tech.com/ HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n", + ( + "GET http://user@tempesta-tech.com/ HTTP/1.1\r\n" + "Host: tempesta-tech.com\r\n" + "Forwarded: host=tempesta-tech.com\r\n" + "Forwarded: host=tempesta1-tech.com\r\n\r\n" + ), + ] + client = self.base_scenario(frang_config="http_host_required true;", requests=requests) + self.check_response(client, status_code="200", warning_msg="frang: ") + + def test_empty_host_header(self): + """Test with empty header `host`.""" + client = self.base_scenario( + frang_config="http_host_required true;", requests=["GET / HTTP/1.1\r\nHost: \r\n\r\n"] + ) + self.check_response(client, status_code="403", warning_msg=WARN_UNKNOWN) + + def test_host_header_missing(self): + """Test with missing header `host`.""" + client = self.base_scenario( + frang_config="http_host_required true;", requests=["GET / HTTP/1.1\r\n\r\n"] + ) + self.check_response(client, status_code="403", warning_msg=WARN_UNKNOWN) + + def test_host_header_with_old_proto(self): + """ + Test with header `host` and http v 1.0. + + Host header in http request below http/1.1. Restricted by + Tempesta security rules. + """ + client = self.base_scenario( + frang_config="http_host_required true;", + requests=["GET / HTTP/1.0\r\nHost: tempesta-tech.com\r\n\r\n"], + ) + self.check_response( + client, + status_code="403", + warning_msg="frang: Host header field in protocol prior to HTTP/1.1", + ) + + def test_host_header_mismatch(self): + """Test with mismatched header `host`.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=["GET http://user@tempesta-tech.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=WARN_DIFFER) + + def test_host_header_mismatch_empty(self): + """ + Test with Host header is empty. + + Only authority in uri points to specific virtual host. + Not allowed by RFC. + """ + client = self.base_scenario( + frang_config="http_host_required true;", + requests=["GET http://user@tempesta-tech.com/ HTTP/1.1\r\nHost: \r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=WARN_UNKNOWN) + + def test_host_header_forwarded(self): + """Test with invalid host in `Forwarded` header.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + ( + "GET / HTTP/1.1\r\n" + "Host: tempesta-tech.com\r\n" + "Forwarded: host=qwerty.com\r\n\r\n" + ) + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_HEADER_FORWARDED) + + def test_host_header_forwarded_double(self): + """Test with double `Forwarded` header (invalid/valid).""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + ( + "GET http://user@tempesta-tech.com/ HTTP/1.1\r\n" + "Host: tempesta-tech.com\r\n" + "Forwarded: host=tempesta1-tech.com\r\n" + "Forwarded: host=tempesta-tech.com\r\n\r\n" + ) + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_HEADER_FORWARDED) + + def test_host_header_no_port_in_uri(self): + """Test with default port in uri.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + "GET http://tempesta-tech.com/ HTTP/1.1\r\nHost: tempesta-tech.com:80\r\n\r\n" + ], + ) + self.check_response(client, status_code="200", warning_msg=WARN_DIFFER) + + def test_host_header_no_port_in_host(self): + """Test with default port in `Host` header.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + "GET http://tempesta-tech.com:80/ HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n" + ], + ) + self.check_response(client, status_code="200", warning_msg=WARN_DIFFER) + + def test_host_header_mismath_port_in_host(self): + """Test with mismatch port in `Host` header.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + "GET http://tempesta-tech.com:81/ HTTP/1.1\r\nHost: tempesta-tech.com:80\r\n\r\n" + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_DIFFER) + + def test_host_header_mismath_port(self): + """Test with mismatch port in `Host` header.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=[ + "GET http://tempesta-tech.com:81/ HTTP/1.1\r\nHost: tempesta-tech.com:81\r\n\r\n" + ], + ) + self.check_response( + client, status_code="403", warning_msg="port from host header doesn't match real port" + ) + + def test_host_header_as_ip(self): + """Test with header `host` as ip address.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=["GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=WARN_IP_ADDR) + + def test_host_header_as_ip6(self): + """Test with header `host` as ip v6 address.""" + client = self.base_scenario( + frang_config="http_host_required true;", + requests=["GET / HTTP/1.1\r\nHost: [20:11:abb::1]:80\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=WARN_IP_ADDR) + + def test_disabled_host_http_required(self): + """Test disable `http_host_required`.""" + client = self.base_scenario( + frang_config="http_host_required false;", requests=["GET / HTTP/1.1\r\n\r\n"] + ) + self.check_response(client, status_code="200", warning_msg="frang: ") + + def test_default_host_http_required(self): + """Test default (true) `http_host_required`.""" + client = self.base_scenario( + frang_config="", requests=["GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"] + ) + self.check_response(client, status_code="403", warning_msg=WARN_IP_ADDR) + + +class FrangHostRequiredH2TestCase(FrangTestCase): + """Tests for checks 'http_host_required' directive with http2.""" + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy_h2", + "addr": "${tempesta_ip}", + "port": "443", + "ssl": True, + }, + ] + + tempesta_template = { + "config": """ +frang_limits { + %(frang_config)s + ip_block off; +} + +listen 443 proto=h2; +server ${server_ip}:8000; + +tls_match_any_server_name; +tls_certificate ${tempesta_workdir}/tempesta.crt; +tls_certificate_key ${tempesta_workdir}/tempesta.key; + +cache 0; +cache_fulfill * *; +block_action attack reply; +block_action error reply; +""", + } + + def test_h2_header_ok(self): + """Test with header `host`, success.""" + self.set_frang_config(frang_config="http_host_required true;") + client = self.get_client("deproxy-1") + client.start() + client.parsing = False + + header_list = [ + [(":authority", "localhost"), (":path", "/")], + [(":path", "/"), ("host", "localhost")], + [(":authority", "localhost"), (":path", "/"), ("host", "localhost")], + [ + (":authority", "tempesta-tech.com"), + (":path", "/"), + ("forwarded", "host=tempesta-tech.com"), + ("forwarded", "for=tempesta.com"), + ], + ] + for header in header_list: + head = [ + (":scheme", "https"), + (":method", "HEAD"), + ] + head.extend(header) + client.make_request(head) + self.assertTrue(client.wait_for_response(1)) + + self.check_response(client, status_code="200", warning_msg="frang: ") + + def test_h2_empty_host_header(self): + """Test with empty header `host`.""" + self._test( + headers=[ + (":path", "/"), + ("host", ""), + ], + expected_warning=WARN_UNKNOWN, + ) + + def test_h2_empty_authority_header(self): + """Test with header `authority`.""" + self._test( + headers=[ + (":path", "/"), + (":authority", ""), + ], + expected_warning=WARN_UNKNOWN, + ) + + def test_h2_host_and_authority_headers_missing(self): + """Test with missing header `host`.""" + self._test( + headers=[ + (":path", "/"), + ], + expected_warning="frang: Request authority is unknown for", + ) + + def test_h2_host_header_as_ip(self): + """Test with header `host` as ip address.""" + self._test( + headers=[ + (":path", "/"), + ("host", "127.0.0.1"), + ], + expected_warning=WARN_IP_ADDR, + ) + + def test_h2_authority_header_as_ip(self): + """Test with header `host` as ip address.""" + self._test( + headers=[ + (":path", "/"), + (":authority", "127.0.0.1"), + ], + expected_warning=WARN_IP_ADDR, + ) + + def test_h2_host_header_as_ipv6(self): + """Test with header `host` as ip v6 address.""" + self._test( + headers=[ + (":path", "/"), + ("host", "[20:11:abb::1]:443"), + ], + expected_warning=WARN_IP_ADDR, + ) + + def test_h2_authority_header_as_ipv6(self): + """Test with header `host` as ip v6 address.""" + self._test( + headers=[ + (":path", "/"), + (":authority", "[20:11:abb::1]:443"), + ], + expected_warning=WARN_IP_ADDR, + ) + + def test_h2_missmatch_forwarded_header(self): + """Test with missmath header `forwarded`.""" + self._test( + headers=[(":path", "/"), (":authority", "localhost"), ("forwarded", "host=qwerty")], + expected_warning=WARN_HEADER_FORWARDED2, + ) + + def test_h2_double_different_forwarded_headers(self): + """Test with double header `forwarded`.""" + self._test( + [ + (":path", "/"), + (":authority", "tempesta-tech.com"), + ("forwarded", "host=tempesta.com"), + ("forwarded", "host=tempesta-tech.com"), + ], + expected_warning=WARN_HEADER_FORWARDED2, + ) + + def test_h2_different_host_and_authority_header(self): + self._test( + headers=[(":path", "/"), (":authority", "localhost"), ("host", "host")], + expected_warning="frang: Request authority differs between headers for", + ) + + def _test( + self, + headers: list, + expected_warning: str = WARN_UNKNOWN, + ): + """ + Test base scenario for process different requests. + """ + head = [ + (":scheme", "https"), + (":method", "GET"), + ] + head.extend(headers) + + client = self.base_scenario(frang_config="http_host_required true;", requests=[head]) + self.check_response(client, status_code="403", warning_msg=expected_warning) + + def test_disabled_host_http_required(self): + client = self.base_scenario( + frang_config="http_host_required false;", + requests=[ + [ + (":scheme", "https"), + (":method", "GET"), + (":path", "/"), + (":authority", "localhost"), + ("host", "host"), + ], + ], + ) + self.check_response(client, status_code="200", warning_msg="frang: ") diff --git a/t_frang/test_http_body_and_header_chunk_cnt.py b/t_frang/test_http_body_and_header_chunk_cnt.py new file mode 100644 index 000000000..176e1b2f5 --- /dev/null +++ b/t_frang/test_http_body_and_header_chunk_cnt.py @@ -0,0 +1,59 @@ +"""Functional tests for `http_header_chunk_cnt` and `http_body_chunk_cnt` directive""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +from t_frang.frang_test_case import FrangTestCase + + +class HttpHeaderChunkCnt(FrangTestCase): + error = "Warning: frang: HTTP header chunk count exceeded" + + requests = [ + "POST / HTTP/1.1\r\n", + "Host: localhost\r\n", + "Content-type: text/plain\r\n" "Content-Length: 0\r\n\r\n", + ] + + def test_header_chunk_cnt_ok(self): + """Set up `http_header_chunk_cnt 3;` and make request with 3 header chunk""" + client = self.base_scenario(frang_config="http_header_chunk_cnt 3;", requests=self.requests) + self.check_response(client, "200", self.error) + + def test_header_chunk_cnt_ok_2(self): + """Set up `http_header_chunk_cnt 5;` and make request with 3 header chunk""" + client = self.base_scenario(frang_config="http_header_chunk_cnt 5;", requests=self.requests) + self.check_response(client, "200", self.error) + + def test_header_chunk_cnt_invalid(self): + """Set up `http_header_chunk_cnt 2;` and make request with 3 header chunk""" + client = self.base_scenario(frang_config="http_header_chunk_cnt 2;", requests=self.requests) + self.check_response(client, "403", self.error) + + +class HttpBodyChunkCnt(FrangTestCase): + error = "Warning: frang: HTTP body chunk count exceeded" + + requests = [ + "POST / HTTP/1.1\r\nHost: debian\r\nContent-type: text/plain\r\nContent-Length: 4\r\n\r\n", + "1", + "2", + "3", + "4", + ] + + def test_body_chunk_cnt_ok(self): + """Set up `http_body_chunk_cnt 4;` and make request with 4 body chunk""" + client = self.base_scenario(frang_config="http_body_chunk_cnt 4;", requests=self.requests) + self.check_response(client, "200", self.error) + + def test_body_chunk_cnt_ok_2(self): + """Set up `http_body_chunk_cnt 10;` and make request with 4 body chunk""" + client = self.base_scenario(frang_config="http_body_chunk_cnt 10;", requests=self.requests) + self.check_response(client, "200", self.error) + + def test_body_chunk_cnt_invalid(self): + """Set up `http_body_chunk_cnt 3;` and make request with 4 body chunk""" + client = self.base_scenario(frang_config="http_body_chunk_cnt 3;", requests=self.requests) + self.check_response(client, "403", self.error) diff --git a/t_frang/test_http_ct_required.py b/t_frang/test_http_ct_required.py new file mode 100644 index 000000000..9ec6e2b04 --- /dev/null +++ b/t_frang/test_http_ct_required.py @@ -0,0 +1,38 @@ +"""Tests for Frang directive `http_ct_required`.""" +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + + +class FrangHttpCtRequiredTestCase(FrangTestCase): + error = "frang: Content-Type header field for" + + def test_content_type_set_ok(self): + """Test with valid header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_required true;", + requests=[ + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n", + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type:\r\n\r\n", + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: invalid\r\n\r\n", + ], + ) + self.check_response(client, status_code="200", warning_msg=self.error) + + def test_missing_content_type(self): + """Test with missing header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_required true;", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=self.error) + + def test_default_http_ct_required(self): + """Test with default (false) http_ct_required directive.""" + client = self.base_scenario( + frang_config="", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\n\r\n"], + ) + self.check_response(client, status_code="200", warning_msg=self.error) diff --git a/t_frang/test_http_ct_vals.py b/t_frang/test_http_ct_vals.py new file mode 100644 index 000000000..37dafb864 --- /dev/null +++ b/t_frang/test_http_ct_vals.py @@ -0,0 +1,69 @@ +"""Tests for Frang directive `http_ct_vals`.""" +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + + +class FrangHttpCtValsTestCase(FrangTestCase): + error = "frang: restricted Content-Type for" + + def test_content_vals_set_ok(self): + """Test with valid header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_vals text/html;", + requests=[ + ( + "POST / HTTP/1.1\r\nHost: localhost\r\n" + "Content-Type: text/html; charset=ISO-8859-4\r\n\r\n" + ), + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n", + ], + ) + self.check_response(client, status_code="200", warning_msg=self.error) + + def test_content_vals_set_ok_conf2(self): + """Test with valid header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_vals text/html text/plain;", + requests=[ + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n", + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\n\r\n", + ], + ) + self.check_response(client, status_code="200", warning_msg=self.error) + + def test_error_content_type(self): + """Test with invalid header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_vals text/html;", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=self.error) + + def test_error_content_type2(self): + """Test with http_ct_vals text/*.""" + client = self.base_scenario( + frang_config="http_ct_vals text/*;", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=self.error) + + def test_missing_content_type(self): + """Test with missing header `Content-type`.""" + client = self.base_scenario( + frang_config="http_ct_vals text/html;", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\n\r\n"], + ) + self.check_response( + client, status_code="403", warning_msg="frang: Content-Type header field for" + ) + + def test_default_http_ct_vals(self): + """Test with default (disabled) http_ct_vals directive.""" + client = self.base_scenario( + frang_config="", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/html\r\n\r\n"], + ) + self.check_response(client, status_code="200", warning_msg=self.error) diff --git a/t_frang/test_http_method_override_allowed.py b/t_frang/test_http_method_override_allowed.py new file mode 100644 index 000000000..b72df5620 --- /dev/null +++ b/t_frang/test_http_method_override_allowed.py @@ -0,0 +1,155 @@ +"""Tests for Frang directive `http_method_override_allowed`.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +import time + +from t_frang.frang_test_case import FrangTestCase + +WARN = "frang: restricted HTTP method" +WARN_ERROR = "frang: restricted overridden HTTP method" +WARN_UNSAFE = "request dropped: unsafe method override:" + +ACCEPTED_REQUESTS = """ +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-HTTP-Method-Override: PUT\r +\r +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-Method-Override: PUT\r +\r +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-HTTP-Method: PUT\r +\r +""" + +REQUEST_FALSE_OVERRIDE = """ +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-HTTP-Method-Override: POST\r +X-Method-Override: POST\r +X-HTTP-Method: POST\r +\r +""" + +DOUBLE_OVERRIDE = """ +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-HTTP-Method-Override: PUT\r +Http-Method: GET\r +\r +""" + +MULTIPLE_OVERRIDE = """ +POST / HTTP/1.1\r +Host: tempesta-tech.com\r +X-HTTP-Method-Override: GET\r +X-HTTP-Method-Override: PUT\r +X-HTTP-Method-Override: GET\r +X-HTTP-Method: GET\r +X-HTTP-Method-Override: PUT\r +X-Method-Override: GET\r +\r +""" + + +class FrangHttpMethodsOverrideTestCase(FrangTestCase): + def test_accepted_request(self): + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=[ + ACCEPTED_REQUESTS, + REQUEST_FALSE_OVERRIDE, + DOUBLE_OVERRIDE, + MULTIPLE_OVERRIDE, + ], + ) + self.check_response(client, status_code="200", warning_msg="frang: ") + + def test_not_accepted_request_x_http_method_override(self): + """ + override methods not allowed by limit http_methods + for X_HTTP_METHOD_OVERRIDE + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=[ + "POST / HTTP/1.1\r\nHost: localhost\r\nX-HTTP-Method-Override: OPTIONS\r\n\r\n" + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_ERROR) + + def test_not_accepted_request_x_method_override(self): + """ + override methods not allowed by limit http_methods + for X_METHOD_OVERRIDE + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=["POST / HTTP/1.1\r\nHost: localhost\r\nX-Method-Override: OPTIONS\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=WARN_ERROR) + + def test_not_accepted_request_x_http_method(self): + """ + override methods not allowed by limit http_methods + for X_HTTP_METHOD + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=[ + "POST / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-HTTP-Method: OPTIONS\r\n\r\n" + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_ERROR) + + def test_unsafe_override_x_http_method_override(self): + """ + should not be allowed to be overridden by unsafe methods + for X-HTTP-Method-Override + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=[ + "GET / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-HTTP-Method-Override: POST\r\n\r\n" + ], + ) + self.check_response(client, status_code="400", warning_msg=WARN_UNSAFE) + + def test_unsafe_override_x_http_method(self): + """ + should not be allowed to be overridden by unsafe methods + for X-HTTP-Method + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=["GET / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-HTTP-Method: POST\r\n\r\n"], + ) + self.check_response(client, status_code="400", warning_msg=WARN_UNSAFE) + + def test_unsafe_override_x_method_override(self): + """ + should not be allowed to be overridden by unsafe methods + for X-Method-Override + """ + client = self.base_scenario( + frang_config="http_method_override_allowed true;\n\thttp_methods post put get;", + requests=[ + "GET / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-Method-Override: POST\r\n\r\n" + ], + ) + self.check_response(client, status_code="400", warning_msg=WARN_UNSAFE) + + def test_default_http_method_override_allowed(self): + """Test default `http_method_override_allowed` value.""" + client = self.base_scenario( + frang_config="http_methods post put get;", + requests=[ + "POST / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-Method-Override: PUT\r\n\r\n" + ], + ) + self.check_response(client, status_code="403", warning_msg=WARN_ERROR) diff --git a/t_frang/test_http_methods.py b/t_frang/test_http_methods.py new file mode 100644 index 000000000..ca56c1d20 --- /dev/null +++ b/t_frang/test_http_methods.py @@ -0,0 +1,51 @@ +"""Tests for Frang directive `http_methods`.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +from t_frang.frang_test_case import FrangTestCase + + +class FrangHttpMethodsTestCase(FrangTestCase): + error = "frang: restricted HTTP method" + + def test_accepted_request(self): + client = self.base_scenario( + frang_config="http_methods get post;", + requests=[ + "GET / HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n", + "POST / HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n", + ], + ) + self.check_response(client, status_code="200", warning_msg=self.error) + + def test_not_accepted_request(self): + client = self.base_scenario( + frang_config="http_methods get post;", + requests=["DELETE / HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=self.error) + + def test_not_accepted_request_register(self): + client = self.base_scenario( + frang_config="http_methods get post;", + requests=["gEt / HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n"], + ) + self.check_response(client, status_code="403", warning_msg=self.error) + + def test_not_accepted_request_zero_byte(self): + client = self.base_scenario( + frang_config="http_methods get post;", + requests=["x0 POST / HTTP/1.1\r\nHost: tempesta-tech.com\r\n\r\n"], + ) + self.check_response(client, status_code="400", warning_msg="Parser error:") + + def test_not_accepted_request_owerride(self): + client = self.base_scenario( + frang_config="http_methods get post;", + requests=[ + "PUT / HTTP/1.1\r\nHost: tempesta-tech.com\r\nX-HTTP-Method-Override: GET\r\n\r\n" + ], + ) + self.check_response(client, status_code="403", warning_msg=self.error) diff --git a/t_frang/test_http_resp_code_block.py b/t_frang/test_http_resp_code_block.py new file mode 100644 index 000000000..b0782bcd5 --- /dev/null +++ b/t_frang/test_http_resp_code_block.py @@ -0,0 +1,275 @@ +""" +Functional tests for http_resp_code_block. +If your web application works with user accounts, then typically it requires +a user authentication. If you implement the user authentication on your web +site, then an attacker may try to use a brute-force password cracker to get +access to accounts of your users. The second case is much harder to detect. +It's worth mentioning that unsuccessful authorization requests typically +produce error HTTP responses. + +Tempesta FW provides http_resp_code_block for efficient blocking +of all types of password crackers +""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +import time + +from t_frang.frang_test_case import FrangTestCase + +NGINX_CONFIG = { + "id": "nginx", + "type": "nginx", + "status_uri": "http://${server_ip}:8000/nginx_status", + "config": """ +pid ${pid}; +worker_processes auto; + +events { + worker_connections 1024; + use epoll; +} + +http { + keepalive_timeout ${server_keepalive_timeout}; + keepalive_requests 10; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + open_file_cache max=1000; + open_file_cache_valid 30s; + open_file_cache_min_uses 2; + open_file_cache_errors off; + + # [ debug | info | notice | warn | error | crit | alert | emerg ] + # Fully disable log errors. + error_log /dev/null emerg; + + # Disable access log altogether. + access_log off; + + server { + listen ${server_ip}:8000; + + location /uri1 { + return 404; + } + location /uri2 { + return 200; + } + location /uri3 { + return 405; + } + location /nginx_status { + stub_status on; + } + } +} +""", +} + + +class HttpRespCodeBlockOneClient(FrangTestCase): + backends = [NGINX_CONFIG] + + request_200 = "GET /uri2 HTTP/1.1\r\nHost: localhost\r\n\r\n" + request_404 = "GET /uri1 HTTP/1.1\r\nHost: localhost\r\n\r\n" + request_405 = "GET /uri3 HTTP/1.1\r\nHost: localhost\r\n\r\n" + + warning = "frang: http_resp_code_block limit exceeded for" + + def test_not_reaching_the_limit(self): + client = self.get_client("deproxy-1") + + self.set_frang_config("http_resp_code_block 404 405 6 2;") + client.start() + + for rps, requests in [ + (3, self.request_404 * 7), + (10, self.request_200 * 10), + ]: + with self.subTest(): + client.set_rps(rps) + client.make_requests(requests) + client.wait_for_response() + + time.sleep(self.timeout) + + self.assertFalse(client.connection_is_closed()) + self.assertFrangWarning(warning=self.warning, expected=0) + + def test_reaching_the_limit(self): + """ + Client send 7 requests. It receives 3 404 responses and 4 404 responses. + Client will be blocked. + """ + self.set_frang_config("http_resp_code_block 404 405 6 2;") + + client = self.get_client("deproxy-1") + client.start() + client.make_requests(self.request_405 * 3 + self.request_404 * 4) + client.wait_for_response() + + time.sleep(self.timeout) + + self.assertTrue(client.connection_is_closed()) + self.assertFrangWarning(warning=self.warning, expected=1) + + def test_reaching_the_limit_2(self): + """ + Client send irregular chain of 404, 405 and 200 requests with 5 rps. + 8 requests: [ '200', '404', '404', '404', '404', '200', '405', '405']. + Client will be blocked. + """ + self.set_frang_config("http_resp_code_block 404 405 5 2;") + + client = self.get_client("deproxy-1") + client.start() + client.make_requests( + self.request_200 + self.request_404 * 4 + self.request_200 + self.request_405 * 2 + ) + client.wait_for_response() + + time.sleep(self.timeout) + + self.assertTrue(client.connection_is_closed()) + self.assertFrangWarning(warning=self.warning, expected=1) + + +class HttpRespCodeBlock(FrangTestCase): + """ + Blocks an attacker's IP address if a protected web application return + 5 error responses with codes 404 or 405 within 2 seconds. This is 2,5 per second. + """ + + clients = [ + { + "id": "deproxy", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + "rps": 6, + }, + { + "id": "deproxy2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + "rps": 5, + }, + { + "id": "deproxy3", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "rps": 5, + }, + { + "id": "deproxy4", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "rps": 5, + }, + ] + + backends = [NGINX_CONFIG] + + warning = "frang: http_resp_code_block limit exceeded for" + + tempesta = { + "config": """ +server ${server_ip}:8000; + +frang_limits { + http_resp_code_block 404 405 5 2; + ip_block on; +} + +""", + } + + def test_two_clients_one_ip(self): + """ + Two clients to be blocked by ip for a total of 404 requests + """ + requests = "GET /uri1 HTTP/1.1\r\nHost: localhost\r\n\r\n" * 10 + requests2 = "GET /uri2 HTTP/1.1\r\nHost: localhost\r\n\r\n" * 10 + self.start_all_services(client=False) + + deproxy_cl = self.get_client("deproxy3") + deproxy_cl.start() + + deproxy_cl2 = self.get_client("deproxy4") + deproxy_cl2.start() + + deproxy_cl.make_requests(requests) + deproxy_cl.wait_for_response(timeout=4) + + deproxy_cl2.make_requests(requests2) + deproxy_cl2.wait_for_response(timeout=6) + + self.assertEqual(5, len(deproxy_cl.responses)) + self.assertEqual(0, len(deproxy_cl2.responses)) + + time.sleep(self.timeout) + + self.assertTrue(deproxy_cl.connection_is_closed()) + self.assertTrue(deproxy_cl2.connection_is_closed()) + + self.assertFrangWarning(warning=self.warning, expected=1) + + def test_two_clients_two_ip(self): + """ + Two clients. One client sends 12 requests by 6 per second during + 2 seconds. Of these, 6 requests by 3 per second give 404 responses and + should be blocked after 10 responses (5 with code 200 and 5 with code 404). + The second client sends 20 requests by 5 per second during 4 seconds. + Of these, 10 requests by 2.5 per second give 404 responses and should not be + blocked. + """ + + requests = ( + "GET /uri1 HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n" + "GET /uri2 HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n" * 6 + ) + requests2 = ( + "GET /uri1 HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n" + "GET /uri2 HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n" * 10 + ) + self.start_all_services(client=False) + + deproxy_cl = self.get_client("deproxy") + deproxy_cl.start() + + deproxy_cl2 = self.get_client("deproxy2") + deproxy_cl2.start() + + deproxy_cl.make_requests(requests) + deproxy_cl2.make_requests(requests2) + + deproxy_cl.wait_for_response(timeout=4) + deproxy_cl2.wait_for_response(timeout=6) + + self.assertEqual(10, len(deproxy_cl.responses)) + self.assertEqual(20, len(deproxy_cl2.responses)) + + time.sleep(self.timeout) + + self.assertTrue(deproxy_cl.connection_is_closed()) + self.assertFalse(deproxy_cl2.connection_is_closed()) + + self.assertFrangWarning(warning=self.warning, expected=1) diff --git a/t_frang/test_http_trailer_split_allowed.py b/t_frang/test_http_trailer_split_allowed.py new file mode 100644 index 000000000..332c6947a --- /dev/null +++ b/t_frang/test_http_trailer_split_allowed.py @@ -0,0 +1,58 @@ +"""Tests for Frang directive `http_trailer_split_allowed`.""" + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +from t_frang.frang_test_case import FrangTestCase + +WARN = "frang: HTTP field appear in header and trailer" + +REQUEST_WITH_TRAILER = ( + "GET / HTTP/1.1\r\n" + "Host: debian\r\n" + "HdrTest: testVal\r\n" + "Transfer-Encoding: gzip, chunked\r\n" + "\r\n" + "4\r\n" + "test\r\n" + "0\r\n" + "HdrTest: testVal\r\n" + "\r\n" +) + + +class FrangHttpTrailerSplitLimitOnTestCase(FrangTestCase): + def test_accepted_request(self): + client = self.base_scenario( + frang_config="http_trailer_split_allowed true;", + requests=[ + REQUEST_WITH_TRAILER, + ( + "GET / HTTP/1.1\r\n" + "Host: debian\r\n" + "HdrTest: testVal\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "4\r\n" + "test\r\n" + "0\r\n" + "\r\n" + ), + "POST / HTTP/1.1\r\nHost: debian\r\nHdrTest: testVal\r\n\r\n", + ], + ) + self.check_response(client, status_code="200", warning_msg=WARN) + + def test_disable_trailer_split_allowed(self): + """Test with disable `http_trailer_split_allowed` directive.""" + client = self.base_scenario( + frang_config="http_trailer_split_allowed false;", + requests=["POST / HTTP/1.1\r\nHost: debian\r\nHdrTest: testVal\r\n\r\n"], + ) + self.check_response(client, status_code="200", warning_msg=WARN) + + def test_default_trailer_split_allowed(self): + """Test with default (false) `http_trailer_split_allowed` directive.""" + client = self.base_scenario(frang_config="", requests=[REQUEST_WITH_TRAILER]) + self.check_response(client, status_code="403", warning_msg=WARN) diff --git a/t_frang/test_ip_block.py b/t_frang/test_ip_block.py new file mode 100644 index 000000000..e115c413a --- /dev/null +++ b/t_frang/test_ip_block.py @@ -0,0 +1,239 @@ +"""Tests for Frang directive `ip_block`.""" +import time + +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + + +class FrangIpBlockBase(FrangTestCase, base=True): + """Base class for tests with 'ip_block' directive.""" + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-interface-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + { + "id": "deproxy-interface-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + ] + + def get_responses(self, client_1, client_2): + self.start_all_services(client=False) + + client_1.start() + client_2.start() + + client_1.make_request("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + client_1.wait_for_response(1) + + client_2.make_request("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + client_2.wait_for_response(1) + + +class FrangIpBlockMessageLimits(FrangIpBlockBase): + """ + For `http_host_required true` and `block_action attack reply`: + Create two client connections, then send invalid and valid requests and receive: + - for different ip and ip_block on - RST and 200 response; + - for single ip and ip_block on - RST and RST; + - for single or different ip and ip_block off - 403 and 200 responses; + """ + + tempesta = { + "config": """ +frang_limits { + http_host_required true; + ip_block on; +} +listen 80; +server ${server_ip}:8000; +block_action attack reply; +""", + } + + def test_two_clients_two_ip_with_ip_block_on(self): + client_1 = self.get_client("deproxy-interface-1") + client_2 = self.get_client("deproxy-interface-2") + + self.get_responses(client_1, client_2) + + self.assertIsNone(client_1.last_response) + self.assertIsNotNone(client_2.last_response) + self.assertEqual(client_2.last_response.status, "200") + + time.sleep(self.timeout) + + self.assertTrue(client_1.connection_is_closed()) + self.assertFalse(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=1) + self.assertFrangWarning(warning="frang: Host header field contains IP address", expected=1) + + def test_two_clients_one_ip_with_ip_block_on(self): + client_1 = self.get_client("deproxy-1") + client_2 = self.get_client("deproxy-2") + + self.get_responses(client_1, client_2) + + self.assertIsNone(client_1.last_response) + self.assertIsNone(client_2.last_response) + + time.sleep(self.timeout) + + self.assertTrue(client_1.connection_is_closed()) + self.assertTrue(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=1) + self.assertFrangWarning(warning="frang: Host header field contains IP address", expected=1) + + def test_two_client_one_ip_with_ip_block_off(self): + self.tempesta = { + "config": """ +frang_limits { + http_host_required true; + ip_block off; +} + +listen 80; + +server ${server_ip}:8000; + +block_action attack reply; +""", + } + self.setUp() + + client_1 = self.get_client("deproxy-1") + client_2 = self.get_client("deproxy-2") + + self.get_responses(client_1, client_2) + + self.assertIsNotNone(client_1.last_response) + self.assertIsNotNone(client_2.last_response) + + self.assertEqual(client_1.last_response.status, "403") + self.assertEqual(client_2.last_response.status, "200") + + time.sleep(self.timeout) + + self.assertTrue(client_1.connection_is_closed()) + self.assertFalse(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=0) + self.assertFrangWarning(warning="frang: Host header field contains IP address", expected=1) + + +class FrangIpBlockConnectionLimits(FrangIpBlockBase): + """ + For `connection_rate 1` and `block_action attack reply`. + Create two client connections, send valid requests and receive: + - for different ip and ip_block on or off - 200 and 200 response; + - for single ip and ip_block on - RST and RST; + - for single ip and ip_block off - 200 response and RST; + """ + + tempesta = { + "config": """ +frang_limits { + http_host_required false; + connection_rate 1; + ip_block on; +} +listen 80; +server ${server_ip}:8000; +block_action attack reply; +""", + } + + def test_two_clients_two_ip_with_ip_block_on(self): + client_1 = self.get_client("deproxy-interface-1") + client_2 = self.get_client("deproxy-interface-2") + + self.get_responses(client_1, client_2) + + self.assertIsNotNone(client_1.last_response) + self.assertIsNotNone(client_2.last_response) + + self.assertEqual(client_2.last_response.status, "200") + self.assertEqual(client_2.last_response.status, "200") + + time.sleep(self.timeout) + + self.assertFalse(client_1.connection_is_closed()) + self.assertFalse(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=0) + self.assertFrangWarning(warning="frang: new connections rate exceeded for", expected=0) + + def test_two_clients_one_ip_with_ip_block_on(self): + client_1 = self.get_client("deproxy-1") + client_2 = self.get_client("deproxy-2") + + self.get_responses(client_1, client_2) + + self.assertIsNone(client_1.last_response) + self.assertIsNone(client_2.last_response) + + time.sleep(self.timeout) + + self.assertTrue(client_1.connection_is_closed()) + self.assertTrue(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=1) + self.assertFrangWarning(warning="frang: new connections rate exceeded for", expected=1) + + def test_two_clients_one_ip_with_ip_block_off(self): + self.tempesta = { + "config": """ +frang_limits { + http_host_required false; + connection_rate 1; + ip_block off; +} +listen 80; +server ${server_ip}:8000; +block_action attack reply; +""", + } + self.setUp() + + client_1 = self.get_client("deproxy-1") + client_2 = self.get_client("deproxy-2") + + self.get_responses(client_1, client_2) + + self.assertIsNotNone(client_1.last_response) + self.assertIsNone(client_2.last_response) + + self.assertEqual(client_1.last_response.status, "200") + + time.sleep(self.timeout) + + self.assertFalse(client_1.connection_is_closed()) + self.assertTrue(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=0) + self.assertFrangWarning(warning="frang: new connections rate exceeded for", expected=1) diff --git a/t_frang/test_length.py b/t_frang/test_length.py new file mode 100644 index 000000000..a14aa3e63 --- /dev/null +++ b/t_frang/test_length.py @@ -0,0 +1,148 @@ +"""Tests for Frang length related directives.""" +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + + +class FrangLengthTestCase(FrangTestCase): + """Tests for length related directives.""" + + def test_uri_len(self): + """ + Test 'http_uri_len'. + + Set up `http_uri_len 5;` and make request with uri greater length + + """ + client = self.base_scenario( + frang_config="http_uri_len 5;", + requests=["POST /123456789 HTTP/1.1\r\nHost: localhost\r\n\r\n"], + ) + self.check_response( + client, status_code="403", warning_msg="frang: HTTP URI length exceeded for" + ) + + def test_uri_len_without_reaching_the_limit(self): + """ + Test 'http_uri_len'. + + Set up `http_uri_len 5;` and make request with uri 1 length + + """ + client = self.base_scenario( + frang_config="http_uri_len 5;", requests=["POST / HTTP/1.1\r\nHost: localhost\r\n\r\n"] + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP URI length exceeded for" + ) + + def test_uri_len_on_the_limit(self): + """ + Test 'http_uri_len'. + + Set up `http_uri_len 5;` and make request with uri 5 length + + """ + client = self.base_scenario( + frang_config="http_uri_len 5;", + requests=["POST /1234 HTTP/1.1\r\nHost: localhost\r\n\r\n"], + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP URI length exceeded for" + ) + + def test_field_len(self): + """ + Test 'http_field_len'. + + Set up `http_field_len 300;` and make request with header greater length + + """ + client = self.base_scenario( + frang_config="http_field_len 300;", + requests=[f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nX-Long: {'1' * 320}\r\n\r\n"], + ) + self.check_response( + client, status_code="403", warning_msg="frang: HTTP field length exceeded for" + ) + + def test_field_without_reaching_the_limit(self): + """ + Test 'http_field_len'. + + Set up `http_field_len 300; and make request with header 200 length + + """ + client = self.base_scenario( + frang_config="http_field_len 300;", + requests=[f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nX-Long: {'1' * 200}\r\n\r\n"], + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP field length exceeded for" + ) + + def test_field_without_reaching_the_limit_2(self): + """ + Test 'http_field_len'. + + Set up `http_field_len 300; and make request with header 300 length + + """ + client = self.base_scenario( + frang_config="http_field_len 300;", + requests=[f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nX-Long: {'1' * 292}\r\n\r\n"], + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP field length exceeded for" + ) + + def test_body_len(self): + """ + Test 'http_body_len'. + + Set up `http_body_len 10;` and make request with body greater length + + """ + client = self.base_scenario( + frang_config="http_body_len 10;", + requests=[ + f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 20\r\n\r\n{'x' * 20}" + ], + ) + self.check_response( + client, status_code="403", warning_msg="frang: HTTP body length exceeded for" + ) + + def test_body_len_without_reaching_the_limit_zero_len(self): + """ + Test 'http_body_len'. + + Set up `http_body_len 10;` and make request with body 0 length + + """ + client = self.base_scenario( + frang_config="http_body_len 10;", + requests=[f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"], + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP body length exceeded for" + ) + + def test_body_len_without_reaching_the_limit(self): + """ + Test 'http_body_len'. + + Set up `http_body_len 10;` and make request with body shorter length + + """ + client = self.base_scenario( + frang_config="http_body_len 10;", + requests=[ + f"POST /1234 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\n{'x' * 10}" + ], + ) + self.check_response( + client, status_code="200", warning_msg="frang: HTTP body length exceeded for" + ) diff --git a/t_frang/test_request_rate_burst.py b/t_frang/test_request_rate_burst.py new file mode 100644 index 000000000..365727074 --- /dev/null +++ b/t_frang/test_request_rate_burst.py @@ -0,0 +1,233 @@ +"""Tests for Frang directive `request_rate` and 'request_burst'.""" +import time + +from t_frang.frang_test_case import DELAY, FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +ERROR_MSG_RATE = "Warning: frang: request rate exceeded" +ERROR_MSG_BURST = "Warning: frang: requests burst exceeded" + + +class FrangRequestRateTestCase(FrangTestCase): + """Tests for 'request_rate' directive.""" + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "deproxy-interface-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + { + "id": "deproxy-interface-2", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + "interface": True, + }, + ] + + tempesta = { + "config": """ +frang_limits { + request_rate 4; + ip_block on; +} +listen 80; +server ${server_ip}:8000; +block_action attack reply; +""", + } + + request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + + error_msg = ERROR_MSG_RATE + + def get_responses(self, client_1, client_2, rps_1: int, rps_2: int, request_cnt: int): + self.start_all_services(client=False) + + client_1.set_rps(rps_1) + client_2.set_rps(rps_2) + + client_1.start() + client_2.start() + + for _ in range(request_cnt): + client_1.make_request(self.request) + client_2.make_request(self.request) + + client_1.wait_for_response(3) + client_2.wait_for_response(3) + + def test_two_clients_two_ip(self): + """ + Set `request_rate 4;` and make requests for two clients with different ip: + - 6 requests for client with 4 rps and receive 6 responses with 200 status; + - 6 requests for client with rps greater than 4 and get ip block; + """ + client_1 = self.get_client("deproxy-interface-1") + client_2 = self.get_client("deproxy-interface-2") + + self.get_responses(client_1, client_2, rps_1=4, rps_2=0, request_cnt=6) + + self.assertFalse(client_1.connection_is_closed()) + self.assertTrue(client_2.connection_is_closed()) + + for response in client_1.responses: + self.assertEqual(response.status, "200") + + self.assertEqual(4, len(client_2.responses)) + + self.assertFrangWarning(warning="Warning: block client:", expected=1) + self.assertFrangWarning(warning=self.error_msg, expected=1) + + def test_two_clients_one_ip(self): + """ + Set `request_rate 4;` and make requests concurrently for two clients with same ip. + Clients will be blocked on 5th request. + """ + client_1 = self.get_client("deproxy-1") + client_2 = self.get_client("deproxy-2") + + self.get_responses(client_1, client_2, rps_1=0, rps_2=0, request_cnt=4) + + self.assertGreater(5, len(client_2.responses) + len(client_1.responses)) + self.assertGreater(len(client_1.responses), 0) + self.assertGreater(len(client_2.responses), 0) + + self.assertTrue(client_1.connection_is_closed()) + self.assertTrue(client_2.connection_is_closed()) + + self.assertFrangWarning(warning="Warning: block client:", expected=1) + self.assertFrangWarning(warning=self.error_msg, expected=1) + + +class FrangRequestBurstTestCase(FrangRequestRateTestCase): + """Tests for and 'request_burst' directive.""" + + tempesta = { + "config": """ +frang_limits { + request_burst 4; + ip_block on; +} +listen 80; +server ${server_ip}:8000; +block_action attack reply; +""", + } + + error_msg = ERROR_MSG_BURST + + +class FrangRequestRateBurstTestCase(FrangTestCase): + """Tests for 'request_rate' and 'request_burst' directive.""" + + tempesta = { + "config": """ +frang_limits { + request_rate 3; + request_burst 2; +} + +listen 80; +server ${server_ip}:8000; +cache 0; +block_action attack reply; +""", + } + + clients = [ + { + "id": "deproxy-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + }, + { + "id": "curl", + "type": "curl", + "headers": { + "Connection": "keep-alive", + "Host": "debian", + }, + "cmd_args": " --verbose", + }, + ] + + rate_warning = ERROR_MSG_RATE + burst_warning = ERROR_MSG_BURST + + def _base_burst_scenario(self, requests: int): + self.start_all_services(client=False) + + client = self.get_client("curl") + client.uri += f"[1-{requests}]" + client.parallel = requests + + client.start() + client.wait_for_finish() + client.stop() + + time.sleep(self.timeout) + + if requests > 2: # burst limit 2 + self.assertFrangWarning(warning=self.burst_warning, expected=1) + else: + self.assertFrangWarning(warning=self.burst_warning, expected=0) + + self.assertFrangWarning(warning=self.rate_warning, expected=0) + + def _base_rate_scenario(self, requests: int): + self.start_all_services() + + client = self.get_client("deproxy-1") + + for step in range(requests): + client.make_request("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + time.sleep(DELAY) + + if requests < 3: # rate limit 3 + self.check_response(client, warning_msg=self.rate_warning, status_code="200") + else: + # rate limit is reached + time.sleep(self.timeout) + self.assertFrangWarning(warning=self.rate_warning, expected=1) + self.assertEqual(client.last_response.status, "403") + self.assertTrue(client.connection_is_closed()) + + self.assertFrangWarning(warning=self.burst_warning, expected=0) + + def test_request_rate_reached(self): + self._base_rate_scenario(requests=4) + + def test_request_rate_without_reaching_the_limit(self): + self._base_rate_scenario(requests=2) + + def test_request_rate_on_the_limit(self): + self._base_rate_scenario(requests=3) + + def test_request_burst_reached(self): + self._base_burst_scenario(requests=3) + + def test_request_burst_not_reached_the_limit(self): + self._base_burst_scenario(requests=1) + + def test_request_burst_on_the_limit(self): + self._base_burst_scenario(requests=2) diff --git a/t_frang/test_tls_incomplete.py b/t_frang/test_tls_incomplete.py new file mode 100644 index 000000000..f1f9a730c --- /dev/null +++ b/t_frang/test_tls_incomplete.py @@ -0,0 +1,79 @@ +"""Tests for Frang directive tls-related.""" +import time + +from t_frang.frang_test_case import FrangTestCase + +__author__ = "Tempesta Technologies, Inc." +__copyright__ = "Copyright (C) 2022 Tempesta Technologies, Inc." +__license__ = "GPL2" + +ERROR_INCOMP_CONN = "Warning: frang: incomplete TLS connections rate exceeded" + + +class FrangTlsIncompleteTestCase(FrangTestCase): + """ + Tests for 'tls_incomplete_connection_rate'. + """ + + clients = [ + { + "id": "curl-1", + "type": "external", + "binary": "curl", + "ssl": False, + "cmd_args": '-If -v https://${tempesta_ip}:443/ -H "Host: tempesta-tech.com:8765"', + } + ] + + tempesta = { + "config": """ + frang_limits { + tls_incomplete_connection_rate 4; + ip_block off; + } + + listen 443 proto=https; + + server ${server_ip}:8000; + + tls_match_any_server_name; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + + cache 0; + block_action attack reply; + """, + } + + def _base_scenario(self, steps): + """ + Create several client connections with fail. + If number of connections is more than 4 they will be blocked. + """ + curl = self.get_client("curl-1") + + self.start_all_services(client=False) + + # tls_incomplete_connection_rate 4; increase to catch limit + for step in range(steps): + curl.run_start() + self.wait_while_busy(curl) + curl.stop() + + time.sleep(self.timeout) + + # until rate limit is reached + if steps <= 4: + self.assertFrangWarning(warning=ERROR_INCOMP_CONN, expected=0) + else: + # rate limit is reached + self.assertFrangWarning(warning=ERROR_INCOMP_CONN, expected=1) + + def test_tls_incomplete_connection_rate(self): + self._base_scenario(steps=5) + + def test_tls_incomplete_connection_rate_without_reaching_the_limit(self): + self._base_scenario(steps=3) + + def test_tls_incomplete_connection_rate_on_the_limit(self): + self._base_scenario(steps=4) diff --git a/tests_disabled.json b/tests_disabled.json index 82967fdae..0e2c7df1f 100644 --- a/tests_disabled.json +++ b/tests_disabled.json @@ -73,14 +73,6 @@ "name" : "flacky_net", "reason" : "Disabled by issue #114" }, - { - "name" : "frang.test_host_required", - "reason" : "Disabled by issue #673" - }, - { - "name" : "frang.test_http_resp_code_block", - "reason" : "Disabled by issue #673" - }, { "name" : "malformed", "reason" : "Disabled by issue #187" @@ -404,6 +396,110 @@ { "name" : "t_stress.test_wordpress.H2WordpressStress", "reason": "Disable by issue #1703 - can cause kernel panic" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangConnectionRateBurstTestCase.test_connection_burst", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_ip_block.FrangIpBlockMessageLimits.test_two_clients_two_ip_with_ip_block_on", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_ip_block.FrangIpBlockMessageLimits.test_two_clients_one_ip_with_ip_block_on", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_ip_block.FrangIpBlockConnectionLimits.test_two_clients_one_ip_with_ip_block_on", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_ip_block.FrangIpBlockConnectionLimits.test_two_clients_one_ip_with_ip_block_off", + "reason": "Disabled by issue #1751 and #1749" + }, + { + "name": "t_frang.test_request_rate_burst.FrangRequestRateTestCase", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_request_rate_burst.FrangRequestBurstTestCase", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangConnectionRateDifferentIp", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangConnectionBurstDifferentIp", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangTlsRateDifferentIp", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangTlsBurstDifferentIp", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangConnectionRateBurstTestCase.test_connection_rate", + "reason": "Disabled by issue #1749" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangTlsRateBurstTestCase.test_connection_rate_on_the_limit", + "reason": "Disabled by issue #1716" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangTlsRateBurstTestCase.test_connection_rate", + "reason": "Disabled by issue #1716" + }, + { + "name": "t_frang.test_connection_rate_burst.FrangConnectionTlsAndNonTlsRateBurst", + "reason": "Disabled by issue #1716" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredTestCase.test_host_header_no_port_in_host", + "reason": "Disabled by issue #1719" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredTestCase.test_host_header_no_port_in_uri", + "reason": "Disabled by issue #1719" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredH2TestCase.test_h2_empty_host_header", + "reason": "Disabled by issue #1630" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredH2TestCase.test_h2_empty_authority_header", + "reason": "Disabled by issue #1630" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredH2TestCase.test_h2_double_different_forwarded_headers", + "reason": "Disabled by issue #1718" + }, + { + "name": "t_frang.test_concurrent_connections.ConcurrentConnections.test_clear_client_connection_stats", + "reason": "Disabled by issue #1740" + }, + { + "name": "t_frang.test_concurrent_connections.ConcurrentConnections.test_three_clients_same_ip", + "reason": "Disabled by issue #1749 and #1751" + }, + { + "name": "t_frang.test_concurrent_connections.ConcurrentConnections.test_three_clients_same_ip_with_block_ip", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_http_resp_code_block.HttpRespCodeBlock.test_two_clients_one_ip", + "reason": "Disabled by issue #1751" + }, + { + "name": "t_frang.test_header_cnt", + "reason": "Disabled by issue #1686" + }, + { + "name": "t_frang.test_host_required.FrangHostRequiredH2TestCase", + "reason": "Disabled by issue #364" } ] }