diff --git a/framework/deproxy_client.py b/framework/deproxy_client.py index 1422c8517..c1f015b31 100644 --- a/framework/deproxy_client.py +++ b/framework/deproxy_client.py @@ -300,10 +300,11 @@ def make_requests(self, requests: list[deproxy.Request | str], pipelined=False) request if isinstance(request, str) else request.msg for request in requests ] + req_buf_len = len(self.request_buffers) self._add_to_request_buffers("".join(requests)) self.valid_req_num += len(requests) - self.nrreq += len(self.request_buffers) + self.nrreq += len(self.request_buffers) - req_buf_len else: for request in requests: self.make_request(request) diff --git a/helpers/deproxy.py b/helpers/deproxy.py index cd26cf932..4ac81f675 100644 --- a/helpers/deproxy.py +++ b/helpers/deproxy.py @@ -681,9 +681,9 @@ def create( request.method = method request.uri = uri request.version = version - request.headers = HeaderCollection( - **{header[0]: header[1] for header in pseudo_headers + headers} - ) + request.headers = HeaderCollection() + for header in pseudo_headers + headers: + request.headers.add(name=header[0], value=header[1]) request.body = body request.build_message() diff --git a/helpers/nginx.py b/helpers/nginx.py index 056800175..ab8de6e83 100644 --- a/helpers/nginx.py +++ b/helpers/nginx.py @@ -95,8 +95,9 @@ def set_workdir(self, workdir): def set_resourse_location(self, location=""): if not location: - location = tf_cfg.cfg.get("Server", "resources") - self.location = "root %s" % location + self.location = "return 200" + else: + self.location = "root %s" % location self.update_config() def set_return_code(self, code=200): diff --git a/http2_general/test_h2_frame.py b/http2_general/test_h2_frame.py index c9d393f67..cfe0ee54d 100644 --- a/http2_general/test_h2_frame.py +++ b/http2_general/test_h2_frame.py @@ -430,7 +430,7 @@ class TestH2FrameEnabledDisabledTsoGroGsoStickyCookie( proxy_pass default; sticky { sticky_sessions; - cookie enforce; + cookie enforce max_misses=0; secret "f00)9eR59*_/22"; } } diff --git a/http2_general/test_h2_headers.py b/http2_general/test_h2_headers.py index 7591297d5..907cabbdf 100644 --- a/http2_general/test_h2_headers.py +++ b/http2_general/test_h2_headers.py @@ -11,7 +11,7 @@ from hyperframe import frame from framework import tester -from framework.parameterize import param, parameterize +from framework.parameterize import param, parameterize, parameterize_class from helpers import tf_cfg from helpers.deproxy import HttpMessage from http2_general.helpers import H2Base @@ -91,6 +91,21 @@ %s """ +DEPROXY_CLIENT_HTTP = { + "id": "deproxy", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", +} + +DEPROXY_CLIENT_H2 = { + "id": "deproxy", + "type": "deproxy_h2", + "addr": "${tempesta_ip}", + "port": "443", + "ssl": True, +} + class HeadersParsing(H2Base): def test_small_header_in_request(self): @@ -151,6 +166,108 @@ def test_long_header_name_in_response(self): client.send_request(self.post_request, status_code) +@parameterize_class( + [ + {"name": "Http", "clients": [DEPROXY_CLIENT_HTTP]}, + {"name": "H2", "clients": [DEPROXY_CLIENT_H2]}, + ] +) +class CookieParsing(tester.TempestaTest): + cookie = {"name": "cname", "value": "123456789"} + + backends = [ + { + "id": "deproxy", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", + } + ] + + tempesta = { + "config": """ +listen 80; +listen 443 proto=h2; + +tls_certificate ${tempesta_workdir}/tempesta.crt; +tls_certificate_key ${tempesta_workdir}/tempesta.key; +tls_match_any_server_name; + +server ${server_ip}:8000; + +block_action attack reply; + +sticky { + cookie enforce; +} +""" + } + + @parameterize.expand( + [ + param(name="single_cookie", cookies="{0}", expected_status_code="200"), + param( + name="many_cookie_first", + cookies="{0}; cookie1=value1; cookie2=value2", + expected_status_code="200", + ), + param( + name="many_cookie_last", + cookies="cookie1=value1; cookie2=value2; {0}", + expected_status_code="200", + ), + param( + name="many_cookie_between", + cookies="cookie1=value1; {0}; cookie2=value2", + expected_status_code="200", + ), + param(name="duplicate_cookie", cookies="{0}; {0}", expected_status_code="500"), + param( + name="many_cookie_and_name_as_substring_other_name_1", + cookies="cookie1__tfw=value1; {0}", + expected_status_code="200", + ), + param( + name="many_cookie_and_name_as_substring_other_name_2", + cookies="__tfwcookie1=value1; {0}", + expected_status_code="200", + ), + param( + name="many_cookie_and_name_as_substring_other_value_1", + cookies="cookie1=value1__tfw; {0}", + expected_status_code="200", + ), + param( + name="many_cookie_and_name_as_substring_other_value_2", + cookies="cookie1=__tfwvalue1; {0}", + expected_status_code="200", + ), + ] + ) + def test(self, name, cookies, expected_status_code): + self.start_all_services() + + client = self.get_client("deproxy") + + client.send_request(client.create_request("GET", []), "302") + # get a sticky cookie from a response headers + tfw_cookie = client.last_response.headers.get("set-cookie").split("; ")[0].split("=") + + sticky_cookie = f"{tfw_cookie[0]}={tfw_cookie[1]}" + for _ in range(2): # first as string and second as bytes from dynamic table + client.send_request( + request=client.create_request( + method="GET", + headers=[("cookie", cookies.format(sticky_cookie))], + ), + expected_status_code=expected_status_code, + ) + if expected_status_code != "200": + self.assertTrue(client.wait_for_connection_close()) + break + + class DuplicateSingularHeader(H2Base): def test_two_header_as_bytes_from_dynamic_table(self): client = self.get_client("deproxy") @@ -1302,12 +1419,7 @@ def __do_test_replacement(self, client, server, content_type, expected_content_t ): request = client.create_request( method="POST", - headers=[ - (":authority", "localhost"), - (":path", "/"), - (":scheme", "https"), - ("content-type", content_type.format(*state)), - ], + headers=[("content-type", content_type.format(*state))], ) client.send_request(request, "200") @@ -1322,12 +1434,7 @@ def test_content_length_field_from_hpack_table(self): request = client.create_request( method="POST", - headers=[ - (":authority", "localhost"), - (":path", "/"), - (":scheme", "https"), - ("content-length", "10"), - ], + headers=[("content-length", "10")], body="aaaaaaaaaa", ) @@ -1354,12 +1461,7 @@ def test_method_override_from_hpack_table(self): request = client.create_request( method="GET", - headers=[ - (":authority", "localhost"), - (":path", "/"), - (":scheme", "https"), - ("x-http-method-override", "HEAD"), - ], + headers=[("x-http-method-override", "HEAD")], ) tempesta = self.get_tempesta() @@ -1381,12 +1483,7 @@ def test_pragma_from_hpack_table(self): request = client.create_request( method="GET", - headers=[ - (":authority", "localhost"), - (":path", "/"), - (":scheme", "https"), - ("pragma", "no-cache"), - ], + headers=[("pragma", "no-cache")], ) tempesta = self.get_tempesta() @@ -1397,14 +1494,7 @@ def test_pragma_from_hpack_table(self): client.send_request(request, "200") self.assertEqual(2, len(server.requests)) - request = client.create_request( - method="GET", - headers=[ - (":authority", "localhost"), - (":path", "/"), - (":scheme", "https"), - ], - ) + request = client.create_request(method="GET", headers=[]) client.send_request(request, "200") self.assertEqual(3, len(server.requests)) diff --git a/selftests/test_deproxy.py b/selftests/test_deproxy.py index a59caadc8..6e2ed20f2 100644 --- a/selftests/test_deproxy.py +++ b/selftests/test_deproxy.py @@ -1,6 +1,7 @@ from h2.exceptions import ProtocolError from framework import deproxy_client, tester +from framework.parameterize import param, parameterize from helpers import chains, deproxy, tempesta, tf_cfg from testers import functional @@ -155,6 +156,24 @@ def test_make_request(self): self.assertTrue(deproxy_cl.wait_for_response(timeout=0.5)) self.assertEqual(deproxy_cl.last_response.status, "200") + def test_duplicate_headers(self): + client = self.get_client("deproxy") + server = self.get_server("deproxy") + + self.start_all_services() + client.send_request( + request=client.create_request( + method="GET", + headers=[ + ("cookie", "name1=value1"), + ("cookie", "name2=value2"), + ], + ), + expected_status_code="200", + ) + + self.assertEqual(server.last_request.headers.get("cookie"), "name1=value1; name2=value2") + def test_parsing_make_request(self): self.start_all() @@ -367,20 +386,26 @@ def test_many_make_request_2(self): self.assertEqual(client.last_response.status, "400") self.assertEqual(len(client.responses), 1) - def test_make_requests(self): + def __send_requests(self, client, request, count, expected_len, pipelined): + client.make_requests([request] * count, pipelined=pipelined) + client.wait_for_response(timeout=3) + + self.assertEqual(len(client.responses), expected_len) + for res in client.responses: + self.assertEqual(res.status, "200") + + @parameterize.expand( + [param(name="not_pipelined", pipelined=False), param(name="pipelined", pipelined=True)] + ) + def test_make_requests(self, name, pipelined): self.start_all() client: deproxy_client.DeproxyClient = self.get_client("deproxy") client.parsing = True request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" - messages = 5 - client.make_requests([request] * messages) - client.wait_for_response(timeout=3) - - self.assertEqual(len(client.responses), messages) - for res in client.responses: - self.assertEqual(res.status, "200") + self.__send_requests(client, request, 3, 3, pipelined) + self.__send_requests(client, request, 3, 6, pipelined) def test_parsing_make_requests(self): self.start_all() diff --git a/sessions/test_cookies.py b/sessions/test_cookies.py index dcade3c8a..5f5c131b7 100644 --- a/sessions/test_cookies.py +++ b/sessions/test_cookies.py @@ -6,7 +6,9 @@ import time from framework import tester -from helpers import tf_cfg +from framework.parameterize import param, parameterize, parameterize_class +from helpers import dmesg, remote, tf_cfg +from helpers.remote import CmdError __author__ = "Tempesta Technologies, Inc." __copyright__ = "Copyright (C) 2019-2024 Tempesta Technologies, Inc." @@ -224,6 +226,66 @@ def test_cookie(self): ) +class CookiesMaxMisses(tester.TempestaTest): + max_misses = 2 + tempesta = { + "config": f""" + server ${{server_ip}}:8000; + + block_action attack reply; + block_action error reply; + + sticky {{ + cookie enforce max_misses={max_misses}; + }} + + """ + } + + clients = [ + { + "id": "client", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + } + ] + + backends = [ + { + "id": "server", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" + "Server-id: deproxy\r\n" + "Content-Length: 0\r\n\r\n", + } + ] + + @parameterize.expand( + [ + param(name="no_cookie", headers=[]), + param(name="with_invalid_cookie", headers=[("cookie", "__tfw=0000db26d9f76f8c40ba5c")]), + ] + ) + def test_max_misses_(self, name, headers): + """ + Tempesta MUST close connection when requests of client does not contain cookie + (or contain invalid cookie) and the number of requests greater than max_misses. + """ + self.start_all_services() + + client = self.get_client("client") + request = client.create_request(method="GET", headers=headers) + + for _ in range(self.max_misses + 1): + client.send_request(request) + + self.assertEqual(client.last_response.status, "403") + self.assertTrue(client.wait_for_connection_close()) + + class VhostCookies(CookiesNotEnabled): """Cookies are configured per-vhost, and clients may get the requested resources only if valid cookie name and value is set. @@ -245,7 +307,7 @@ class VhostCookies(CookiesNotEnabled): proxy_pass vh_1_srvs; sticky { - cookie name=c_vh1 enforce; + cookie name=c_vh1 enforce max_misses=0; } } @@ -253,7 +315,7 @@ class VhostCookies(CookiesNotEnabled): proxy_pass vh_2_srvs; sticky { - cookie name=c_vh2 enforce; + cookie name=c_vh2 enforce max_misses=0; } } @@ -387,7 +449,7 @@ class CookiesInherit(VhostCookies): } sticky { - cookie name=c_vh1 enforce; + cookie name=c_vh1 enforce max_misses=0; } vhost vh_1 { @@ -395,7 +457,7 @@ class CookiesInherit(VhostCookies): } sticky { - cookie name=c_vh2 enforce; + cookie name=c_vh2 enforce max_misses=0; } vhost vh_2 { @@ -481,3 +543,245 @@ def test_cookie(self): ) response = self.client_send_req(client, req) self.assertEqual(response.status, "302", "Unexpected redirect status code") + + +GLOBAL_TEMPLATE = { + "config": """ +sticky { + %s +} +""" +} + +VHOST_TEMPLATE = { + "config": """ +srv_group default { + server ${server_ip}:8000; +} + +vhost example.com { + sticky { + %s + } + + proxy_pass default; +} +""" +} + + +@parameterize_class( + [ + {"name": "Global", "tempesta": GLOBAL_TEMPLATE}, + {"name": "Vhost", "tempesta": VHOST_TEMPLATE}, + ] +) +class StickyCookieConfig(tester.TempestaTest): + @dmesg.unlimited_rate_on_tempesta_node + def check_cannot_start_impl(self, msg): + self.oops_ignore = ["WARNING", "ERROR"] + with self.assertRaises(CmdError, msg=""): + self.start_tempesta() + self.assertTrue( + self.oops.find(msg, cond=dmesg.amount_positive), "Tempesta doesn't report error" + ) + + def setUp(self): + super().setUp() + srcdir = tf_cfg.cfg.get("Tempesta", "srcdir") + workdir = tf_cfg.cfg.get("Tempesta", "workdir") + remote.tempesta.run_cmd(f"cp {srcdir}/etc/js_challenge.js.tpl {workdir}") + remote.tempesta.run_cmd(f"cp {srcdir}/etc/js_challenge.tpl {workdir}/js1.tpl") + + @parameterize.expand( + [ + param( + name="cookie_options_no_cookie", + cookie_config="cookie_options Path=/;", + msg="http_sess: cookie options requires sticky cookies enabled and explicitly defined in the same section", + ), + param( + name="js_challenge_no_cookie", + cookie_config="js_challenge resp_code=503 delay_min=1000 delay_range=3000 %s/js1.html;" + % tf_cfg.cfg.get("Tempesta", "workdir"), + msg="http_sess: JavaScript challenge requires sticky cookies enabled and explicitly defined in the same section", + ), + param( + name="cookie_options_with_cookie", + cookie_config="cookie_options Path=/;\ncookie enforce;", + msg=None, + ), + param( + name="js_challenge_with_cookie", + cookie_config="js_challenge resp_code=503 delay_min=1000 delay_range=3000 %s/js1.html;\ncookie enforce;" + % tf_cfg.cfg.get("Tempesta", "workdir"), + msg=None, + ), + ] + ) + def test(self, name, cookie_config, msg): + tempesta_conf = self.get_tempesta().config + + tempesta_conf.set_defconfig(tempesta_conf.defconfig % cookie_config) + if msg is not None: + self.check_cannot_start_impl(msg) + else: + self.start_all_services() + + +class StickyCookieOptions(tester.TempestaTest): + clients = [{"id": "deproxy", "type": "deproxy", "addr": "${tempesta_ip}", "port": "80"}] + + tempesta = { + "config": """ + srv_group default { + server ${server_ip}:8000; + } + srv_group example { + server ${server_ip}:8001; + } + + sticky { + cookie enforce max_misses=10; + %s + } + + vhost default { + proxy_pass default; + } + + vhost example.com { + sticky { + cookie enforce max_misses=2; + %s + } + + proxy_pass example; + } + + http_chain { + host == "example.com" -> example.com; + -> default; + } + """ + } + + backends = [ + { + "id": "deproxy", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", + } + ] + + @parameterize.expand( + [ + # If no options are set, session lifetime is equal to UINT_MAX (4294967295) + # and Max-Age is set to 4294967295 also. Path is not set, default Path="/" + # is used. + param( + name="empty_options", + cookie_options_global="", + cookie_options_vhost="", + options_global_in_response=["Path=/", "Max-Age=4294967295"], + options_vhost_in_response=["Path=/", "Max-Age=4294967295"], + ), + # Global session lifetime is set and vhost session lifetime is + # empty. Global session lifetime is used to set Max-Age for global + # and vhost cookies. + param( + name="empty_vhost_global_sess_lifetime_is_set", + cookie_options_global="sess_lifetime 5;", + cookie_options_vhost="", + options_global_in_response=["Path=/", "Max-Age=5"], + options_vhost_in_response=["Path=/", "Max-Age=5"], + ), + # Global Expires is set, global session lifetime doesn't affect global + # cookie options, but vhost cookie options are empty, so global session + # lifetime is used to set Max-Age for vhost cookies. + param( + name="empty_vhost_global_expires_and_sess_lifetime_are_set", + cookie_options_global="cookie_options Path=/etc Expires=111;\nsess_lifetime 5;", + cookie_options_vhost="", + options_global_in_response=["Path=/etc", "Expires=111"], + options_vhost_in_response=["Path=/", "Max-Age=5"], + ), + # Global Max-Age is set, session lifetime doesn't affect global cookie + # options. Vhost session lifetime is set and used to set vhost cookies + # Max-Age. + param( + name="vhost_sess_lifetime_and_global_sess_lifetime_and_max_age_are_set", + cookie_options_global="cookie_options Max-Age=111;\nsess_lifetime 5;", + cookie_options_vhost="sess_lifetime 3;", + options_global_in_response=["Path=/", "Max-Age=111"], + options_vhost_in_response=["Path=/", "Max-Age=3"], + ), + # Secure option is set, Path and Max-Age are set according to + # default values ("/" for Path and 4294967295 for Max-Age). + param( + name="vhost_and_global_options_other", + cookie_options_global="cookie_options Secure;", + cookie_options_vhost="cookie_options Secure;", + options_global_in_response=["Secure", "Path=/", "Max-Age=4294967295"], + options_vhost_in_response=["Secure", "Path=/", "Max-Age=4294967295"], + ), + # Vhost Expires is set, global session lifetime doesn't affect + # vhost cookie options. + param( + name="vhost_expires_is_set", + cookie_options_global="sess_lifetime 5;", + cookie_options_vhost="cookie_options Expires=111;", + options_global_in_response=["Path=/", "Max-Age=5"], + options_vhost_in_response=["Path=/", "Expires=111"], + ), + # Vhost Max-Age is set, global session lifetime doesn't affect + # vhost cookie options. + param( + name="vhost_max_age_is_set", + cookie_options_global="sess_lifetime 5;", + cookie_options_vhost="cookie_options Max-Age=111;", + options_global_in_response=["Path=/", "Max-Age=5"], + options_vhost_in_response=["Path=/", "Max-Age=111"], + ), + ] + ) + def test( + self, + name, + cookie_options_global, + cookie_options_vhost, + options_global_in_response, + options_vhost_in_response, + ): + """ + Send two request, first request to default vhost with global cookies and + second request to example.com with special cookies. Check Set-Cookie + header - Tempesta FW should set Path and Max-Age or Expires. + """ + tempesta_conf = self.get_tempesta().config + + tempesta_conf.set_defconfig( + tempesta_conf.defconfig % (cookie_options_global, cookie_options_vhost) + ) + self.start_all_services() + + client = self.get_client("deproxy") + client.send_request(client.create_request(method="GET", headers=[]), "302") + set_cookie = client.last_response.headers.get("Set-Cookie", None) + cookie_opt = set_cookie.split("; ") + for opt in options_global_in_response: + self.assertIn(opt, cookie_opt) + for opt in cookie_opt[1:]: + self.assertIn(opt, options_global_in_response) + + client.send_request( + client.create_request(method="GET", authority="example.com", headers=[]), "302" + ) + set_cookie = client.last_response.headers.get("Set-Cookie", None) + cookie_opt = set_cookie.split("; ") + for opt in options_vhost_in_response: + self.assertIn(opt, cookie_opt) + for opt in cookie_opt[1:]: + self.assertIn(opt, options_vhost_in_response) diff --git a/sessions/test_js_challenge.py b/sessions/test_js_challenge.py index d0c2a7afe..42c3f0571 100644 --- a/sessions/test_js_challenge.py +++ b/sessions/test_js_challenge.py @@ -2,144 +2,135 @@ Tests for JavaScript challenge. """ -import abc import re import time -from framework import deproxy_client, tester +from framework import tester +from framework.parameterize import param, parameterize, parameterize_class from framework.templates import fill_template, populate_properties -from helpers import remote, tempesta, tf_cfg +from helpers import dmesg, remote, tf_cfg from helpers.deproxy import HttpMessage __author__ = "Tempesta Technologies, Inc." __copyright__ = "Copyright (C) 2020-2024 Tempesta Technologies, Inc." __license__ = "GPL2" +MAX_MISSES = 2 +DELAY_MIN = 1000 +DELAY_RANGE = 1500 + + +DEPROXY_CLIENT = { + "id": "client-1", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", +} + +DEPROXY_CLIENT_H2 = { + "id": "client-1", + "type": "deproxy_h2", + "addr": "${tempesta_ip}", + "port": "443", + "ssl": True, +} + class BaseJSChallenge(tester.TempestaTest): - def client_send_req(self, client, req): - curr_responses = len(client.responses) - client.make_request(req) - client.wait_for_response(timeout=1) - self.assertEqual(curr_responses + 1, len(client.responses)) - - return client.responses[-1] - - def client_expect_block(self, client, req): - curr_responses = len(client.responses) - client.make_request(req) - client.wait_for_response(timeout=1) - self.assertEqual(curr_responses, len(client.responses)) - self.assertTrue(client.wait_for_connection_close()) - - @abc.abstractmethod - def prepare_js_templates(self): - pass - - def start_all(self): - self.prepare_js_templates() - self.start_all_servers() - self.start_tempesta() - self.start_all_clients() - self.deproxy_manager.start() - self.assertTrue(self.wait_all_connections(1)) - - def expect_restart(self, client, req, status_code, last_cookie): - """We tried to pass JS challenge, but the cookie we have was generated - too long time ago. We can't pass JS challenge now, but Tempesta - doesn't block us, but gives second chance to pass the challenge. - - Expect a new redirect response with new sticky cookie value. - """ - resp = self.client_send_req(client, req) - self.assertEqual(resp.status, "%d" % status_code, "unexpected response status code") - c_header = resp.headers.get("Set-Cookie", None) - self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") - match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) - self.assertIsNotNone(match, "Can't extract value from Set-Cookie header") - new_cookie = (match.group(1), match.group(2)) - self.assertNotEqual(last_cookie, new_cookie, "Challenge is not restarted") - - def process_js_challenge( - self, - client, - host, - delay_min, - delay_range, - status_code, - expect_pass, - req_delay, - restart_on_fail=False, + @classmethod + def setUpClass(cls): + super().setUpClass() + srcdir = tf_cfg.cfg.get("Tempesta", "srcdir") + workdir = tf_cfg.cfg.get("Tempesta", "workdir") + remote.tempesta.run_cmd(f"cp {srcdir}/etc/js_challenge.js.tpl {workdir}") + remote.tempesta.run_cmd(f"cp {srcdir}/etc/js_challenge.tpl {workdir}/js1.tpl") + + @staticmethod + def client_send_pipelined_requests(client, reqs, error_expected) -> list: + client.make_requests(reqs, pipelined=True) + client.wait_for_response(strict=True) + + return client.responses[-len(reqs) :] if not error_expected else client.responses[-1:] + + @staticmethod + def prepare_first_req(client, method="GET", host=tf_cfg.cfg.get("Tempesta", "hostname")): + return client.create_request( + method=method, + headers=[("accept", "text/html")], + authority=host, + ) + + @staticmethod + def prepare_second_req( + client, cookie: tuple, uri="/", host=tf_cfg.cfg.get("Tempesta", "hostname") ): - """Our tests can't pass the JS challenge with propper configuration, - enlarge delay limit to not recommended values to make it possible to - hardcode the JS challenge. - """ + return client.create_request( + method="GET", + uri=uri, + headers=[("accept", "text/html"), ("cookie", f"{cookie[0]}={cookie[1]}")], + authority=host, + ) + + @staticmethod + def _java_script_sleep_time(cookie: str): + return (DELAY_MIN + int(cookie[:16], 16) % DELAY_RANGE) / 1000 + + @staticmethod + def _java_script_sleep(cookie: str) -> None: + """This repeats sleep from JavaScript in response body""" + time.sleep(BaseJSChallenge._java_script_sleep_time(cookie)) - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", host), - (":path", "/"), - (":scheme", "https"), - (":method", "GET"), - ("accept", "text/html"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = "GET / HTTP/1.1\r\nHost: %s\r\nAccept: text/html\r\n\r\n" % host - - resp = self.client_send_req(client, req) - self.assertEqual(resp.status, "%d" % status_code, "unexpected response status code") + def _check_and_get_cookie(self, resp) -> tuple: c_header = resp.headers.get("Set-Cookie", None) self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) self.assertIsNotNone(match, "Cant extract value from Set-Cookie header") - cookie = (match.group(1), match.group(2)) + return match.group(1), match.group(2) + def check_resp_body_and_cookie(self, resp): + cookie = self._check_and_get_cookie(resp) # Check that all the variables are passed correctly into JS challenge # code: js_vars = [ 'var c_name = "%s";' % cookie[0], - "var delay_min = %d;" % delay_min, - "var delay_range = %d;" % delay_range, + "var delay_min = %d;" % DELAY_MIN, + "var delay_range = %d;" % DELAY_RANGE, ] for js_var in js_vars: self.assertIn(js_var, resp.body, "Can't find JS Challenge parameter in response body") + return cookie - # Pretend we can eval JS code and pass the challenge, but we can't set - # reliable timeouts and pass the challenge on CI or in virtual - # environments. Instead increase the JS parameters to make hardcoding - # easy and reliable. - if req_delay: - time.sleep(req_delay) - - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", host), - (":path", "/"), - (":scheme", "https"), - (":method", "GET"), - ("accept", "text/html"), - ("cookie", f"{cookie[0]}={cookie[1]}"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = ( - "GET / HTTP/1.1\r\n" - "Host: %s\r\n" - "Accept: text/html\r\n" - "Cookie: %s=%s\r\n" - "\r\n" % (host, cookie[0], cookie[1]) - ) + def process_first_js_challenge_req(self, client): + """ + Our tests can't pass the JS challenge with propper configuration, + enlarge delay limit to not recommended values to make it possible to + hardcode the JS challenge. + """ + client.send_request(self.prepare_first_req(client), "503") + return self.check_resp_body_and_cookie(client.last_response) - if not expect_pass: - if restart_on_fail: - self.expect_restart(client, req, status_code, cookie) - else: - self.client_expect_block(client, req) - return - resp = self.client_send_req(client, req) - self.assertEqual(resp.status, "200", "unexpected response status code") + def _set_tempesta_config_without_js(self): + """Recreate config without js_challenge directive.""" + desc = self.tempesta.copy() + populate_properties(desc) + new_cfg = fill_template(desc["config"], desc) + new_cfg = re.sub(r"js_challenge[\s\w\d_/=\.\n]+;", "", new_cfg, re.M) + + self.get_tempesta().config.set_defconfig(new_cfg) + def _set_tempesta_js_config(self): + """Recreate config with js_challenge directive.""" + desc = self.tempesta.copy() + populate_properties(desc) + self.get_tempesta().config.set_defconfig(fill_template(desc["config"], desc)) + +@parameterize_class( + [ + {"name": "Http", "clients": [DEPROXY_CLIENT]}, + {"name": "H2", "clients": [DEPROXY_CLIENT_H2]}, + ] +) class JSChallenge(BaseJSChallenge): """ With sticky sessions enabled, client will be pinned to the same server, @@ -166,606 +157,552 @@ class JSChallenge(BaseJSChallenge): ] tempesta = { - "config": """ - server ${server_ip}:8000; - - vhost vh1 { - proxy_pass default; - sticky { - cookie enforce name=cname; - js_challenge resp_code=503 delay_min=1000 delay_range=1500 - delay_limit=3000 ${tempesta_workdir}/js1.html; - } - } - - vhost vh2 { - proxy_pass default; - sticky { - cookie enforce; - js_challenge resp_code=302 delay_min=2000 delay_range=1200 - delay_limit=2000 ${tempesta_workdir}/js2.html; - } - } - - vhost vh3 { - proxy_pass default; - sticky { - cookie enforce; - js_challenge delay_min=1000 delay_range=1000 - ${tempesta_workdir}/js3.html; - } - } - - http_chain { - host == "vh1.com" -> vh1; - host == "vh2.com" -> vh2; - host == "vh3.com" -> vh3; - -> block; - } + "config": f""" + server ${{server_ip}}:8000; + + listen 80; + listen 443 proto=h2; + + tls_certificate ${{tempesta_workdir}}/tempesta.crt; + tls_certificate_key ${{tempesta_workdir}}/tempesta.key; + tls_match_any_server_name; + + frang_limits {{ + http_method_override_allowed true; + http_strict_host_checking false; + }} + + block_action attack reply; + block_action error reply; + + cache 2; + cache_methods GET HEAD POST; + cache_fulfill * *; + + sticky {{ + cookie enforce name=cname max_misses={MAX_MISSES}; + js_challenge resp_code=503 delay_min={DELAY_MIN} delay_range={DELAY_RANGE} + ${{tempesta_workdir}}/js1.html; + }} """ } - clients = [ - { - "id": "client-1", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - { - "id": "client-2", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - { - "id": "client-3", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - ] + def _test_first_request(self, client, request, method, accept, status, conn_is_closed=False): + client.send_request(request, status) + resp = client.last_response - def prepare_js_templates(self): - """ - Templates for JS challenge are modified by start script, create a copy - of default template for each vhost. + cookie = resp.headers.get("Set-Cookie", None) + + if status == "503": + self.assertIsNotNone( + cookie, "Tempesta did not added a Set-Cookie header for a JS challenge." + ) + self.check_resp_body_and_cookie(resp) + else: + self.assertIsNone( + cookie, "Tempesta added a Set-Cookie header in a 4xx response for JS challenge." + ) + self.assertEqual( + resp.body, + "", + f"Tempesta send a response body for a reqeust with {method} method and " + f"accept header - {accept}", + ) + if conn_is_closed: + self.assertTrue(client.wait_for_connection_close()) + else: + self.assertFalse(client.conn_is_closed) + + @parameterize.expand( + [ + param(name="GET_and_accept_html", method="GET", accept="text/html", status="503"), + param(name="GET_and_accept_all", method="GET", accept="*/*", status="403"), + param(name="GET_and_accept_text_all", method="GET", accept="text/*", status="403"), + param(name="GET_and_accept_image", method="GET", accept="image/*", status="403"), + param(name="GET_and_accept_plain", method="GET", accept="text/plain", status="403"), + param(name="POST", accept="text/html", method="POST", status="403"), + ] + ) + def test_first_request(self, name, method, accept, status): """ - srcdir = tf_cfg.cfg.get("Tempesta", "srcdir") - workdir = tf_cfg.cfg.get("Tempesta", "workdir") - template = "%s/etc/js_challenge.tpl" % srcdir - js_code = "%s/etc/js_challenge.js.tpl" % srcdir - remote.tempesta.run_cmd("cp %s %s" % (js_code, workdir)) - remote.tempesta.run_cmd("cp %s %s/js1.tpl" % (template, workdir)) - remote.tempesta.run_cmd("cp %s %s/js2.tpl" % (template, workdir)) - remote.tempesta.run_cmd("cp %s %s/js3.tpl" % (template, workdir)) - - def test_get_challenge(self): - """Not all requests are challengeable. Tempesta sends the challenge + Not all requests are challengeable. Tempesta sends the challenge only if the client can accept it, i.e. request should has GET method and 'Accept: text/html'. In other cases normal browsers don't eval JS code and TempestaFW is not trying to send the challenge to bots. """ - self.start_all() - client = self.get_client("client-1") + self.start_all_services() - # Client can accept JS code in responses. - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", "vh1.com"), - (":path", "/"), - (":scheme", "https"), - (":method", "GET"), - ("accept", "text/html"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = "GET / HTTP/1.1\r\nHost: vh1.com\r\nAccept: text/html\r\n\r\n" - - resp = self.client_send_req(client, req) - self.assertEqual(resp.status, "503", "Unexpected response status code") - self.assertIsNotNone( - resp.headers.get("Set-Cookie", None), "Set-Cookie header is missing in the response" - ) - match = re.search(r"(location\.replace)", resp.body) - self.assertIsNotNone(match, "Can't extract redirect target from response body") - - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", "vh1.com"), - (":path", "/"), - (":scheme", "https"), - (":method", "GET"), - ("accept", "*/*"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = "GET / HTTP/1.1\r\nHost: vh1.com\r\nAccept: */*\r\n\r\n" - - client = self.get_client("client-2") - self.client_expect_block(client, req) - - # Resource is not challengable, request will be blocked and the - # connection will be reset. - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", "vh1.com"), - (":path", "/"), - (":scheme", "https"), - (":method", "GET"), - ("accept", "text/plain"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = "GET / HTTP/1.1\r\nHost: vh1.com\r\nAccept: text/plain\r\n\r\n" - - client = self.get_client("client-3") - self.client_expect_block(client, req) - - def test_pass_challenge(self): - """Clients send the validating request just in time and pass the - challenge. + client = self.get_client("client-1") + request = client.create_request(method=method, headers=[("accept", accept)]) + self._test_first_request(client, request, method, accept, status) + + @parameterize.expand( + [ + param(name="GET", method="GET", status="200"), + param(name="HEAD", method="HEAD", status="200"), + param(name="POST", method="POST", status="403"), + ] + ) + def test_servicing_non_challengeble_request_from_cache(self, name, method, status): + """ + Tempesta try to service non-challengeable requests from the cache. If Tempesta + can't service such request from the cache drop it """ - self.start_all() + self.test_second_request_GET_and_accept_all() + + for _ in range(1, 2 * MAX_MISSES): + client = self.get_client("client-1") + client.send_request( + client.create_request(method=method, headers=[("accept", "image/*")]), + status, + ) - tf_cfg.dbg(3, "Send request to vhost 1 with timeout 2s...") + @parameterize.expand( + [ + param(name="GET_and_accept_all", method="GET", accept="*/*"), + param(name="POST", method="POST", accept="*/*"), + ] + ) + def test_second_request(self, name, method, accept): + """Tempesta should not check a request if max_misses > 0.""" + self.start_all_services() client = self.get_client("client-1") - self.process_js_challenge( - client, - "vh1.com", - delay_min=1000, - delay_range=1500, - status_code=503, - expect_pass=True, - req_delay=2.5, - ) + cookie = self.process_first_js_challenge_req(client) + + self._java_script_sleep(cookie[1]) - tf_cfg.dbg(3, "Send request to vhost 2 with timeout 4s...") - client = self.get_client("client-2") - self.process_js_challenge( - client, - "vh2.com", - delay_min=2000, - delay_range=1200, - status_code=302, - expect_pass=True, - req_delay=3.5, + client.send_request( + client.create_request( + method=method, headers=[("accept", accept), ("cookie", f"{cookie[0]}={cookie[1]}")] + ), + "200", ) - # Vhost 3 has very strict window to receive the request, skip it in - # this test. - def test_fail_challenge_too_early(self): - """Clients send the validating request too early, Tempesta closes the - connection. + @parameterize.expand( + [ + param(name="pass", sleep=True, second_status="200"), + param(name="too_small", sleep=False, second_status="503"), + ] + ) + def test_delay(self, name, sleep, second_status): """ - self.start_all() + The client MUST repeat a request between min_time and max_time when: + - min_time = delay_min + (value between 0 and delay_range); + """ + self.start_all_services() - tf_cfg.dbg(3, "Send request to vhost 1 with timeout 0s...") client = self.get_client("client-1") - self.process_js_challenge( - client, - "vh1.com", - delay_min=1000, - delay_range=1500, - status_code=503, - expect_pass=False, - req_delay=0, - ) + cookie = self.process_first_js_challenge_req(client) - tf_cfg.dbg(3, "Send request to vhost 2 with timeout 1s...") - client = self.get_client("client-2") - self.process_js_challenge( - client, - "vh2.com", - delay_min=2000, - delay_range=1200, - status_code=302, - expect_pass=False, - req_delay=1, - ) + if sleep: + self._java_script_sleep(cookie[1]) - tf_cfg.dbg(3, "Send request to vhost 3 with timeout 0s...") - client = self.get_client("client-3") - self.process_js_challenge( - client, - "vh3.com", - delay_min=1000, - delay_range=1000, - status_code=503, - expect_pass=False, - req_delay=0, + client.send_request( + request=self.prepare_second_req(client, cookie), + expected_status_code=second_status, + ) + self.assertFalse( + client.conn_is_closed, + "Tempesta close a connection during a JS challenge check " + "and max_misses was not exceeded.", ) - def test_fail_challenge_too_late(self): - """Clients send the validating request too late, Tempesta restarts - cookie challenge. + def test_restart_js_challenge(self): """ - self.start_all() - - tf_cfg.dbg(3, "Send request to vhost 1 with timeout 6s...") + Tempesta MUST restart JS challenge when client make request with invalid cookie + and the number of requests is less than `max_misses`. + """ + self.start_all_services() + invalid_cookie = ("cname", "0000000100116a72fd67776d455aacf0dda1b56e570a109c89cd5582") client = self.get_client("client-1") - self.process_js_challenge( - client, - "vh1.com", - delay_min=1000, - delay_range=1500, - status_code=503, - expect_pass=False, - req_delay=6, - restart_on_fail=True, - ) - tf_cfg.dbg(3, "Send request to vhost 2 with timeout 6s...") - client = self.get_client("client-2") - self.process_js_challenge( - client, - "vh2.com", - delay_min=2000, - delay_range=1200, - status_code=302, - expect_pass=False, - req_delay=6, - restart_on_fail=True, + # make first request without cookie, request misses = 1 + client.send_request( + request=self.prepare_second_req(client, invalid_cookie), + expected_status_code="503", ) + cookie_1 = self.check_resp_body_and_cookie(client.last_response) - tf_cfg.dbg(3, "Send request to vhost 3 with timeout 3s...") - client = self.get_client("client-3") - self.process_js_challenge( - client, - "vh3.com", - delay_min=1000, - delay_range=1000, - status_code=503, - expect_pass=False, - req_delay=3, - restart_on_fail=True, - ) + self._java_script_sleep(cookie_1[1]) - def client_send_custom_req(self, client, uri, cookie, host=None): - if not host: - host = "localhost" - - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", host), - (":path", uri), - (":scheme", "https"), - (":method", "GET"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = ( - "GET %s HTTP/1.1\r\n" - "Host: %s\r\n" - "%s" - "\r\n" % (uri, host, ("Cookie: %s=%s\r\n" % cookie) if cookie else "") - ) - response = self.client_send_req(client, req) + client.send_request(self.prepare_second_req(client, cookie_1), expected_status_code="200") + self.assertNotEqual(invalid_cookie[1], cookie_1[1], "Tempesta did not restart JS challenge") - self.assertEqual(response.status, "302", "unexpected response status code") - c_header = response.headers.get("Set-Cookie", None) - self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") - match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) - self.assertIsNotNone(match, "Cant extract value from Set-Cookie header") - cookie = (match.group(1), match.group(2)) + def test_number_of_requests_is_greater_than_max_misses(self): + """ + The client make several tries to bypass JS challenge. + Tempesta MUST block client when the number of requests is greater than `max_misses'. + """ + client = self.get_client("client-1") + self.start_all_services() - uri = response.headers.get("Location", None) - self.assertIsNotNone(uri, "Location header is missing in the response") - return (uri, cookie) + # make first request without cookie, request misses = 1 + cookie = self.process_first_js_challenge_req(client) - def client_send_first_req(self, client, uri, host=None): - return self.client_send_custom_req(client, uri, None, host=host) + # make second request without cookie, request misses = 2 + cookie = self.process_first_js_challenge_req(client) - def get_config_without_js(self): - """Recreate config without js_challenge directive.""" - desc = self.tempesta.copy() - populate_properties(desc) - new_cfg = fill_template(desc["config"], desc) - new_cfg = re.sub(r"js_challenge[\s\w\d_/=\.\n]+;", "", new_cfg, re.M) - return new_cfg + # make third request without cookie, request misses = 3 + client.send_request(self.prepare_first_req(client), expected_status_code="403") + client.wait_for_connection_close(strict=True) def test_disable_challenge_on_reload(self): - """Test on disable JS Challenge after reload""" - self.start_all() + """ + Test on disable JS Challenge after reload. + The second request without sleep from JS challenge must be successful. + """ + self.start_all_services() # Reloading Tempesta config with JS challenge disabled. - config = tempesta.Config() - config.set_defconfig(self.get_config_without_js()) + self._set_tempesta_config_without_js() + self.get_tempesta().reload() + + client = self.get_client("client-1") + client.send_request( + request=client.create_request(method="GET", headers=[]), + expected_status_code="302", + ) + cookie = self._check_and_get_cookie(client.last_response) + + client.send_request( + request=client.create_request( + method="GET", + headers=[("cookie", f"{cookie[0]}={cookie[1]}")], + ), + expected_status_code="200", + ) + + def test_enable_challenge_on_reload(self): + """ + Clients sends the validating request after reload just in time and + passes the challenge. + """ + # Set Tempesta config with JS challenge disabled. + self._set_tempesta_config_without_js() + self.start_all_services() - self.get_tempesta().config = config + # Reloading Tempesta config with JS challenge. + self._set_tempesta_js_config() self.get_tempesta().reload() client = self.get_client("client-1") - uri = "/" - vhost = "vh1.com" - uri, cookie = self.client_send_first_req(client, uri, host=vhost) - - if isinstance(client, deproxy_client.DeproxyClientH2): - req = [ - (":authority", vhost), - (":path", uri.split("https:/")[1]), - (":scheme", "https"), - (":method", "GET"), - ("cookie", f"{cookie[0]}={cookie[1]}"), - ] - elif isinstance(client, deproxy_client.DeproxyClient): - req = ( - "GET %s HTTP/1.1\r\n" - "Host: %s\r\n" - "Cookie: %s=%s\r\n" - "\r\n" % (uri, vhost, cookie[0], cookie[1]) + cookie = self.process_first_js_challenge_req(client) + + self._java_script_sleep(cookie[1]) + + client.send_request(self.prepare_second_req(client, cookie), "200") + + def test_not_block_after_tempesta_restart(self): + """This case repeats the blocking of the browser on the next day.""" + self.start_all_services() + client = self.get_client("client-1") + + # browser make request and wait for time from JS challenge + cookie_1 = self.process_first_js_challenge_req(client) + self._java_script_sleep(cookie_1[1]) + + # browser make valid request and close session + client.send_request(self.prepare_second_req(client, cookie_1), expected_status_code="200") + client.stop() + + self.get_tempesta().restart() + + # browser create a new session with an existing cookie. + # This cookie valid for browser, but invalid for Tempesta + # because it has other timestamp after reboot + client.start() + client.send_request(self.prepare_second_req(client, cookie_1), expected_status_code="503") + cookie_2 = self._check_and_get_cookie(client.last_response) + + self._java_script_sleep(cookie_2[1]) + client.send_request(self.prepare_second_req(client, cookie_2), expected_status_code="200") + + def test_new_client_session_with_exist_cookie(self): + """Client opens an already present session: JS challenge is skipped.""" + self.start_all_services() + client = self.get_client("client-1") + + cookie_1 = self.process_first_js_challenge_req(client) + self._java_script_sleep(cookie_1[1]) + + client.send_request(self.prepare_second_req(client, cookie_1), expected_status_code="200") + client.restart() + client.send_request(self.prepare_second_req(client, cookie_1), expected_status_code="200") + + @parameterize.expand( + [ + param(name="pass", sleep=True, conn_is_closed=False), + param(name="too_early", sleep=False, conn_is_closed=True), + ] + ) + def test_delay_pipelined(self, name, sleep, conn_is_closed): + self.start_all_services() + self.disable_deproxy_auto_parser() + + client = self.get_client("client-1") + + request = self.prepare_first_req(client) + responses = self.client_send_pipelined_requests(client, [request, request], False) + + cookies = [] + for response in responses: + self.assertEqual( + response.status, + "503", + "Tempesta returned a invalid status code for the pipelined requests.", ) + cookies.append(self._check_and_get_cookie(response)) + + if sleep: + self._java_script_sleep(cookies[0][1]) - response = self.client_send_req(client, req) - self.assertEqual(response.status, "200", "unexpected response status code") + requests = [self.prepare_second_req(client, cookie) for cookie in cookies] + responses = self.client_send_pipelined_requests( + client, requests, True if conn_is_closed else False + ) + if conn_is_closed: + self.assertEqual(client.last_response.status, "403", "unexpected response status code") + self.assertEqual(len(responses), 1) + else: + self.assertEqual(len(responses), 2) + for resp in responses: + self.assertEqual(resp.status, "200", "unexpected response status code") + self.assertEqual(client.conn_is_closed, conn_is_closed) -class JSChallengeH2(JSChallenge): - tempesta = { - "config": ( - "listen 443 proto=h2;\n" - + JSChallenge.tempesta["config"] - + """ - tls_certificate ${tempesta_workdir}/tempesta.crt; - tls_certificate_key ${tempesta_workdir}/tempesta.key; - tls_match_any_server_name; - """ + def test_first_post_request_pipelined(self): + self.start_all_services() + + client = self.get_client("client-1") + + request1 = self.prepare_first_req(client, method="POST") + request2 = self.prepare_first_req(client, method="GET") + responses = self.client_send_pipelined_requests(client, [request1, request2], False) + + self.assertEqual(len(responses), 2) + + self.assertEqual( + responses[0].status, + "403", + "Tempesta returned a invalid status code for the pipelined requests.", ) - } + c_header = responses[0].headers.get("Set-Cookie", None) + self.assertIsNone(c_header, "Post request is challenged") - clients = [ - { - "id": "client-1", - "type": "deproxy_h2", - "addr": "${tempesta_ip}", - "port": "443", - "ssl": True, - }, - { - "id": "client-2", - "type": "deproxy_h2", - "addr": "${tempesta_ip}", - "port": "443", - "ssl": True, - }, - { - "id": "client-3", - "type": "deproxy_h2", - "addr": "${tempesta_ip}", - "port": "443", - "ssl": True, - }, - ] + self.assertEqual( + responses[1].status, + "503", + "Tempesta returned a invalid status code for the pipelined requests.", + ) + cookie = self._check_and_get_cookie(responses[1]) + self._java_script_sleep(cookie[1]) -class JSChallengeVhost(JSChallenge): - """Same as JSChallenge, but 'sticky' configuration is inherited from - updated defaults for the named vhosts. - """ + self.assertEqual(client.conn_is_closed, False) + client.send_request( + client.create_request(method="POST", headers=[("cookie", f"{cookie[0]}={cookie[1]}")]), + "200", + ) + client.send_request( + client.create_request(method="GET", headers=[("cookie", f"{cookie[0]}={cookie[1]}")]), + "200", + ) - tempesta = { - "config": """ - server ${server_ip}:8000; - - sticky { - cookie enforce name=cname; - js_challenge resp_code=503 delay_min=1000 delay_range=1500 - delay_limit=3000 ${tempesta_workdir}/js1.html; - } - vhost vh1 { - proxy_pass default; - } - - sticky { - cookie enforce; - js_challenge resp_code=302 delay_min=2000 delay_range=1200 - delay_limit=2000 ${tempesta_workdir}/js2.html; - } - vhost vh2 { - proxy_pass default; - } - - - sticky { - cookie enforce; - js_challenge delay_min=1000 delay_range=1000 - ${tempesta_workdir}/js3.html; - } - vhost vh3 { - proxy_pass default; - } - - vhost vh4 { - proxy_pass default; - - sticky { - cookie enforce; - } - } - - http_chain { - host == "vh1.com" -> vh1; - host == "vh2.com" -> vh2; - host == "vh3.com" -> vh3; - host == "vh4.com" -> vh4; - -> block; - } - """ - } + @parameterize.expand( + [ + param(name="multiple_in_one", single=True), + param(name="multiple_in_two", single=False), + ] + ) + def test_cookies_in_request(self, name, single): + if isinstance(self, JSChallengeHttp) and not single: + return - def test_js_overriden_together_with_cookie(self): - """Vhost `vh4` overrides `sticky` directive using only `cookie` - directive. JS challenge is always derived with `cookie` directive, so - JS challenge will be disabled for this vhost. - """ - self.start_all() + self.start_all_services() client = self.get_client("client-1") - uri = "/" - vhost = "vh4.com" - uri, cookie = self.client_send_first_req(client, uri, host=vhost) - - req = ( - "GET %s HTTP/1.1\r\n" - "Host: %s\r\n" - "Cookie: %s=%s\r\n" - "\r\n" % (uri, vhost, cookie[0], cookie[1]) - ) - response = self.client_send_req(client, req) - self.assertEqual(response.status, "200", "unexpected response status code") + cookie1 = self.process_first_js_challenge_req(client) + self._java_script_sleep(cookie1[1]) + cookie2 = self.process_first_js_challenge_req(client) + self._java_script_sleep(cookie2[1]) -class JSChallengeDefVhostInherit(BaseJSChallenge): - """ - Implicit default vhost use other implementation of `sticky` inheritance. - Check that correct configuration is derived. - """ + self.assertNotEqual(cookie1[1], cookie2[1]) - backends = [ - { - "id": "server-1", - "type": "deproxy", - "port": "8000", - "response": "static", - "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", - }, - ] + request = None + if single: + request = client.create_request( + method="GET", + headers=[("cookie", f"{cookie1[0]}={cookie1[1]}; {cookie2[0]}={cookie2[1]}")], + ) + else: + request = client.create_request( + method="GET", + headers=[ + ("cookie", f"{cookie1[0]}={cookie1[1]}"), + ("cookie", f"{cookie2[0]}={cookie2[1]}"), + ], + ) - tempesta = { - "config": """ - server ${server_ip}:8000; - - sticky { - cookie enforce name=cname; - js_challenge resp_code=503 delay_min=1000 delay_range=1500 - delay_limit=3000 ${tempesta_workdir}/js1.html; - } - """ - } + client.send_request(request, "500") - clients = [ - { - "id": "client-1", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - { - "id": "client-2", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - { - "id": "client-3", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - ] + @parameterize.expand( + [ + param(name="403", resp_code="resp_code=403", expected_status="403"), + param(name="default", resp_code="", expected_status="503"), + param(name="302", resp_code="resp_code=302", expected_status="302"), + ] + ) + def test_resp_code(self, name, resp_code, expected_status): + new_cfg = self.get_tempesta().config.get_config().replace("resp_code=503", resp_code) + self.get_tempesta().config.set_defconfig(new_cfg) - def prepare_js_templates(self): - """ - Templates for JS challenge are modified by start script, create a copy - of default template for each vhost. - """ - srcdir = tf_cfg.cfg.get("Tempesta", "srcdir") - workdir = tf_cfg.cfg.get("Tempesta", "workdir") - template = "%s/etc/js_challenge.tpl" % srcdir - js_code = "%s/etc/js_challenge.js.tpl" % srcdir - remote.tempesta.run_cmd("cp %s %s" % (js_code, workdir)) - remote.tempesta.run_cmd("cp %s %s/js1.tpl" % (template, workdir)) - - def test_pass_challenge(self): - """Clients send the validating request just in time and pass the - challenge. - """ - self.start_all() + self.start_all_services() - tf_cfg.dbg(3, "Send request to default vhost with timeout 2s...") client = self.get_client("client-1") - self.process_js_challenge( - client, - "vh1.com", - delay_min=1000, - delay_range=1500, - status_code=503, - expect_pass=True, - req_delay=2.5, + client.send_request(self.prepare_first_req(client), expected_status) + self.check_resp_body_and_cookie(client.last_response) + + @parameterize.expand( + [ + param( + name="GET_POST", method="GET", override="POST", status="400", conn_is_closed=True + ), + param( + name="GET_HEAD", method="GET", override="HEAD", status="403", conn_is_closed=False + ), + param( + name="HEAD_POST", method="HEAD", override="POST", status="400", conn_is_closed=True + ), + param( + name="HEAD_GET", method="HEAD", override="GET", status="503", conn_is_closed=False + ), + param( + name="POST_HEAD", method="POST", override="HEAD", status="403", conn_is_closed=False + ), + param( + name="POST_GET", method="POST", override="GET", status="503", conn_is_closed=False + ), + ] + ) + def test_method_override(self, name, method, override, status, conn_is_closed): + self.start_all_services() + + client = self.get_client("client-1") + request = client.create_request( + method=method, headers=[("accept", "text/html"), ("X-HTTP-Method-Override", override)] ) + self._test_first_request(client, request, method, "text/html", status, conn_is_closed) + + @parameterize.expand( + [ + param(name="GET_POST", method="GET", override="POST", status="400"), + param(name="GET_HEAD", method="GET", override="HEAD", status="200"), + param(name="HEAD_POST", method="HEAD", override="POST", status="400"), + param(name="HEAD_GET", method="HEAD", override="GET", status="200"), + param(name="POST_HEAD", method="POST", override="HEAD", status="200"), + param(name="POST_GET", method="POST", override="GET", status="200"), + ] + ) + def test_method_override_with_cache(self, name, method, override, status): + self.test_second_request_GET_and_accept_all() + client = self.get_client("client-1") + client.send_request( + client.create_request( + method=method, headers=[("accept", "image/*"), ("X-HTTP-Method-Override", override)] + ), + status, + ) -class JSChallengeAfterReload(BaseJSChallenge): - # Test on enable JS Challenge after reload +@parameterize_class( + [ + {"name": "Http", "clients": [DEPROXY_CLIENT]}, + {"name": "H2", "clients": [DEPROXY_CLIENT_H2]}, + ] +) +class JSChallengeCookieExpiresAndMethodOverride(BaseJSChallenge): backends = [ { "id": "server-1", "type": "deproxy", "port": "8000", "response": "static", - "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", + "response_content": ( + "HTTP/1.1 200 OK\r\n" + + f"Date: {HttpMessage.date_time_string()}\r\n" + + "Server: deproxy\r\n" + + "Content-Length: 0\r\n\r\n" + ), }, ] tempesta = { - "config": """ - server ${server_ip}:8000; + "config": f""" + server ${{server_ip}}:8000; + + listen 80; + listen 443 proto=h2; + + tls_certificate ${{tempesta_workdir}}/tempesta.crt; + tls_certificate_key ${{tempesta_workdir}}/tempesta.key; + tls_match_any_server_name; + + block_action attack reply; + block_action error reply; + + cache 2; + cache_fulfill * *; - sticky { - cookie enforce name=cname; - } + sticky {{ + cookie enforce name=cname max_misses={MAX_MISSES}; + js_challenge resp_code=503 delay_min={DELAY_MIN} delay_range={DELAY_RANGE} + ${{tempesta_workdir}}/js1.html; + sess_lifetime 3; + }} """ } - clients = [ - { - "id": "client-1", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - }, - ] + def setUp(self): + super().setUp() + self.klog = dmesg.DmesgFinder(disable_ratelimit=True) + self.assert_msg = "Expected nums of warnings in `journalctl`: {exp}, but got {got}" + # Cleanup part + self.addCleanup(self.cleanup_klog) - def prepare_js_templates(self): - """ - Templates for JS challenge are modified by start script, create a copy - of default template for each vhost. - """ - srcdir = tf_cfg.cfg.get("Tempesta", "srcdir") - workdir = tf_cfg.cfg.get("Tempesta", "workdir") - template = "%s/etc/js_challenge.tpl" % srcdir - js_code = "%s/etc/js_challenge.js.tpl" % srcdir - remote.tempesta.run_cmd("cp %s %s" % (js_code, workdir)) - remote.tempesta.run_cmd("cp %s %s/js1.tpl" % (template, workdir)) + def cleanup_klog(self): + if hasattr(self, "klog"): + del self.klog - def test(self): - """Clients sends the validating request after reload just in time and - passes the challenge. - """ - self.start_all() - - # Reloading Tempesta config with JS challenge enabled - config = tempesta.Config() - config.set_defconfig( - """ - server %s:8000; - - sticky { - cookie enforce name=cname; - js_challenge resp_code=503 delay_min=1000 delay_range=1500 - delay_limit=3000 %s/js1.html; - } - """ - % (tf_cfg.cfg.get("Server", "ip"), tf_cfg.cfg.get("Tempesta", "workdir")) - ) - self.get_tempesta().config = config - self.get_tempesta().reload() + @dmesg.unlimited_rate_on_tempesta_node + def test_cookie_expires(self): + self.start_all_services() - tf_cfg.dbg(3, "Send request to vhost 1 with timeout 2s...") client = self.get_client("client-1") - self.process_js_challenge( - client, - "vh1.com", - delay_min=1000, - delay_range=1500, - status_code=503, - expect_pass=True, - req_delay=2.5, + client.send_request( + client.create_request(method="GET", headers=[("accept", "text/html")]), + "503", + ) + resp = client.last_response + + cookie = resp.headers.get("Set-Cookie", None) + cookie_opt = cookie.split("; ") + self.assertIn("Max-Age=3", cookie_opt) + + cookie = self._check_and_get_cookie(resp) + self.assertLess(self._java_script_sleep_time(cookie[1]), 3) + time.sleep(3.1) + + client.send_request( + client.create_request( + method="GET", + headers=[("accept", "text/html"), ("cookie", f"{cookie[0]}={cookie[1]}")], + ), + "503", + ) + + self.assertTrue( + self.klog.find("http_sess: sticky cookie value expired", cond=dmesg.amount_equals(1)), + 1, ) diff --git a/sessions/test_learn.py b/sessions/test_learn.py index 862875279..5ff5d8486 100644 --- a/sessions/test_learn.py +++ b/sessions/test_learn.py @@ -1,6 +1,7 @@ import re from framework import tester +from helpers import dmesg __author__ = "Tempesta Technologies, Inc." __copyright__ = "Copyright (C) 2019 Tempesta Technologies, Inc." @@ -13,7 +14,74 @@ ATTEMPTS = 64 -class LearnSessions(tester.TempestaTest): +class LearnSessionsBase(tester.TempestaTest): + def wait_all_connections(self, tmt=1): + sids = self.get_servers_id() + for sid in sids: + srv = self.get_server(sid) + if not srv.wait_for_connections(timeout=tmt): + return False + return True + + def reconfigure_responses(self, sid_resp_sent): + for sid in ["server-1", "server-2", "server-3"]: + srv = self.get_server(sid) + if not srv: + continue + if sid == sid_resp_sent: + status = "200 OK" + else: + status = "503 Service Unavailable" + srv.set_response( + "HTTP/1.1 %s\r\n" "Server-id: %s\r\n" "Content-Length: 0\r\n\r\n" % (status, sid) + ) + + def client_send_req(self, client, req): + curr_responses = len(client.responses) + client.make_request(req) + client.wait_for_response(timeout=1) + self.assertEqual(curr_responses + 1, len(client.responses)) + + return client.responses[-1] + + def client_send_first_req(self, client): + req = "GET / HTTP/1.1\r\n" "Host: localhost\r\n" "\r\n" + response = self.client_send_req(client, req) + + self.assertEqual(response.status, "200", "unexpected response status code") + c_header = response.headers.get("Set-Cookie", None) + self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") + match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) + self.assertIsNotNone(match, "Cant extract value from Set-Cookie header") + cookie = (match.group(1), match.group(2)) + + s_id = response.headers.get("Server-id", None) + self.assertIsNotNone(s_id, "Server-id header is missing in the response") + + return (s_id, cookie) + + def client_send_next_req(self, client, cookie): + req = ( + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Cookie: %s=%s\r\n" + "\r\n" % (cookie[0], cookie[1]) + ) + response = self.client_send_req(client, req) + self.assertEqual(response.status, "200", "unexpected response status code") + s_id = response.headers.get("Server-id", None) + self.assertIsNotNone(s_id, "Server-id header is missing in the response") + return s_id + + def start_all(self): + self.start_all_servers() + self.start_tempesta() + self.start_all_clients() + self.deproxy_manager.start() + self.assertTrue(self.wait_all_connections(1)) + + +class LearnSessions(LearnSessionsBase): """ When a learn option is enabled, then backend server sets a cookie for the client and Tempesta creates a session entry for that cookie. All the @@ -94,30 +162,6 @@ def reconfigure_responses(self, sid_resp_sent): "HTTP/1.1 %s\r\n" "Server-id: %s\r\n" "Content-Length: 0\r\n\r\n" % (status, sid) ) - def client_send_req(self, client, req): - curr_responses = len(client.responses) - client.make_request(req) - client.wait_for_response(timeout=1) - self.assertEqual(curr_responses + 1, len(client.responses)) - - return client.responses[-1] - - def client_send_first_req(self, client): - req = "GET / HTTP/1.1\r\n" "Host: localhost\r\n" "\r\n" - response = self.client_send_req(client, req) - - self.assertEqual(response.status, "200", "unexpected response status code") - c_header = response.headers.get("Set-Cookie", None) - self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") - match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) - self.assertIsNotNone(match, "Cant extract value from Set-Cookie header") - cookie = (match.group(1), match.group(2)) - - s_id = response.headers.get("Server-id", None) - self.assertIsNotNone(s_id, "Server-id header is missing in the response") - - return (s_id, cookie) - def client_send_next_req(self, client, cookie): req = ( "GET / HTTP/1.1\r\n" @@ -183,6 +227,148 @@ def test_backend_fail(self): self.assertEqual(s_id, new_s_id, "Sticky session was forwarded to not-pinned server") +class LearnSessionsMultipleSameSetCookie(LearnSessionsBase): + """ + RFC 6265 4.1.1: + Servers SHOULD NOT include more than one Set-Cookie header + field in the same response with the same cookie-name + """ + + backends = [ + { + "id": "server-1", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" + "Server-id: server-1\r\n" + "Set-Cookie: client-id=jdsfhrkfj53542njfnjdmdnvjs45343n4nn4b54m\r\n" + "Set-Cookie: client-id=543543kjhkjdg445345579gfjdjgkdcedhfbrh12\r\n" + "Set-Cookie: client-id=432435645jkfsdhfksjdhfjkd54675jncjnsddjk\r\n" + "Content-Length: 0\r\n\r\n", + } + ] + + tempesta = { + "config": """ + server ${server_ip}:8000; + sticky { + learn name=client-id; + } + + """ + } + + clients = [ + { + "id": "deproxy", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + } + ] + + def setUp(self): + super().setUp() + self.klog = dmesg.DmesgFinder(disable_ratelimit=True) + self.assert_msg = "Expected nums of warnings in `journalctl`: {exp}, but got {got}" + # Cleanup part + self.addCleanup(self.cleanup_klog) + + def cleanup_klog(self): + if hasattr(self, "klog"): + del self.klog + + @dmesg.unlimited_rate_on_tempesta_node + def test(self): + """ + Check that we stop processing Set-Cookie header if there are + more than one Set-Cookie header field in the same response with + the same cookie-name, but don't drop response, just write warning + in dmesg. + """ + self.start_all() + client = self.get_client("deproxy") + + req = "GET / HTTP/1.1\r\n" "Host: localhost\r\n" "\r\n" + response = self.client_send_req(client, req) + self.assertEqual(response.status, "200", "unexpected response status code") + + self.assertTrue( + self.klog.find( + "Multiple sticky cookies found in response: 2", cond=dmesg.amount_equals(1) + ), + 1, + ) + + +class LearnSessionsMultipleDiffSetCookie(LearnSessions): + """ + Same as LearnSessions but multiple Set-Cookie headers + """ + + backends = [ + { + "id": "server-1", + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" + "Server-id: server-1\r\n" + "Set-Cookie: client-id=jdsfhrkfj53542njfnjdmdnvjs45343n4nn4b54m\r\n" + "Set-Cookie: cookie1=server11\r\n" + "Set-Cookie: cookie2=server12\r\n" + "Content-Length: 0\r\n\r\n", + }, + { + "id": "server-2", + "type": "deproxy", + "port": "8001", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" + "Server-id: server-2\r\n" + "Set-Cookie: client-id=543543kjhkjdg445345579gfjdjgkdcedhfbrh12\r\n" + "Set-Cookie: cookie1=server21\r\n" + "Set-Cookie: cookie2=server22\r\n" + "Content-Length: 0\r\n\r\n", + }, + { + "id": "server-3", + "type": "deproxy", + "port": "8002", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" + "Server-id: server-3\r\n" + "Set-Cookie: client-id=432435645jkfsdhfksjdhfjkd54675jncjnsddjk\r\n" + "Set-Cookie: cookie1=server31\r\n" + "Set-Cookie: cookie2=server32\r\n" + "Content-Length: 0\r\n\r\n", + }, + ] + + tempesta = { + "config": """ + server ${server_ip}:8000; + server ${server_ip}:8001; + server ${server_ip}:8002; + + sticky { + learn name=client-id; + } + + """ + } + + clients = [ + { + "id": "deproxy", + "type": "deproxy", + "addr": "${tempesta_ip}", + "port": "80", + } + ] + + class LearnSessionsVhost(LearnSessions): """Same as LearnSessions, but 'sticky' configuration is inherited from updated defaults for a named vhost. diff --git a/sessions/test_redir_mark.py b/sessions/test_redir_mark.py deleted file mode 100644 index c586dccb1..000000000 --- a/sessions/test_redir_mark.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -Redirection marks tests. -""" - -import random -import re -import string -import time - -from framework import tester -from helpers import tf_cfg - -__author__ = "Tempesta Technologies, Inc." -__copyright__ = "Copyright (C) 2019 Tempesta Technologies, Inc." -__license__ = "GPL2" - -DFLT_COOKIE_NAME = "__tfw" - - -class BaseRedirectMark(tester.TempestaTest): - backends = [ - { - "id": "server", - "type": "deproxy", - "port": "8000", - "response": "static", - "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", - } - ] - - clients = [ - { - "id": "deproxy", - "type": "deproxy", - "addr": "${tempesta_ip}", - "port": "80", - } - ] - - def client_expect_block(self, client, req): - curr_responses = len(client.responses) - client.make_request(req) - client.wait_for_response(timeout=2) - self.assertEqual(curr_responses, len(client.responses)) - self.assertTrue(client.connection_is_closed()) - - def client_send_req(self, client, req): - curr_responses = len(client.responses) - client.make_request(req) - client.wait_for_response(timeout=1) - self.assertEqual(curr_responses + 1, len(client.responses)) - - return client.responses[-1] - - def client_send_custom_req(self, client, uri, cookie): - req = ( - "GET %s HTTP/1.1\r\n" - "Host: localhost\r\n" - "%s" - "\r\n" % (uri, ("Cookie: %s=%s\r\n" % cookie) if cookie else "") - ) - response = self.client_send_req(client, req) - - self.assertEqual(response.status, "302", "unexpected response status code") - c_header = response.headers.get("Set-Cookie", None) - self.assertIsNotNone(c_header, "Set-Cookie header is missing in the response") - match = re.search(r"([^;\s]+)=([^;\s]+)", c_header) - self.assertIsNotNone(match, "Cant extract value from Set-Cookie header") - cookie = (match.group(1), match.group(2)) - - # Checking default path option of cookies - match = re.search(r"path=([^;\s]+)", c_header) - self.assertIsNotNone(match, "Cant extract path from Set-Cookie header") - self.assertEqual(match.group(1), "/") - - uri = response.headers.get("Location", None) - self.assertIsNotNone(uri, "Location header is missing in the response") - return (uri, cookie) - - def client_send_first_req(self, client, uri): - return self.client_send_custom_req(client, uri, None) - - def start_all(self): - self.start_all_servers() - self.start_tempesta() - self.start_all_clients() - self.deproxy_manager.start() - self.assertTrue(self.wait_all_connections(1)) - - -class RedirectMark(BaseRedirectMark): - """ - Sticky cookies are not enabled on Tempesta, so all clients may access the - requested resources. No cookie challenge is used to check clients behaviour. - """ - - tempesta = { - "config": """ - server ${server_ip}:8000; - - sticky { - cookie enforce max_misses=5; - } - """ - } - - def test_good_rmark_value(self): - """Client fully process the challenge: redirect is followed correctly, - cookie is set correctly, so the client can get the requested resources. - """ - self.start_all() - - client = self.get_client("deproxy") - uri = "/" - uri, cookie = self.client_send_first_req(client, uri) - uri, _ = self.client_send_custom_req(client, uri, cookie) - hostname = tf_cfg.cfg.get("Tempesta", "hostname") - self.assertEqual(uri, "http://%s/" % hostname) - - req = ( - "GET %s HTTP/1.1\r\n" - "Host: localhost\r\n" - "Cookie: %s=%s\r\n" - "\r\n" % (uri, cookie[0], cookie[1]) - ) - response = self.client_send_req(client, req) - self.assertEqual(response.status, "200", "unexpected response status code") - - def test_rmark_wo_or_incorrect_cookie(self): - """ - A client sending more than 5 requests without cookies or incorrect - cookies is blocked. For example a bot which can follow redirects, but - can't set cookies, must be blocked after 5 attempts. - """ - self.start_all() - - client = self.get_client("deproxy") - uri = "/" - uri, cookie = self.client_send_first_req(client, uri) - cookie = ( - cookie[0], - "".join(random.choice(string.hexdigits) for i in range(len(cookie[1]))), - ) - uri, _ = self.client_send_custom_req(client, uri, cookie) - uri, cookie = self.client_send_first_req(client, uri) - cookie = ( - cookie[0], - "".join(random.choice(string.hexdigits) for i in range(len(cookie[1]))), - ) - uri, _ = self.client_send_custom_req(client, uri, cookie) - uri, _ = self.client_send_first_req(client, uri) - - req = "GET %s HTTP/1.1\r\n" "Host: localhost\r\n" "\r\n" % uri - self.client_expect_block(client, req) - - def test_rmark_invalid(self): - # Requests w/ incorrect rmark and w/o cookies, must be blocked - self.start_all() - - client = self.get_client("deproxy") - uri = "/" - uri, _ = self.client_send_first_req(client, uri) - m = re.match(r"(.*=)([0-9a-f]*)(/)", uri) - - req = ( - "GET %s HTTP/1.1\r\n" - "Host: localhost\r\n" - "\r\n" - % ( - m.group(1) - + "".join(random.choice(string.hexdigits) for i in range(len(m.group(2)))) - + m.group(3) - ) - ) - self.client_expect_block(client, req) - - -class RedirectMarkVhost(RedirectMark): - """Same as RedirectMark, but , but 'sticky' configuration is inherited from - updated defaults for a named vhost. - """ - - tempesta = { - "config": """ - srv_group vh_1_srvs { - server ${server_ip}:8000; - } - - # Update defaults two times, only the last one must be applied. - sticky { - cookie name=c_vh2 enforce; - } - sticky { - cookie enforce max_misses=5; - } - - vhost vh_1 { - proxy_pass vh_1_srvs; - } - - http_chain { - -> vh_1; - } - """ - } - - -class RedirectMarkTimeout(BaseRedirectMark): - """ - Current count of redirected requests should be reset if time has - passed more than timeout cookie option. - """ - - tempesta = { - "config": """ - server ${server_ip}:8000; - - sticky { - cookie enforce max_misses=5 timeout=2; - } - """ - } - - def test(self): - self.start_all() - - client = self.get_client("deproxy") - uri = "/" - uri, _ = self.client_send_first_req(client, uri) - uri, _ = self.client_send_first_req(client, uri) - uri, _ = self.client_send_first_req(client, uri) - uri, _ = self.client_send_first_req(client, uri) - uri, _ = self.client_send_first_req(client, uri) - - tf_cfg.dbg(3, "Sleep until cookie timeout get expired...") - time.sleep(3) - - uri, cookie = self.client_send_first_req(client, uri) - uri, _ = self.client_send_custom_req(client, uri, cookie) - hostname = tf_cfg.cfg.get("Tempesta", "hostname") - self.assertEqual(uri, "http://%s/" % hostname) - - req = ( - "GET %s HTTP/1.1\r\n" - "Host: localhost\r\n" - "Cookie: %s=%s\r\n" - "\r\n" % (uri, cookie[0], cookie[1]) - ) - response = self.client_send_req(client, req) - self.assertEqual(response.status, "200", "unexpected response status code") - - -class RedirectMarkTimeoutVhost(RedirectMarkTimeout): - """Same as RedirectMarkTimeout, but , but 'sticky' configuration is - inherited from updated defaults for a named vhost. - """ - - tempesta = { - "config": """ - srv_group vh_1_srvs { - server ${server_ip}:8000; - } - - # Update defaults two times, only the last one must be applied. - sticky { - cookie name=c_vh2 enforce; - } - sticky { - cookie enforce max_misses=5 timeout=2; - } - - vhost vh_1 { - proxy_pass vh_1_srvs; - } - - http_chain { - -> vh_1; - } - """ - } diff --git a/t_frang/test_whitelist_mark.py b/t_frang/test_whitelist_mark.py index df6aa7e09..ad12c0087 100644 --- a/t_frang/test_whitelist_mark.py +++ b/t_frang/test_whitelist_mark.py @@ -63,7 +63,7 @@ class FrangWhitelistMarkTestCase(NetfilterMarkMixin, tester.TempestaTest): sticky { cookie enforce name=cname; js_challenge resp_code=503 delay_min=1000 delay_range=1500 - delay_limit=100 ${tempesta_workdir}/js_challenge.html; + ${tempesta_workdir}/js_challenge.html; } proxy_pass sg1; } @@ -162,5 +162,5 @@ def test_non_whitelisted_request_are_js_challenged(self): client.start() client.send_request( client.create_request(uri="/", method="GET", headers=[]), - expected_status_code="503", + expected_status_code="403", ) diff --git a/t_http_rules/test_http_tables.py b/t_http_rules/test_http_tables.py index 8f36844ab..2a0252090 100644 --- a/t_http_rules/test_http_tables.py +++ b/t_http_rules/test_http_tables.py @@ -3,8 +3,9 @@ (via sereral HTTP chains). Mark rules and match rules are also tested here (in separate tests). """ -from framework import tester -from framework.parameterize import param, parameterize + +from framework import deproxy_client, tester +from framework.parameterize import param, parameterize, parameterize_class from helpers import chains, dmesg, remote from helpers.networker import NetWorker from helpers.remote import LocalNode @@ -78,11 +79,11 @@ class HttpTablesTest(tester.TempestaTest): "config": """ listen 80; listen 443 proto=h2; - + tls_certificate ${tempesta_workdir}/tempesta.crt; tls_certificate_key ${tempesta_workdir}/tempesta.key; tls_match_any_server_name; - + block_action attack reply; srv_group grp1 { server ${server_ip}:8000; @@ -706,4 +707,174 @@ def __test_reject_by_mark(self, client, server): self.assertTrue(client.last_response.status, "200") +@parameterize_class( + [ + { + "name": "Http", + "clients": [{"id": 0, "type": "deproxy", "addr": "${tempesta_ip}", "port": "80"}], + }, + { + "name": "H2", + "clients": [ + { + "id": 0, + "type": "deproxy_h2", + "addr": "${tempesta_ip}", + "port": "443", + "ssl": True, + } + ], + }, + ] +) +class HttpTablesTestMultipleCookies(tester.TempestaTest): + backends = [ + { + "id": 0, + "type": "deproxy", + "port": "8000", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", + }, + { + "id": 1, + "type": "deproxy", + "port": "8001", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", + }, + { + "id": 4, + "type": "deproxy", + "port": "8004", + "response": "static", + "response_content": "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n\r\n", + }, + ] + + tempesta = { + "config": """ + listen 80; + listen 443 proto=h2; + tls_certificate ${tempesta_workdir}/tempesta.crt; + tls_certificate_key ${tempesta_workdir}/tempesta.key; + tls_match_any_server_name; + block_action attack reply; + srv_group grp1 { + server ${server_ip}:8000; + } + srv_group grp2 { + server ${server_ip}:8001; + } + srv_group grp5 { + server ${server_ip}:8004; + } + vhost vh1 { + proxy_pass grp1; + } + vhost vh2 { + proxy_pass grp2; + } + vhost vh5 { + proxy_pass grp5; + } + http_chain { + cookie "tempesta_good" == "*" -> vh2; + cookie "tempesta" == "good" -> vh1; + cookie "tempesta_bad" == "*" -> block; + cookie "tempesta" == "bad" -> block; + cookie "*good_suffix" == "g*" -> vh2; + cookie "good_prefix*" == "g*" -> vh2; + -> vh5; + } + """ + } + + @parameterize.expand( + [ + param( + name="tempesta_good", + cookie=["rule1=action1", "rule2=action2", "tempesta=good"], + expected_status_code="200", + server_id=0, + ), + param( + name="tempesta_good_is_last", + cookie=["rule1=action1", "tempesta_bad=test", "tempesta_good=test"], + expected_status_code="200", + server_id=1, + ), + param( + name="tempesta_bad", + cookie=["rule1=action1", "tempesta_bad=test", "rule2=action2"], + expected_status_code="403", + server_id=4, + ), + param( + name="tempesta_bad_2", + cookie=["rule1=action1", "tempesta=bad", "rule2=action2"], + expected_status_code="403", + server_id=4, + ), + param( + name="no_rules_matched", + cookie=["tempesta=action1", "tempesta=test", "tempesta=action2"], + expected_status_code="200", + server_id=4, + ), + param( + name="good_suffix", + cookie=[ + "aaa_good_suffix_1=ggg", + "bbb_good_suffix=gaction", + "ccc_good_suffix=action1", + ], + expected_status_code="200", + server_id=1, + ), + param( + name="good_prefix", + cookie=["good_prefix_1=ggg", "1_good_prefix=gaction", "good_prefix=gaction2"], + expected_status_code="200", + server_id=1, + ), + ] + ) + def test_cookie(self, name, cookie, expected_status_code, server_id): + """Test for matching rules in HTTP chains: according to + test configuration of HTTP tables, requests must be + forwarded to the right vhosts according to it's + headers content. + """ + self.start_all_services() + client = self.get_client(0) + server = self.get_server(server_id) + + """ + We match rules from http chain in the order in which they are defined. + When one of the rule is matched we stop this process and apply the rule. + For HTTP1 we can set several cookie values in one cookie header separated + by ';' + """ + if isinstance(client, deproxy_client.DeproxyClient): + headers = [("cookie", "; ".join(cookie))] + else: + headers = [("cookie", cookie) for cookie in cookie] + + client.send_request( + request=client.create_request(method="GET", uri="/baz/index.html", headers=headers), + expected_status_code=expected_status_code, + ) + + if expected_status_code == "200": + self.assertIsNotNone(server.last_request) + # Check if the connection alive (general case) or + # not (case of 'block' rule matching) after the main + # message processing. Response 404 is enough here. + client.send_request(client.create_request(method="GET", headers=[]), "200") + else: + self.assertIsNone(server.last_request) + self.assertTrue(client.wait_for_connection_close()) + + # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/tests_disabled.json b/tests_disabled.json index 72f6a2765..c5dab22ae 100644 --- a/tests_disabled.json +++ b/tests_disabled.json @@ -130,8 +130,8 @@ "reason": "Disable by issue #1714" }, { - "name" : "sessions.test_sticky_sess_stress.OneClient", - "reason" : "Disabled by issue #398" + "name" : "t_sites.test_wordpress.TestWordpressSite.test_blog_post_flow", + "reason": "Disable by issue #1692" }, { "name": "t_frang.test_connection_rate_burst.FrangTcp.test_connection_rate", @@ -376,6 +376,14 @@ { "name": "t_frang.test_concurrent_connections.ConcurrentConnections.test_clear_client_connection_stats_greater", "reason": "Disabled by issue #2084" + }, + { + "name": "sessions.test_js_challenge.JSChallengeHttp.test_method_override_HEAD_GET", + "reason": "Disabled by issue #613" + }, + { + "name": "sessions.test_js_challenge.JSChallengeH2.test_method_override_HEAD_GET", + "reason": "Disabled by issue #613" } ] } diff --git a/tests_disabled_remote.json b/tests_disabled_remote.json index 8bcd722bf..fc9b62b3b 100644 --- a/tests_disabled_remote.json +++ b/tests_disabled_remote.json @@ -53,6 +53,14 @@ "name": "t_frang.test_http_resp_code_block.HttpRespCodeBlock.test_two_clients_one_ip", "reason": "These tests should not be run with remote setup. Tempesta blocks ip and ssh does not work." }, + { + "name": "sessions.test_cookies.StickyCookieConfigGlobal", + "reason": "These tests should not be run with remote setup." + }, + { + "name": "sessions.test_cookies.StickyCookieConfigVhost", + "reason": "These tests should not be run with remote setup." + }, { "name": "t_modify_http_headers.test_logic.TestManyRequestHeaders.test_many_headers", "reason": "Disabled by issue #1103" @@ -129,10 +137,6 @@ "name": "t_frang.test_connection_rate_burst.FrangTcpVsBoth.test_rate", "reason": "Disabled by test issue #529." }, - { - "name": "sessions.test_redir_mark", - "reason": "Disabled by issue #1102." - }, { "name": "very_many_backends.test_deadtime_1M.RemovingBackendSG", "reason": "Disabled by test issue #56." diff --git a/tests_disabled_tcpseg.json b/tests_disabled_tcpseg.json index 7fb6bf040..6615ecfb7 100644 --- a/tests_disabled_tcpseg.json +++ b/tests_disabled_tcpseg.json @@ -89,6 +89,14 @@ "name": "t_frang.test_client_body_and_header_timeout", "reason": "These tests should not be run with TCP segmentation." }, + { + "name": "sessions.test_cookies.StickyCookieConfigGlobal", + "reason": "These tests should not be run with TCP segmentation." + }, + { + "name": "sessions.test_cookies.StickyCookieConfigVhost", + "reason": "These tests should not be run with TCP segmentation." + }, { "name": "reconf", "reason": "These tests should not be run with TCP segmentation." diff --git a/tls/test_tls_limits.py b/tls/test_tls_limits.py index a1a46ae80..baf89b239 100644 --- a/tls/test_tls_limits.py +++ b/tls/test_tls_limits.py @@ -99,5 +99,5 @@ def test_host_sni_mismatch(self): self.assertFalse(deproxy_cl.wait_for_response()) self.assertEqual(2, len(deproxy_cl.responses)) - self.assertTrue(deproxy_cl.connection_is_closed()) + self.assertTrue(deproxy_cl.wait_for_connection_close()) self.assertTrue(klog.find(self.TLS_WARN), "Frang limits warning is not shown") diff --git a/wrk/cookie-many-clients.lua b/wrk/cookie-many-clients.lua index c85f6a5a1..7d7ed6908 100644 --- a/wrk/cookie-many-clients.lua +++ b/wrk/cookie-many-clients.lua @@ -15,7 +15,7 @@ end local_response = function(status, headers, body) if not cookie and status == 302 then - cookie = headers["Set-Cookie"] + cookie = headers["set-cookie"] wrk.headers["Cookie"] = cookie end end diff --git a/wrk/cookie-one-client.lua b/wrk/cookie-one-client.lua index f875979b8..89b856fd7 100644 --- a/wrk/cookie-one-client.lua +++ b/wrk/cookie-one-client.lua @@ -12,7 +12,7 @@ end local_response = function(status, headers, body) if not cookie and status == 302 then - cookie = headers["Set-Cookie"] + cookie = headers["set-cookie"] wrk.headers["Cookie"] = cookie end end