Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions falcon/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,51 @@
_RESERVED_SAMESITE_VALUES = frozenset({'lax', 'strict', 'none'})


def _prepare_header(name: str, value: Any) -> tuple[str, str]:
"""Prepare a header name and value for setting on a response.

Validates that the header name contains only ASCII characters, and that
the header value is either ASCII or can be encoded as Latin-1. The value
is also coerced to str() for backwards compatibility with integer and
other non-string values.

Args:
name (str): Header name to validate.
value: Header value to validate and coerce.

Returns:
tuple[str, str]: A tuple of (lowercased_name, coerced_value).

Raises:
ValueError: If the name contains non-ASCII characters, or if the
value contains characters that cannot be encoded as Latin-1.
"""
# NOTE(kgriffs): Validate that the header name is ASCII.
if not name.isascii():
raise ValueError('Header name must contain only ASCII characters.')

# NOTE(kgriffs): uwsgi fails with a TypeError if any header
# is not a str, so do the conversion here. It's actually
# faster to not do an isinstance check. str() will encode
# to US-ASCII.
value = str(value)

# NOTE(kgriffs): Validate that the header value is either ASCII or
# Latin-1 encodable. This is required for both WSGI and ASGI
# compatibility, and allows legacy applications that use Latin-1
# in header values to continue working.
if not value.isascii():
try:
value.encode('latin-1')
except UnicodeEncodeError:
raise ValueError(
'Header value must contain only ASCII or Latin-1 '
'characters.'
)

return (name.lower(), value)


class Response:
"""Represents an HTTP response to a client request.

Expand Down Expand Up @@ -728,14 +773,7 @@ def set_header(self, name: str, value: str) -> None:
ValueError: `name` cannot be ``'Set-Cookie'``.
"""

# NOTE(kgriffs): uwsgi fails with a TypeError if any header
# is not a str, so do the conversion here. It's actually
# faster to not do an isinstance check. str() will encode
# to US-ASCII.
value = str(value)

# NOTE(kgriffs): normalize name by lowercasing it
name = name.lower()
name, value = _prepare_header(name, value)

if name == 'set-cookie':
raise HeaderNotSupported('This method cannot be used to set cookies')
Expand Down Expand Up @@ -796,14 +834,7 @@ def append_header(self, name: str, value: str) -> None:
value may contain only US-ASCII characters.
"""

# NOTE(kgriffs): uwsgi fails with a TypeError if any header
# is not a str, so do the conversion here. It's actually
# faster to not do an isinstance check. str() will encode
# to US-ASCII.
value = str(value)

# NOTE(kgriffs): normalize name by lowercasing it
name = name.lower()
name, value = _prepare_header(name, value)

if name == 'set-cookie':
if not self._extra_headers:
Expand Down Expand Up @@ -860,13 +891,8 @@ def set_headers(
_headers = self._headers

for name, value in headers: # type: ignore[str-unpack]
# NOTE(kgriffs): uwsgi fails with a TypeError if any header
# is not a str, so do the conversion here. It's actually
# faster to not do an isinstance check. str() will encode
# to US-ASCII.
value = str(value)
name, value = _prepare_header(name, value)

name = name.lower()
if name == 'set-cookie':
raise HeaderNotSupported('This method cannot be used to set cookies')

Expand Down
9 changes: 6 additions & 3 deletions tests/test_error_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,13 @@ def test_report_fatal_error_rendering_asgi_headers(util, status, set_reporter):
if set_reporter:
app.set_error_reporter(reporter.report)

with pytest.raises(ValueError):
falcon.testing.simulate_get(app, '/')
# NOTE: Falcon now validates header ASCII/Latin-1 at set_header/set_headers
# time, catching the error early and converting it to a 500 response.
resp = falcon.testing.simulate_get(app, '/')
assert resp.status == falcon.HTTP_500

assert reporter.log == ([(ValueError, False)] if set_reporter else [])
# NOTE: The error is now handled (converted to 500) rather than unhandled.
assert reporter.log == ([(ValueError, True)] if set_reporter else [])


async def test_report_websocket(util):
Expand Down
135 changes: 112 additions & 23 deletions tests/test_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,35 +704,26 @@ def test_unicode_headers_contain_non_ascii(self, method, client):

if method == 'CONNECT':
# NOTE(vytas): Response headers cannot be encoded to Latin-1.
if not app._ASGI:
pytest.skip('wsgiref.validate sees no evil here')

# NOTE(vytas): Shouldn't this result in an HTTP 500 instead of
# bubbling up a ValueError to the app server?
with pytest.raises(ValueError):
client.simulate_request(method, '/')
# Falcon now validates this at set_header time and converts to 500.
resp = client.simulate_request(method, '/')
assert resp.status == falcon.HTTP_500
elif method == 'PUT':
# NOTE(vytas): Latin-1 header values are allowed.
resp = client.simulate_request(method, '/')
assert resp.headers
else:
elif method == 'PATCH':
# NOTE: \x01\x02\xff is Latin-1 encodable (passes Falcon validation),
# but control chars are rejected by the ASGI/WSGI framework.
if app._ASGI:
# NOTE(kgriffs): Unlike PEP-3333, the ASGI spec requires the
# app to encode header names and values to a byte string. This
# gives Falcon the opportunity to verify the character set
# in the process and raise an error as appropriate.
error_type = ValueError
with pytest.raises(ValueError, match='Bad header value'):
client.simulate_request(method, '/')
else:
# NOTE(kgriffs): The wsgiref validator that is integrated into
# Falcon's testing framework will catch this. However, Falcon
# itself does not do the check to avoid potential overhead
# in a production deployment.
error_type = AssertionError

pattern = 'Bad header name' if method == 'POST' else 'Bad header value'

with pytest.raises(error_type, match=pattern):
client.simulate_request(method, '/')
with pytest.raises(AssertionError, match='Bad header value'):
client.simulate_request(method, '/')
else:
# POST has non-ASCII header name; Falcon now validates at set_headers time.
resp = client.simulate_request(method, '/')
assert resp.status == falcon.HTTP_500

def test_response_set_and_get_header(self, client):
resource = HeaderHelpersResource()
Expand Down Expand Up @@ -1058,3 +1049,101 @@ def _check_header(self, client, resource, header, expected_value):

result = client.simulate_get()
assert result.headers[header] == expected_value


class TestHeaderASCIIValidation:
"""Test that response header methods enforce ASCII requirements."""

def test_set_header_non_ascii_name_raises(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header name must contain only ASCII'):
resp.set_header('X-Üser', 'value')

def test_set_header_non_ascii_value_raises(self):
resp = falcon.Response()
# 'café' contains 'é' (U+00E9) which IS Latin-1 encodable, so it's allowed
# Use a truly non-Latin-1 character like emoji or CJK
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.set_header('X-Header', 'caf\u00e9\U0001f37d') # emoji not Latin-1

def test_set_header_latin1_value_allowed(self):
resp = falcon.Response()
# Latin-1 characters like 'ö' should be allowed
resp.set_header('X-Header', 'Förmånsrätt')
assert resp.get_header('x-header') == 'Förmånsrätt'

def test_set_header_ascii_value_allowed(self):
resp = falcon.Response()
resp.set_header('X-Header', 'simple-value')
assert resp.get_header('x-header') == 'simple-value'

def test_set_header_integer_value_allowed(self):
"""Test backwards compatibility: integer values should still work."""
resp = falcon.Response()
resp.set_header('X-Count', 42)
assert resp.get_header('x-count') == '42'

def test_append_header_non_ascii_name_raises(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header name must contain only ASCII'):
resp.append_header('X-Üser', 'value')

def test_append_header_non_ascii_value_raises(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.append_header('X-Header', 'caf\u00e9\U0001f37d') # emoji not Latin-1

def test_append_header_latin1_value_allowed(self):
resp = falcon.Response()
resp.append_header('X-Header', 'Förmånsrätt')
assert resp.get_header('x-header') == 'Förmånsrätt'

def test_set_headers_non_ascii_name_raises(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header name must contain only ASCII'):
resp.set_headers([('X-Üser', 'value')])

def test_set_headers_non_ascii_value_raises(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.set_headers([('X-Header', 'caf\u00e9\U0001f37d')]) # emoji not Latin-1

def test_set_headers_latin1_value_allowed(self):
resp = falcon.Response()
resp.set_headers([('X-Header', 'Förmånsrätt')])
assert resp.get_header('x-header') == 'Förmånsrätt'

def test_set_headers_multiple_headers_one_invalid(self):
resp = falcon.Response()
with pytest.raises(ValueError, match='Header name must contain only ASCII'):
resp.set_headers([('X-First', 'value1'), ('X-Üser', 'value2')])

def test_set_header_non_latin1_value_raises(self):
"""Test that non-Latin-1 values (CJK, emoji, etc.) are rejected."""
resp = falcon.Response()
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.set_header('X-Header', '中文') # CJK characters not Latin-1

def test_append_header_non_latin1_value_raises(self):
"""Test that non-Latin-1 values (CJK, emoji, etc.) are rejected."""
resp = falcon.Response()
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.append_header('X-Header', '中文') # CJK characters not Latin-1

def test_set_headers_non_latin1_value_raises(self):
"""Test that non-Latin-1 values (CJK, emoji, etc.) are rejected."""
resp = falcon.Response()
with pytest.raises(ValueError, match='Header value must contain only ASCII'):
resp.set_headers([('X-Header', '中文')]) # CJK characters not Latin-1

def test_set_header_latin1_value_allowed_via_set_headers(self):
"""Test that Latin-1 values work through set_headers."""
resp = falcon.Response()
resp.set_headers([('X-Header', 'Förmånsrätt')])
assert resp.get_header('x-header') == 'Förmånsrätt'

def test_set_header_latin1_value_allowed_via_append(self):
"""Test that Latin-1 values work through append_header."""
resp = falcon.Response()
resp.append_header('X-Header', 'Förmånsrätt')
assert resp.get_header('x-header') == 'Förmånsrätt'