mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 10:11:30 +08:00
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:
parent
9aa54703f4
commit
7ae6503dea
@ -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"
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user