diff --git a/falcon/response.py b/falcon/response.py index 268988801..5b56b4bf0 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -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. @@ -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') @@ -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: @@ -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') diff --git a/tests/test_error_reporter.py b/tests/test_error_reporter.py index f86475118..0443279ab 100644 --- a/tests/test_error_reporter.py +++ b/tests/test_error_reporter.py @@ -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): diff --git a/tests/test_headers.py b/tests/test_headers.py index e9fdc35cd..142efcf8d 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -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() @@ -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'