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
8 changes: 8 additions & 0 deletions docs/_newsfragments/2767.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
:meth:`Response.set_header`, :meth:`Response.append_header`, and
:meth:`Response.set_headers` now reject header names that are not US-ASCII
and header values that cannot be encoded as Latin-1 (ISO-8859-1).
Values are still coerced with ``str()``, and historical Latin-1 header
values, including raw ``Set-Cookie`` headers emitted on ASGI, remain
supported. The check validates encoding only; it does not reject ASCII
control characters or otherwise certify that header content is a valid
HTTP field.
12 changes: 6 additions & 6 deletions falcon/asgi/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,16 +365,16 @@ def _asgi_headers(self, media_type: str | None = None) -> list[tuple[bytes, byte
# CPython's C API functions such as PyUnicode_EncodeLatin1.
items = _encode_items_to_latin1(headers)
except UnicodeEncodeError as ex:
# TODO(vytas): In 3.1.0, update this error message to highlight the
# fact that we decided to allow ISO-8859-1?
raise ValueError(
'The modern series of HTTP standards require that header '
f'names and values use only ASCII characters: {ex}'
)
'Header names and values must be encodable as Latin-1 '
f'(ISO-8859-1): {ex}'
) from ex

if self._extra_headers:
# Names stay US-ASCII. Values use Latin-1, matching normal headers
# and the historical WSGI string encoding.
items += [
(n.encode('ascii'), v.encode('ascii')) for n, v in self._extra_headers
(n.encode('ascii'), v.encode('latin1')) for n, v in self._extra_headers
]

# NOTE(kgriffs): It is important to append these after self._extra_headers
Expand Down
127 changes: 91 additions & 36 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: str) -> tuple[str, str]:
"""Normalize a response header name and coerced value.

The name must be US-ASCII before it is lowercased. ``value`` is coerced
with ``str()`` and must be encodable as Latin-1 (ISO-8859-1). ASCII
values skip the Latin-1 probe.

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

Returns:
tuple[str, str]: Lowercased name and coerced value.

Raises:
ValueError: `name` is not US-ASCII, or the coerced value is not
Latin-1 encodable.
"""

# Characters such as U+212A KELVIN SIGN lowercase to ASCII, so the name
# is checked before case folding.
if name.isascii():
name = name.lower()
else:
raise ValueError(
'Header name must contain only US-ASCII characters: {!r}'.format(name)
)

# 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.
value = str(value)
if not value.isascii():
try:
value.encode('latin1')
except UnicodeEncodeError:
raise ValueError(
'Header value must be encodable as Latin-1 (ISO-8859-1): {!r}'.format(
value
)
)

return name, value


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

Expand Down Expand Up @@ -720,22 +765,23 @@ def set_header(self, name: str, value: str) -> None:

Args:
name (str): Header name (case-insensitive). The name may contain
only US-ASCII characters.
value (str): Value for the header. As with the header's name, the
value may contain only US-ASCII characters.
only US-ASCII characters. This restriction is checked before
the name is lowercased.
value (str): Value for the header. The value is coerced with
``str()`` and must be encodable as Latin-1 (ISO-8859-1).

Note:
This method validates character encoding only. It does not
enforce HTTP token syntax, and it does not reject ASCII
control characters. Applications must supply valid header
content. Further checks, if any, depend on the server.

Raises:
ValueError: `name` cannot be ``'Set-Cookie'``.
ValueError: `name` is not US-ASCII, `value` is not Latin-1
encodable, or `name` is ``'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 @@ -791,19 +837,23 @@ def append_header(self, name: str, value: str) -> None:

Args:
name (str): Header name (case-insensitive). The name may contain
only US-ASCII characters.
value (str): Value for the header. As with the header's name, the
value may contain only US-ASCII characters.
"""
only US-ASCII characters. This restriction is checked before
the name is lowercased.
value (str): Value for the header. The value is coerced with
``str()`` and must be encodable as Latin-1 (ISO-8859-1).

# 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:
This method validates character encoding only. It does not
enforce HTTP token syntax, and it does not reject ASCII
control characters. Applications must supply valid header
content. Further checks, if any, depend on the server.

# NOTE(kgriffs): normalize name by lowercasing it
name = name.lower()
Raises:
ValueError: `name` is not US-ASCII, or `value` is not Latin-1
encodable.
"""

name, value = _prepare_header(name, value)

if name == 'set-cookie':
if not self._extra_headers:
Expand Down Expand Up @@ -836,18 +886,29 @@ def set_headers(
:meth:`~.append_header` or :meth:`~.set_cookie`.

Args:
headers (Iterable[[str, str]]): An iterable of ``[name, value]`` two-member
iterables, or a dict-like object that implements an ``items()`` method.
Both *name* and *value* must be of type ``str`` and
contain only US-ASCII characters.
headers (Iterable[[str, str]]): An iterable of ``[name, value]``
two-member iterables, or a dict-like object that implements
an ``items()`` method. Each name may contain only US-ASCII
characters, which is checked before the name is lowercased.
Each value is coerced with ``str()`` and must be encodable as
Latin-1 (ISO-8859-1).

Note:
Falcon can process an iterable of tuples slightly faster
than a dict.

Note:
This method validates character encoding only. It does not
enforce HTTP token syntax, and it does not reject ASCII
control characters. Applications must supply valid header
content. Further checks, if any, depend on the server.

Headers from items that have already been applied stay
set when a later item is rejected.

Raises:
ValueError: `headers` was not a ``dict`` or ``list`` of ``tuple``
or ``Iterable[[str, str]]``.
ValueError: A header name is not US-ASCII, a header value is not
Latin-1 encodable, or a name is ``'Set-Cookie'``.
"""

header_items = getattr(headers, 'items', None)
Expand All @@ -860,13 +921,7 @@ 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 = name.lower()
name, value = _prepare_header(name, value)
if name == 'set-cookie':
raise HeaderNotSupported('This method cannot be used to set cookies')

Expand Down
13 changes: 9 additions & 4 deletions tests/test_error_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,18 @@ async def process_response_async(self, req, resp, rsrc, succeeded):
def test_report_fatal_error_rendering_asgi_headers(util, status, set_reporter):
# NOTE(vytas): Unlike WSGI, where headers are passed to the app server as
# native strings, ASGI headers are encoded by the framework itself.
resource = falcon.testing.SimpleTestResourceAsync(
status=status, body='Hello', headers={'X-Falcon': '🦅'}
)
# Header setters now reject values that cannot be encoded as Latin-1,
# so store the header directly to exercise the rendering failure path.
class Resource:
async def on_get(self, req, resp):
resp.status = status
resp.text = 'Hello'
resp._headers['x-falcon'] = '🦅'

reporter = ErrorReporter(None)

app = util.create_app(True)
app.add_route('/', resource)
app.add_route('/', Resource())
if set_reporter:
app.set_error_reporter(reporter.report)

Expand Down
Loading
Loading