Skip to content

Commit ff4869b

Browse files
committed
Decode non-ASCII header values with iso-8859-1.
Per RFC 9110, section 5.5: > Historically, HTTP allowed field content with text > in the ISO-8859-1 charset This is as reversible and less surprising than surrogate escaping (which was never documented in websockets and is quite uncommon).
1 parent 399bb08 commit ff4869b

2 files changed

Lines changed: 26 additions & 9 deletions

File tree

src/websockets/http11.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ def parse(
149149
)
150150
if method != b"GET":
151151
raise ValueError(f"unsupported HTTP method; expected GET; got {d(method)}")
152-
path = raw_path.decode("ascii", "surrogateescape")
152+
153+
# RFC 9110 defers the definition of URIs to RFC 3986, which allows only
154+
# a subset of ASCII. Non-ASCII IRIs must be UTF-8 then percent-encoded.
155+
path = raw_path.decode("ascii")
153156

154157
headers = yield from parse_headers(read_line)
155158

@@ -272,7 +275,10 @@ def parse(
272275
)
273276
if not _value_re.fullmatch(raw_reason):
274277
raise ValueError(f"invalid HTTP reason phrase: {d(raw_reason)}")
275-
reason = raw_reason.decode("ascii", "surrogateescape")
278+
279+
# RFC 2616 implies ISO-8859-1. It's easy to reverse and cannot crash.
280+
# Non-ASCII never worked reliably and the reason isn't useful anyway.
281+
reason = raw_reason.decode("iso-8859-1")
276282

277283
headers = yield from parse_headers(read_line)
278284

@@ -368,7 +374,10 @@ def parse_headers(
368374
raise ValueError(f"invalid HTTP header value: {d(raw_value)}")
369375

370376
name = raw_name.decode("ascii") # guaranteed to be ASCII at this point
371-
value = raw_value.decode("ascii", "surrogateescape")
377+
# Headers should be ASCII. Section 5.5 of RFC 9110 says: "Historically,
378+
# HTTP allowed field content with text in the ISO-8859-1 charset."
379+
# It's easy to reverse and cannot crash, making it a decent choice.
380+
value = raw_value.decode("iso-8859-1")
372381

373382
# Since we just validated raw_value, we don't need to revalidate it.
374383
headers.set_insecure(name, value)

tests/test_http11.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,30 +397,38 @@ def setUp(self):
397397
self.reader = StreamReader()
398398

399399
def parse_headers(self):
400-
return parse_headers(self.reader.read_line)
400+
try:
401+
next(parse_headers(self.reader.read_line))
402+
except StopIteration as exc:
403+
return exc.value
404+
405+
def test_parse_iso_8859_1(self):
406+
self.reader.feed_data(b"X-Drink: caf\xe9\r\n\r\n")
407+
headers = self.parse_headers()
408+
self.assertEqual(headers["X-Drink"], "café")
401409

402410
def test_parse_invalid_name(self):
403411
self.reader.feed_data(b"foo bar: baz qux\r\n\r\n")
404412
with self.assertRaises(ValueError):
405-
next(self.parse_headers())
413+
self.parse_headers()
406414

407415
def test_parse_invalid_value(self):
408416
self.reader.feed_data(b"foo: \x00\x00\x0f\r\n\r\n")
409417
with self.assertRaises(ValueError):
410-
next(self.parse_headers())
418+
self.parse_headers()
411419

412420
def test_parse_too_long_value(self):
413421
self.reader.feed_data(b"foo: bar\r\n" * 129 + b"\r\n")
414422
with self.assertRaises(SecurityError):
415-
next(self.parse_headers())
423+
self.parse_headers()
416424

417425
def test_parse_too_long_line(self):
418426
# Header line contains 5 + 8186 + 2 = 8193 bytes.
419427
self.reader.feed_data(b"foo: " + b"a" * 8186 + b"\r\n\r\n")
420428
with self.assertRaises(SecurityError):
421-
next(self.parse_headers())
429+
self.parse_headers()
422430

423431
def test_parse_invalid_line_ending(self):
424432
self.reader.feed_data(b"foo: bar\n\n")
425433
with self.assertRaises(EOFError):
426-
next(self.parse_headers())
434+
self.parse_headers()

0 commit comments

Comments
 (0)