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
This commit is contained in:
ran 2026-04-13 17:19:18 +08:00
parent 9aa54703f4
commit 7ae6503dea
2 changed files with 84 additions and 0 deletions

View File

@ -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', '</style.css>; 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"

View File

@ -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', '</evil>; 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', '</evil>'),
])
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', '</style.css>'),
])
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', '</style.css>; rel=preload; as=style'),
('Link', '</app.js>; 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: </style.css>; rel=preload; as=style\r\n" in sent_data
@pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available")
class TestHTTP2EarlyHints: