From 7ae6503deadbe413a25e8870adfdedca49057298 Mon Sep 17 00:00:00 2001 From: ran Date: Mon, 13 Apr 2026 17:19:18 +0800 Subject: [PATCH] fix: validate headers in early_hints callback to match process_headers The early_hints callback constructs 103 Early Hints responses without any header validation, while process_headers validates against TOKEN_RE and HEADER_VALUE_RE for normal responses. This inconsistency means a WSGI app passing unsanitized data to wsgi.early_hints could enable HTTP response splitting via CRLF injection. Apply the same TOKEN_RE/HEADER_VALUE_RE checks from process_headers to the early_hints callback for defense-in-depth consistency. Closes #3585 --- gunicorn/http/wsgi.py | 14 ++++++++ tests/test_early_hints.py | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/gunicorn/http/wsgi.py b/gunicorn/http/wsgi.py index 77231a85..a4a3d5ca 100644 --- a/gunicorn/http/wsgi.py +++ b/gunicorn/http/wsgi.py @@ -143,6 +143,10 @@ def _make_early_hints_callback(req, sock, resp): Args: headers: List of (name, value) header tuples, typically Link headers Example: [('Link', '; rel=preload; as=style')] + + Raises: + InvalidHeaderName: If a header name is not a valid HTTP token. + InvalidHeader: If a header value contains invalid characters. """ # Don't send after response has started - would break framing if resp.headers_sent: @@ -159,6 +163,16 @@ def _make_early_hints_callback(req, sock, resp): name = name.decode('latin-1') if isinstance(value, bytes): value = value.decode('latin-1') + + # Validate header name and value using the same checks as + # Response.process_headers — defense-in-depth against + # HTTP response splitting via CRLF injection. + if not TOKEN_RE.fullmatch(name): + raise InvalidHeaderName('%r' % name) + if not HEADER_VALUE_RE.fullmatch(value): + raise InvalidHeader('%r' % value) + + value = value.strip(" \t") response += f"{name}: {value}\r\n".encode('latin-1') response += b"\r\n" diff --git a/tests/test_early_hints.py b/tests/test_early_hints.py index 62d84ca6..194794b2 100644 --- a/tests/test_early_hints.py +++ b/tests/test_early_hints.py @@ -19,6 +19,7 @@ except ImportError: H2_AVAILABLE = False from gunicorn.http import wsgi +from gunicorn.http.errors import InvalidHeader, InvalidHeaderName class MockConfig: @@ -225,6 +226,75 @@ class TestWSGIEarlyHints: # Should still send 103 response with no headers assert sent_data == b"HTTP/1.1 103 Early Hints\r\n\r\n" + def test_early_hints_rejects_crlf_in_header_value(self): + """Test that CRLF in header values is rejected (response splitting).""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Attempt CRLF injection in header value + with pytest.raises(InvalidHeader): + environ['wsgi.early_hints']([ + ('Link', '; rel=preload\r\nX-Injected: true'), + ]) + + # Nothing should have been sent + assert sock.get_sent_data() == b"" + + def test_early_hints_rejects_crlf_in_header_name(self): + """Test that CRLF in header names is rejected.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + with pytest.raises(InvalidHeaderName): + environ['wsgi.early_hints']([ + ('Link\r\nX-Injected', ''), + ]) + + assert sock.get_sent_data() == b"" + + def test_early_hints_rejects_invalid_header_name(self): + """Test that invalid token characters in header name are rejected.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Space is not allowed in header names + with pytest.raises(InvalidHeaderName): + environ['wsgi.early_hints']([ + ('Invalid Header', ''), + ]) + + def test_early_hints_valid_headers_pass_validation(self): + """Test that valid headers still work after adding validation.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # These should all pass validation + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ('Link', '; rel=preload; as=script'), + ('X-Custom-Header', 'some-value'), + ]) + + sent_data = sock.get_sent_data() + assert b"HTTP/1.1 103 Early Hints\r\n" in sent_data + assert b"Link: ; rel=preload; as=style\r\n" in sent_data + @pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available") class TestHTTP2EarlyHints: