Merge pull request #3588 from eddieran/fix/early-hints-header-validation

fix: add header validation to early_hints callback
This commit is contained in:
Benoit Chesneau 2026-04-19 09:16:10 +02:00 committed by GitHub
commit e5c30b4bc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 88 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,20 @@ 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):
# Pass only the name — the invalid value may contain
# sensitive data that shouldn't cross security boundaries
# via exception propagation (browsers/proxies may forward
# it to untrusted parties).
raise InvalidHeader('%r' % name)
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: