Skip to content

Commit 2503d10

Browse files
committed
Round-trip cleanly non-ASCII data in HTTP messages.
Use ISO-8859-1 consistently, except in places that must really be ASCII. This leads to changes in undocumented behaviors. When serializing -- this changes in this commit: - Non-ASCII request paths are rejected instead of encoded with UTF-8. This isn't supposed to have any effect because websockets converts non-ASCII URIs to ASCII IRIs before they reach the HTTP layer. - Other non-ASCII values (reason phrase, header values) are now encoded as ISO-8859-1 instead of UTF-8 -- which was never intended; there were comments saying "only contain ASCII"; it's an implementation accident. This has the benefit that it's now possible to send header values in any encoding, while previously only valid UTF-8 could be sent. When parsing -- this changed between 16.0 and 16.1, in ff4869b: - Non-ASCII request paths are rejected instead of decoded as ASCII with surrogate escapes. - Other non-ASCII values (reason phrase, header values) are now decoded as ISO-8859-1 instead of ASCII with surrogate escapes.
1 parent f083457 commit 2503d10

5 files changed

Lines changed: 86 additions & 21 deletions

File tree

docs/project/changelog.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ Backwards-incompatible changes
5252
Previously, the server closed the connection without returning an HTTP
5353
response. Now, ``process_request`` runs and can return an HTTP response.
5454

55+
.. admonition:: Encoding and decoding non-ASCII headers in handshake requests
56+
and responses changed.
57+
:class: note
58+
59+
The previous behavior was undocumented, inconsistent, and didn't match the
60+
HTTP specification. If you relied on the encoding being UTF-8 or ASCII with
61+
surrogate escapes, depending on the context, you must switch to ISO-8859-1.
62+
5563
.. admonition:: In the :mod:`threading` implementation, the ``socket`` argument
5664
is renamed to ``sock``.
5765
:class: note
@@ -76,6 +84,9 @@ New features
7684
Improvements
7785
............
7886

87+
* Supported non-ASCII headers consistently in handshake requests and responses,
88+
using ISO-8859-1 encoding.
89+
7990
* Replied with HTTP 405 Method Not Allowed when the handshake request doesn't
8091
use the GET method, instead of closing the connection.
8192

src/websockets/datastructures.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __str__(self) -> str:
2525
return super().__str__()
2626

2727

28-
# Obsolete line folding for header values is not supported.
28+
# Same regex as http11._value_re, but for matching str rather than bytes.
2929
is_valid_header_value = re.compile(r"[\x09\x20-\x7e\x80-\xff]*").fullmatch
3030

3131

@@ -65,6 +65,26 @@ class Headers(MutableMapping[str, str]):
6565
- :meth:`get_all` returns a list of all values for a header;
6666
- :meth:`raw_items` returns an iterator of ``(name, values)`` pairs.
6767
68+
Header names and values are expected to contain only ASCII text. However,
69+
non-ASCII values happen in practice, even though there is no standard for
70+
transmitting non-ASCII data in HTTP headers. :class:`Headers` supports it
71+
by treating it as ISO-8859-1 data. This is a safe and reversible encoding
72+
to represent arbitrary data in a :class:`str`.
73+
74+
When reading headers from the network, if the actual encoding isn't
75+
ISO-8859-1, you must re-encode and decode, e.g.::
76+
77+
value = headers[key].encode("iso-8859-1").decode("utf-8")
78+
79+
Conversely, when sending headers to the network, if you need to use a
80+
different encoding, you can encode and decode, e.g.::
81+
82+
headers[key] = value.encode("utf-8").decode("iso-8859-1")
83+
84+
When assigning a value to a header, as a security hardening measure, the
85+
value is checked for unsafe characters. The name isn't checked because it's
86+
usually a constant in code, unlikely to be tainted by user input.
87+
6888
"""
6989

7090
__slots__ = ["_dict", "_list"]
@@ -88,8 +108,9 @@ def copy(self) -> Headers:
88108
return copy
89109

90110
def serialize(self) -> bytes:
91-
# Since headers only contain ASCII characters, we can keep this simple.
92-
return str(self).encode()
111+
# parse_headers() supports non-ASCII header values. It decodes them as
112+
# ISO-8859-1. Encode back in ISO-8859-1 in order to round-trip cleanly.
113+
return str(self).encode("iso-8859-1")
93114

94115
# Collection methods
95116

src/websockets/http11.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ class Request:
8686
"""
8787
WebSocket handshake request.
8888
89+
``method`` and ``path`` must contain only ASCII characters. ``headers``
90+
should contain only ASCII characters; however, non-ASCII header values are
91+
tolerated and encoded as ISO-8859-1.
92+
8993
Attributes:
9094
path: Request path, including optional query.
9195
headers: Request headers.
@@ -121,10 +125,10 @@ def parse(
121125
122126
This is a generator-based coroutine.
123127
124-
The request method, path, and headers are expected to contain only ASCII
125-
characters. The request path isn't URL-decoded or validated in any way.
126-
Non-ASCII header values are decoded as ISO-8859-1, which has been
127-
historically allowed, but is now deprecated.
128+
The request method and path must contain only ASCII characters. The
129+
request path isn't URL-decoded or validated in any way. Request headers
130+
should contain only ASCII characters; however, non-ASCII header values
131+
are tolerated and decoded with ISO-8859-1.
128132
129133
:meth:`parse` doesn't read the request body because WebSocket handshake
130134
requests don't have one. If the request contains a body, it may be read
@@ -188,9 +192,10 @@ def serialize(self) -> bytes:
188192
Serialize a WebSocket handshake request.
189193
190194
"""
191-
# Since the request line and headers only contain ASCII characters,
192-
# we can keep this simple.
193-
request = f"{self.method} {self.path} HTTP/1.1\r\n".encode()
195+
# Methods are hardcoded and always ASCII. Non-ASCII paths are converted
196+
# from URI to IRI and percent-encoded. Enforce ASCII as a safety net.
197+
request_line = f"{self.method} {self.path} HTTP/1.1\r\n"
198+
request = request_line.encode("ascii")
194199
request += self.headers.serialize()
195200
return request
196201

@@ -200,6 +205,10 @@ class Response:
200205
"""
201206
WebSocket handshake response.
202207
208+
``reason_phrase`` and ``headers`` should contain only ASCII characters;
209+
however, non-ASCII reason phrases and header values are tolerated and
210+
encoded as ISO-8859-1.
211+
203212
Attributes:
204213
status_code: Response code.
205214
reason_phrase: Response reason.
@@ -241,9 +250,9 @@ def parse(
241250
242251
This is a generator-based coroutine.
243252
244-
The reason phrase and header values are expected to contain only ASCII
245-
characters. Non-ASCII values are decoded as ISO-8859-1, which has been
246-
historically allowed, but is now deprecated.
253+
The reason phrase and headers should contain only ASCII characters;
254+
however, non-ASCII reason phrases and header values are tolerated and
255+
decoded as ISO-8859-1.
247256
248257
Args:
249258
read_line: Generator-based coroutine that reads a LF-terminated
@@ -319,9 +328,9 @@ def serialize(self) -> bytes:
319328
Serialize a WebSocket handshake response.
320329
321330
"""
322-
# Since the status line and headers only contain ASCII characters,
323-
# we can keep this simple.
324-
response = f"HTTP/1.1 {self.status_code} {self.reason_phrase}\r\n".encode()
331+
# Encode the reason phrase as ISO-8859-1 to round-trip cleanly.
332+
status_line = f"HTTP/1.1 {self.status_code} {self.reason_phrase}\r\n"
333+
response = status_line.encode("iso-8859-1")
325334
response += self.headers.serialize()
326335
response += self.body
327336
return response
@@ -364,9 +373,8 @@ def parse_headers(
364373
"""
365374
Parse HTTP headers.
366375
367-
Header values are expected to contain only ASCII characters. Non-ASCII
368-
values are decoded as ISO-8859-1, which has been historically allowed,
369-
but is now deprecated.
376+
Headers should contain only ASCII characters; however, non-ASCII values are
377+
tolerated and decoded as ISO-8859-1.
370378
371379
Args:
372380
read_line: Generator-based coroutine that reads a LF-terminated line
@@ -404,8 +412,10 @@ def parse_headers(
404412

405413
name = raw_name.decode("ascii") # guaranteed to be ASCII at this point
406414
# Headers should be ASCII. Section 5.5 of RFC 9110 says: "Historically,
407-
# HTTP allowed field content with text in the ISO-8859-1 charset."
408-
# It's easy to reverse and cannot crash, making it a decent choice.
415+
# HTTP allowed field content with text in the ISO-8859-1 charset." and
416+
# "A recipient SHOULD treat other allowed octets in field content (i.e.,
417+
# obs-text) as opaque data." ISO-8859-1 is an opaque representation of
418+
# arbitrary binary data in a str object and it is easy to reverse.
409419
value = raw_value.decode("iso-8859-1")
410420

411421
# Since we just validated raw_value, we don't need to revalidate it.

tests/test_datastructures.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ def test_setitem_case_insensitive(self):
122122
self.headers["upgrade"] = "websocket"
123123
self.assertEqual(self.headers["Upgrade"], "websocket")
124124

125+
def test_setitem_non_ascii_value(self):
126+
self.headers["X-Non-ASCII"] = "café"
127+
self.assertEqual(self.headers["X-Non-ASCII"], "café")
128+
129+
def test_setitem_non_latin1_value(self):
130+
with self.assertRaises(InvalidHeaderValue):
131+
self.headers["X-Invalid"] = "→"
132+
125133
def test_setitem_invalid_value(self):
126134
with self.assertRaises(InvalidHeaderValue):
127135
self.headers["X-Invalid"] = "multi\r\nline"
@@ -181,6 +189,14 @@ def test_set_insecure_invalid_value(self):
181189
self.headers.set_insecure("X-Invalid", "multi\r\nline")
182190
self.assertEqual(self.headers["X-Invalid"], "multi\r\nline") # oops
183191

192+
def test_serialize_non_ascii_value(self):
193+
self.headers.clear()
194+
self.headers.set_insecure("X-Non-ASCII", "café")
195+
self.assertEqual(
196+
self.headers.serialize(),
197+
b"X-Non-ASCII: caf\xe9\r\n\r\n",
198+
)
199+
184200

185201
class MultiValueHeadersTests(unittest.TestCase):
186202
def setUp(self):

tests/test_http11.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,13 @@ def test_serialize_with_body(self):
431431
b"Hello world!\n",
432432
)
433433

434+
def test_serialize_iso_8859_1_reason(self):
435+
response = Response(200, "ØK", Headers([("Content-Length", "0")]))
436+
self.assertEqual(
437+
response.serialize(),
438+
b"HTTP/1.1 200 \xd8K\r\nContent-Length: 0\r\n\r\n",
439+
)
440+
434441

435442
class HeadersTests(GeneratorTestCase):
436443
def setUp(self):

0 commit comments

Comments
 (0)