mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 10:41: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:
|
Args:
|
||||||
headers: List of (name, value) header tuples, typically Link headers
|
headers: List of (name, value) header tuples, typically Link headers
|
||||||
Example: [('Link', '</style.css>; rel=preload; as=style')]
|
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
|
# Don't send after response has started - would break framing
|
||||||
if resp.headers_sent:
|
if resp.headers_sent:
|
||||||
@ -159,6 +163,16 @@ def _make_early_hints_callback(req, sock, resp):
|
|||||||
name = name.decode('latin-1')
|
name = name.decode('latin-1')
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
value = value.decode('latin-1')
|
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 += f"{name}: {value}\r\n".encode('latin-1')
|
||||||
response += b"\r\n"
|
response += b"\r\n"
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ except ImportError:
|
|||||||
H2_AVAILABLE = False
|
H2_AVAILABLE = False
|
||||||
|
|
||||||
from gunicorn.http import wsgi
|
from gunicorn.http import wsgi
|
||||||
|
from gunicorn.http.errors import InvalidHeader, InvalidHeaderName
|
||||||
|
|
||||||
|
|
||||||
class MockConfig:
|
class MockConfig:
|
||||||
@ -225,6 +226,75 @@ class TestWSGIEarlyHints:
|
|||||||
# Should still send 103 response with no headers
|
# Should still send 103 response with no headers
|
||||||
assert sent_data == b"HTTP/1.1 103 Early Hints\r\n\r\n"
|
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")
|
@pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available")
|
||||||
class TestHTTP2EarlyHints:
|
class TestHTTP2EarlyHints:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user