diff --git a/test/modules/core/env.py b/test/modules/core/env.py index 21906be7361..272cb220534 100644 --- a/test/modules/core/env.py +++ b/test/modules/core/env.py @@ -12,14 +12,16 @@ class CoreTestSetup(HttpdTestSetup): def __init__(self, env: 'HttpdTestEnv'): super().__init__(env=env) self.add_source_dir(os.path.dirname(inspect.getfile(CoreTestSetup))) - self.add_modules(["cgid","include","userdir","suexec","headers"]) + self.add_modules(["cgid","include","userdir","suexec","headers", + "actions","asis","info","status","mime", + "negotiation"]) class CoreTestEnv(HttpdTestEnv): def __init__(self, pytestconfig=None): super().__init__(pytestconfig=pytestconfig) - self.add_httpd_log_modules(["http", "core"]) + self.add_httpd_log_modules(["http", "core", "info", "status"]) def setup_httpd(self, setup: HttpdTestSetup = None): super().setup_httpd(setup=CoreTestSetup(env=self)) diff --git a/test/modules/core/htdocs/cgi/env_parameters.py b/test/modules/core/htdocs/cgi/env_parameters.py index c08d1b6990d..15766c6e459 100644 --- a/test/modules/core/htdocs/cgi/env_parameters.py +++ b/test/modules/core/htdocs/cgi/env_parameters.py @@ -9,6 +9,7 @@ data = { "REQUEST_METHOD": os.getenv("REQUEST_METHOD", ""), "QUERY_STRING": os.getenv("QUERY_STRING", ""), + "HTTP_PROXY": os.getenv("HTTP_PROXY", ""), } print(json.dumps(data, indent=2)) diff --git a/test/modules/core/htdocs/test1/actionstest/dummy.chtml b/test/modules/core/htdocs/test1/actionstest/dummy.chtml new file mode 100644 index 00000000000..96b0094f5db --- /dev/null +++ b/test/modules/core/htdocs/test1/actionstest/dummy.chtml @@ -0,0 +1 @@ +This is a dummy file for testing mod_actions. diff --git a/test/modules/core/htdocs/test1/asistest/example.ahtml b/test/modules/core/htdocs/test1/asistest/example.ahtml new file mode 100644 index 00000000000..5499effe115 --- /dev/null +++ b/test/modules/core/htdocs/test1/asistest/example.ahtml @@ -0,0 +1,4 @@ +Status: 301 Moved +Location: http://example.com/ + +This has moved. diff --git a/test/modules/core/htdocs/test1/cgi/handler.py b/test/modules/core/htdocs/test1/cgi/handler.py new file mode 100755 index 00000000000..b1d36c4e86b --- /dev/null +++ b/test/modules/core/htdocs/test1/cgi/handler.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +print("Content-Type: text/plain") +print() +print("this file was processed") diff --git a/test/modules/core/test_003_cgi_env_vars.py b/test/modules/core/test_003_cgi_env_vars.py index f82a5c3764e..a27120e80b4 100644 --- a/test/modules/core/test_003_cgi_env_vars.py +++ b/test/modules/core/test_003_cgi_env_vars.py @@ -21,13 +21,19 @@ def _class_scope(self, env): assert env.apache_restart() == 0 def test_cgi_003_01(self, env): - """ - CVE-2025-65082: - Configuration-defined env vars must not override - server-calculated CGI env vars. - """ + """Configuration-defined env vars must not override + server-calculated CGI env vars.""" url = env.mkurl("http", "cgi", "/env_parameters.py?x=123") r = env.curl_get(url) assert r.response["status"] == 200 assert r.response["json"]["REQUEST_METHOD"] == "GET" assert r.response["json"]["QUERY_STRING"] == "x=123" + + def test_cgi_003_02(self, env): + """A client-supplied Proxy header must not be propagated as + HTTP_PROXY to CGI scripts (httpoxy).""" + url = env.mkurl("http", "cgi", "/env_parameters.py") + r = env.curl_get(url, options=['-H', 'Proxy: http://evil.example.com']) + assert r.response["status"] == 200 + assert r.response["json"]["HTTP_PROXY"] == "", \ + "HTTP_PROXY should be empty — Proxy header must not leak to CGI" diff --git a/test/modules/core/test_010_handlers.py b/test/modules/core/test_010_handlers.py new file mode 100644 index 00000000000..2f0bf4afaf3 --- /dev/null +++ b/test/modules/core/test_010_handlers.py @@ -0,0 +1,131 @@ +import os +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestHandlers: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + doc_dir = os.path.join(env.server_dir, "htdocs", "test1") + conf = HttpdConf(env, extras={ + 'base': f""" + + SetHandler server-info + Require all granted + + + + SetHandler server-status + Require all granted + + """, + f"test1.{env.http_tld}": f""" + + Options +ExecCGI + AddHandler cgi-script .py + + Action html-cgi /cgi/handler.py + AddHandler html-cgi .chtml + AddHandler send-as-is .ahtml + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + # mod_info: verify server-info handler returns server information page + def test_core_010_01(self, env): + url = env.mkurl("http", "test1", "/our-server-info") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "Apache Server Information" in body + + # mod_info: verify ?list query returns module list + def test_core_010_02(self, env): + url = env.mkurl("http", "test1", "/our-server-info?list") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "Server Module List" in body + + # mod_status: verify basic server-status page + def test_core_010_03(self, env): + url = env.mkurl("http", "test1", "/our-server-status") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "Apache Server Status" in body + + # mod_status: verify extended status shows worker information + def test_core_010_04(self, env): + # Reconfigure with ExtendedStatus On + conf = HttpdConf(env, extras={ + 'base': f""" + ExtendedStatus On + + + SetHandler server-status + Require all granted + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + url = env.mkurl("http", "test1", "/our-server-status") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "requests currently being processed" in body or \ + "idle workers" in body + + # mod_actions: verify Action directive routes request through CGI handler + def test_core_010_05(self, env): + # Re-apply the full config (010_04 changed it) + doc_dir = os.path.join(env.server_dir, "htdocs", "test1") + conf = HttpdConf(env, extras={ + 'base': f""" + + SetHandler server-info + Require all granted + + + SetHandler server-status + Require all granted + + """, + f"test1.{env.http_tld}": f""" + + Options +ExecCGI + AddHandler cgi-script .py + + Action html-cgi /cgi/handler.py + AddHandler html-cgi .chtml + AddHandler send-as-is .ahtml + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + url = env.mkurl("http", "test1", "/actionstest/dummy.chtml") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "this file was processed" in body + + # mod_asis: verify send-as-is handler sends raw response + def test_core_010_06(self, env): + url = env.mkurl("http", "test1", "/asistest/example.ahtml") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 301 diff --git a/test/modules/core/test_011_userdir.py b/test/modules/core/test_011_userdir.py new file mode 100644 index 00000000000..6fc715f5bb2 --- /dev/null +++ b/test/modules/core/test_011_userdir.py @@ -0,0 +1,42 @@ +import os +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestUserDir: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + # Create userdir content that will be served for any ~user request. + # Using UserDir with an absolute path makes every /~user/path resolve + # to {absolute_path}/path, avoiding the need for real system users. + userdir_base = os.path.join(env.server_dir, "htdocs", "userdir") + hello_dir = os.path.join(userdir_base, "anyuser", "hello") + os.makedirs(hello_dir, exist_ok=True) + with open(os.path.join(hello_dir, "world.txt"), "w") as f: + f.write("Hello World!") + + conf = HttpdConf(env, extras={ + 'base': f""" + UserDir "{userdir_base}" + + + AllowOverride None + Require all granted + + """ + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + # UserDir with absolute path: /~anyuser/hello/world.txt serves from + # the configured directory regardless of the username + def test_core_011_01(self, env): + url = env.mkurl("http", "test1", "/~anyuser/hello/world.txt") + r = env.curl_get(url) + assert r.response, f"no response: {r.stderr}" + assert r.response["status"] == 200 + body = r.response["body"].decode() + assert "Hello World!" in body diff --git a/test/modules/core/test_012_malformed_requests.py b/test/modules/core/test_012_malformed_requests.py new file mode 100644 index 00000000000..89bb1f83a71 --- /dev/null +++ b/test/modules/core/test_012_malformed_requests.py @@ -0,0 +1,85 @@ +import os +import re +import pytest + +from pyhttpd.conf import HttpdConf + +class TestNull: + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + conf = HttpdConf(env) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_core_012_01(self, env): + url = f"https://localhost:{env.http_port}/" + env.curl_raw(url, options=[ + '--connect-timeout', '5', '--max-time', '10', '-k', + ]) + +class TestBadRequest: + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + conf = HttpdConf(env, extras={ + 'base': """ + KeepAlive On + Redirect 301 /2bad /destination + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_core_012_02(self, env): + url = env.mkurl("http", "test1", "/2bad") + r = env.curl_raw(url, options=[ + '-H', 'Content-Length: 1', '-v', + '--connect-timeout', '5', '--max-time', '10', + ]) + if r.response is not None: + assert r.response["status"] != 301, \ + "Should not redirect if request body not fully consumed" + assert r.response["status"] in [400, 408], \ + f"Expected 400 or 408, got {r.response['status']}" + else: + assert "Closing connection" in r.stderr or \ + "Connection reset" in r.stderr or \ + r.exit_code != 0, \ + "Connection should be closed, not left intact" + assert "HTTP/1.1 301" not in r.stderr, \ + "Should not redirect if request body not fully consumed" + env.httpd_error_log.ignore_recent( + lognos=["AH10390"], + matches=[r'.*:error\].*', r'.*:warn\].*']) + + +class TestBadOptions: + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + htdocs = os.path.join(env.server_dir, "htdocs", "test1") + ob_dir = os.path.join(htdocs, "badoption") + os.makedirs(ob_dir, exist_ok=True) + with open(os.path.join(ob_dir, ".htaccess"), "w") as f: + f.write("\n\n") + conf = HttpdConf(env, extras={ + f"test1.{env.http_tld}": f""" + + AllowOverride Limit + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_core_012_03(self, env): + """OPTIONS request to directory with invalid Limit directive should + return 500 and log a configuration error, not leak memory.""" + url = env.mkurl("http", "test1", "/badoption/") + r = env.curl_raw(url, options=['-X', 'OPTIONS', '-v']) + assert r.response is not None + assert r.response["status"] == 500 + env.httpd_error_log.ignore_recent( + matches=[r'.*Could not register method.*', + r'.*core:error.*'])