From: Giannis Christodoulou Date: Thu, 4 Jun 2026 06:55:13 +0000 (+0000) Subject: mod_proxy: add response handling tests X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=291000fb9484f150b98ddc1169ea8276ad15698a;p=thirdparty%2Fapache%2Fhttpd.git mod_proxy: add response handling tests Add coverage for backend Content-Type handling, X-Forwarded-* headers and request body preservation during balancer failover. Add a simple TCP backend faker used by the tests. git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1934963 13f79535-47bb-0310-9956-ffa450edef68 --- diff --git a/test/modules/proxy/env.py b/test/modules/proxy/env.py index 098d4d4948..92e85ba9fc 100644 --- a/test/modules/proxy/env.py +++ b/test/modules/proxy/env.py @@ -1,22 +1,72 @@ import inspect import logging import os -import subprocess -from typing import Dict, Any +import socket +from threading import Thread from pyhttpd.certs import CertificateSpec -from pyhttpd.conf import HttpdConf + from pyhttpd.env import HttpdTestEnv, HttpdTestSetup log = logging.getLogger(__name__) +class TCPFaker: + # tcp backend for custom responses + + def __init__(self, host, port): + self._thread = None + self._socket = None + self._host = host + self._port = port + self._done = False + + def start(self): + def process(): + self._socket.listen(1) + self._socket.settimeout(0.5) + self._process() + + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind((self._host, self._port)) + self._thread = Thread(target=process, daemon=True) + self._thread.start() + + def stop(self): + self._done = True + self._thread.join(timeout=5) + self._socket.close() + + def _make_response(self, data): + return """HTTP/1.1 200 OK +Content-Type: text/html +Content-Length: 5 + +Hello""".encode() + + def _process(self): + while not self._done: + try: + c, client_address = self._socket.accept() + try: + data = c.recv(4096) + c.sendall(self._make_response(data)) + finally: + c.close() + except socket.timeout: + pass + except ConnectionAbortedError: + self._done = True + + class ProxyTestSetup(HttpdTestSetup): def __init__(self, env: 'HttpdTestEnv'): super().__init__(env=env) self.add_source_dir(os.path.dirname(inspect.getfile(ProxyTestSetup))) - self.add_modules(["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests"]) + self.add_modules(["proxy", "proxy_http", "proxy_ajp", "proxy_balancer", + "lbmethod_byrequests", "remoteip"]) class ProxyTestEnv(HttpdTestEnv): @@ -24,17 +74,20 @@ class ProxyTestEnv(HttpdTestEnv): def __init__(self, pytestconfig=None): super().__init__(pytestconfig=pytestconfig) self.add_httpd_conf([ - ]) + ]) self._d_reverse = f"reverse.{self.http_tld}" self._d_forward = f"forward.{self.http_tld}" self._d_mixed = f"mixed.{self.http_tld}" - self.add_httpd_log_modules(["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests", "ssl"]) + self.add_httpd_log_modules( + ["proxy", "proxy_http", "proxy_balancer", "lbmethod_byrequests", + "ssl"]) self.add_cert_specs([ CertificateSpec(domains=[ self._d_forward, self._d_reverse, self._d_mixed ]), - CertificateSpec(domains=[f"noh2.{self.http_tld}"], key_type='rsa2048'), + CertificateSpec(domains=[f"noh2.{self.http_tld}"], + key_type='rsa2048'), ]) def setup_httpd(self, setup: HttpdTestSetup = None): diff --git a/test/modules/proxy/test_03_response.py b/test/modules/proxy/test_03_response.py new file mode 100644 index 0000000000..60a4afa8af --- /dev/null +++ b/test/modules/proxy/test_03_response.py @@ -0,0 +1,124 @@ +import re + +import pytest + +from pyhttpd.conf import HttpdConf +from .env import TCPFaker + + +class _ResponseFaker(TCPFaker): + + def _make_response(self, data): + first_line = data.split(b"\r\n")[0].decode("latin-1") + path = first_line.split(" ")[1] if " " in first_line else "/" + + if "/content-type" in path: + return """HTTP/1.1 200 OK\r\nContent-Type: + text/html\x00extra\r\n\r\n""".encode() + + if "/forwarded" in path: + headers = data.split(b"\r\n\r\n")[0].decode("latin-1") + forwarded = [] + for line in headers.split("\r\n"): + if line.lower().startswith("x-forwarded-"): + forwarded.append(line) + return ("HTTP/1.1 200 OK\r\n" + "\r\n".join( + forwarded) + "\r\n\r\n").encode() + + if "/balancer" in path: + headers = data.split(b"\r\n\r\n")[0].decode("latin-1") + body_lines = [] + for line in headers.split("\r\n"): + if ": " in line: + name, value = line.split(": ", 1) + body_lines.append(f"{name.upper()} = {value}") + body = "\r\n".join(body_lines) + return ( + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + f"Content-Length: {len(body.encode())}\r\n" + "\r\n" + + body + ).encode() + + return super()._make_response(data) + + +class TestProxyResponse: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + faker = _ResponseFaker("127.0.0.1", env.http_port2) + faker.start() + + conf = HttpdConf(env) + conf.add([ + '', + f' BalancerMember "http://127.0.0.1:{env.http_port2 + 1}"', + f' BalancerMember "http://127.0.0.1:{env.http_port2}"', + ' ProxySet forcerecovery=Off', + '', + ]) + conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port, + doc_root="htdocs", with_ssl=False) + conf.add([ + "ProxyPass /balancer balancer://xqacluster/balancer", + f"ProxyPass / http://127.0.0.1:{env.http_port2}/", + f"ProxyPassReverse / http://127.0.0.1:{env.http_port2}/", + ]) + if env.has_shared_module("remoteip"): + conf.add([ + "RemoteIPHeader X-Forwarded-For", + "RemoteIPTrustedProxy 0.0.0.0", + ]) + conf.end_vhost() + conf.install() + assert env.apache_restart() == 0 + yield + faker.stop() + + # check Content-Type + def test_proxy_03_001(self, env): + if not env.httpd_is_at_least("2.4.55"): + pytest.skip(f'need at least httpd 2.4.55 for this') + + r = env.curl_get(env.mkurl("http", "test1", "/content-type")) + assert r.response["status"] == 502 + + env.httpd_error_log.ignore_recent( + lognos=["AH01106", "AH10404"] + ) + + # checks X-Forwarded headers + def test_proxy_03_002(self, env): + if not env.httpd_is_at_least("2.4.54"): + pytest.skip(f'need at least httpd 2.4.54 for this') + + r = env.curl_get(env.mkurl("http", "test1", "/forwarded"), options=[ + '-H', 'Connection: close, X-Forwarded-For, X-Forwarded-Host, ' + 'X-Forwarded-Server', + ]) + assert r.response["status"] == 200 + assert "x-forwarded-for" in r.response["header"] + assert "x-forwarded-host" in r.response["header"] + assert "x-forwarded-server" in r.response["header"] + + # check body is preserved after failover + def test_proxy_03_003(self, env): + if not env.httpd_is_at_least("2.4.42"): + pytest.skip(f'need at least httpd 2.4.42 for this') + + post_body = "123testing" + r = env.curl_get( + env.mkurl("http", "test1", "/balancer"), + options=['--data', post_body, '-H', 'Content-Type: text/plain; ' + 'charset=utf-8'] + ) + assert r.response["status"] == 200 + body = r.response["body"].decode("latin-1") + m = re.search(r'CONTENT-LENGTH = (\d+)', body, re.IGNORECASE) + assert m is not None + assert int(m.group(1)) == len(post_body) + env.httpd_error_log.ignore_recent( + lognos=["AH00957", "AH00959", "AH01114"] + )