| import socket | |
| from threading import Thread | |
| import pytest | |
| from pyhttpd.conf import HttpdConf | |
| from .env import TCPFaker | |
| class _StatusLineBackend(TCPFaker): | |
| """Backend that sends various status line formats.""" | |
| def __init__(self, host, port, mode="final-status-sep-cr"): | |
| super().__init__(host, port) | |
| self._mode = mode | |
| def _make_response(self, data): | |
| if self._mode == "final-status-sep-cr": | |
| return ( | |
| b"HTTP/1.1 200\rX-Foobar: abc\r\n" | |
| b"Content-Length: 2\r\n" | |
| b"Content-Type: text/plain\r\n" | |
| b"\r\n" | |
| b"OK" | |
| ) | |
| elif self._mode == "interim-status-cr": | |
| return ( | |
| b"HTTP/1.1 103 Early\rX-Foobar: abc\r\n" | |
| b"X-Early: whatever\r\n" | |
| b"\r\n" | |
| b"HTTP/1.1 200 OK\r\n" | |
| b"Content-Length: 2\r\n" | |
| b"Content-Type: text/plain\r\n" | |
| b"\r\n" | |
| b"OK" | |
| ) | |
| elif self._mode == "interim-102": | |
| return ( | |
| b"HTTP/1.1 102 Processing\r\n" | |
| b"\r\n" | |
| b"HTTP/1.1 200 OK\r\n" | |
| b"Content-Length: 2\r\n" | |
| b"Content-Type: text/plain\r\n" | |
| b"\r\n" | |
| b"OK" | |
| ) | |
| return super()._make_response(data) | |
| def _recv_all(sock, timeout=5): | |
| sock.settimeout(timeout) | |
| data = b"" | |
| while True: | |
| try: | |
| chunk = sock.recv(4096) | |
| if not chunk: | |
| break | |
| data += chunk | |
| except socket.timeout: | |
| break | |
| return data | |
| class TestStatusLineCR: | |
| """Verify that bare CR in backend status lines is rejected. | |
| Two cases: | |
| 1. Final response with CR at the separator position (byte 3 of status_line) | |
| 2. Interim 1xx response with CR in the reason phrase | |
| """ | |
| @pytest.fixture(autouse=True, scope='class') | |
| def _class_scope(self, env): | |
| conf = HttpdConf(env) | |
| conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port, | |
| doc_root="htdocs", with_ssl=False) | |
| conf.add([ | |
| f"ProxyPass / http://127.0.0.1:{env.http_port2}/", | |
| f"ProxyPassReverse / http://127.0.0.1:{env.http_port2}/", | |
| ]) | |
| conf.end_vhost() | |
| conf.install() | |
| assert env.apache_restart() == 0 | |
| yield | |
| def test_proxy_06_001_final_status_sep_cr(self, env): | |
| """A final response with CR at status-code separator must not | |
| forward attacker text as the reason phrase.""" | |
| faker = _StatusLineBackend("127.0.0.1", env.http_port2, | |
| mode="final-status-sep-cr") | |
| faker.start() | |
| try: | |
| with socket.create_connection(('localhost', int(env.http_port))) as sock: | |
| req = ( | |
| f"GET / HTTP/1.0\r\n" | |
| f"Host: test1.{env.http_tld}\r\n" | |
| f"\r\n" | |
| ) | |
| sock.sendall(req.encode()) | |
| sock.shutdown(socket.SHUT_WR) | |
| raw = _recv_all(sock) | |
| finally: | |
| faker.stop() | |
| status_line = raw.split(b"\r\n")[0] | |
| assert b"\r" not in status_line[:-1] if status_line.endswith(b"\r") else b"\r" not in status_line, \ | |
| f"bare CR in status line: {status_line!r}" | |
| assert b"X-Foobar" not in status_line, \ | |
| f"attacker text in status line: {status_line!r}" | |
| # Prevent 'f-string expression part cannot include a backslash' | |
| hide_backslash = b'\\r\\n\\r\\n' | |
| assert b"X-Foobar" not in raw.split(b"\r\n\r\n")[0], \ | |
| f"injected header in response headers: {raw.split(hide_backslash)[0]!r}" | |
| env.httpd_error_log.ignore_recent( | |
| lognos=["AH00957", "AH01106", "AH01114"] | |
| ) | |
| def test_proxy_06_002_interim_status_cr(self, env): | |
| """An interim 103 response with CR in the reason phrase must not | |
| forward the bare CR to the client.""" | |
| faker = _StatusLineBackend("127.0.0.1", env.http_port2, | |
| mode="interim-status-cr") | |
| faker.start() | |
| try: | |
| with socket.create_connection(('localhost', int(env.http_port))) as sock: | |
| req = ( | |
| f"GET / HTTP/1.1\r\n" | |
| f"Host: test1.{env.http_tld}\r\n" | |
| f"Connection: close\r\n" | |
| f"\r\n" | |
| ) | |
| sock.sendall(req.encode()) | |
| sock.shutdown(socket.SHUT_WR) | |
| raw = _recv_all(sock) | |
| finally: | |
| faker.stop() | |
| # Split into individual response blocks. The 103 interim response | |
| # comes before the final 200. Check every status line for bare CR. | |
| lines = raw.split(b"\r\n") | |
| for line in lines: | |
| if line.startswith(b"HTTP/"): | |
| assert b"\r" not in line, \ | |
| f"bare CR in status line: {line!r}" | |
| assert b"X-Foobar" not in line, \ | |
| f"attacker text in status line: {line!r}" | |
| headers_section = raw.split(b"\r\n\r\n")[0] | |
| assert b"X-Foobar" not in headers_section, \ | |
| f"injected header in response: {headers_section!r}" | |
| env.httpd_error_log.ignore_recent( | |
| lognos=["AH01106"] | |
| ) | |
| def test_proxy_06_003_interim_102_ok(self, env): | |
| """A well-formed 102 Processing interim response is forwarded | |
| correctly, followed by the final 200.""" | |
| faker = _StatusLineBackend("127.0.0.1", env.http_port2, | |
| mode="interim-102") | |
| faker.start() | |
| try: | |
| with socket.create_connection(('localhost', int(env.http_port))) as sock: | |
| req = ( | |
| f"GET / HTTP/1.1\r\n" | |
| f"Host: test1.{env.http_tld}\r\n" | |
| f"Connection: close\r\n" | |
| f"\r\n" | |
| ) | |
| sock.sendall(req.encode()) | |
| sock.shutdown(socket.SHUT_WR) | |
| raw = _recv_all(sock) | |
| finally: | |
| faker.stop() | |
| status_lines = [l for l in raw.split(b"\r\n") | |
| if l.startswith(b"HTTP/")] | |
| assert len(status_lines) == 2, \ | |
| f"expected 2 status lines (102 + 200), got {len(status_lines)}: {status_lines!r}" | |
| assert b"102" in status_lines[0], \ | |
| f"first status line should be 102: {status_lines[0]!r}" | |
| assert b"200" in status_lines[1], \ | |
| f"second status line should be 200: {status_lines[1]!r}" |