Skip to content

Commit f04e85e

Browse files
committed
gh-161053: Fix out-of-bounds read in TextIOWrapper.tell() after seek()
Clamp the bytes-per-character start guess in tell() to the size of the decoder snapshot instead of only asserting it.
1 parent 410fdda commit f04e85e

4 files changed

Lines changed: 37 additions & 5 deletions

File tree

‎Lib/_pyio.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2458,9 +2458,11 @@ def tell(self):
24582458
# be O(1) in most situations (common decoders, sensible input).
24592459
# Actually, it will be exactly 1 for fixed-size codecs (all
24602460
# 8-bit codecs, also UTF-16 and UTF-32).
2461-
skip_bytes = int(self._b2cratio * chars_to_skip)
2461+
# _b2cratio is only a hint and may not have been computed from
2462+
# this snapshot, so never guess past the end of next_input.
2463+
skip_bytes = min(int(self._b2cratio * chars_to_skip),
2464+
len(next_input))
24622465
skip_back = 1
2463-
assert skip_bytes <= len(next_input)
24642466
while skip_bytes > 0:
24652467
decoder.setstate((b'', dec_flags))
24662468
# Decode up to temptative start point

‎Lib/test/test_io/test_textio.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,32 @@ def test_tell_after_readline_with_cr(self):
705705
remaining = f.read()
706706
self.assertEqual(remaining, "")
707707

708+
def test_tell_after_seek_with_chars_to_skip(self):
709+
# tell() after seek() to a position inside the output of one decoder
710+
# step, when the chunk read before had many bytes per character.
711+
for encoding, errors, data, decoded in [
712+
("big5hkscs", "ignore", b"\x88\x62" + b"\xff" * 8190,
713+
"\xca\u0304"),
714+
("utf-7", "strict", b"+AGEAYg-+" + b"A" * 8183, "ab"),
715+
]:
716+
with self.subTest(encoding=encoding):
717+
f = self.TextIOWrapper(self.BytesIO(data),
718+
encoding=encoding, errors=errors)
719+
self.assertEqual(f.read(1), decoded[0])
720+
pos = f.tell()
721+
f.seek(pos)
722+
self.assertEqual(f.tell(), pos)
723+
self.assertEqual(f.read(1), decoded[1])
724+
725+
def test_tell_after_seek_after_lone_cr(self):
726+
data = b"\xff" * 8000 + "a\r\u3042\n".encode()
727+
f = self.TextIOWrapper(self.BytesIO(data),
728+
encoding="utf-8", errors="ignore")
729+
self.assertEqual(f.readline(), "a\n")
730+
pos = f.tell()
731+
f.seek(pos)
732+
self.assertEqual(f.tell(), pos)
733+
self.assertEqual(f.read(), "\u3042\n")
708734

709735
def test_seek_with_encoder_state(self):
710736
f = self.open(os_helper.TESTFN, "w", encoding="euc_jis_2004")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix :meth:`io.TextIOWrapper.tell` reading past the end of an internal buffer
2+
when called after :meth:`~io.TextIOWrapper.seek`, which could make it raise a
3+
spurious :exc:`UnicodeDecodeError`.

‎Modules/_io/textio.c‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2981,10 +2981,11 @@ _io_TextIOWrapper_tell_impl(textio *self)
29812981
} while (0)
29822982

29832983
/* Fast search for an acceptable start point, close to our
2984-
current pos */
2985-
skip_bytes = (Py_ssize_t) (self->b2cratio * chars_to_skip);
2984+
current pos. b2cratio is only a hint and may not have been computed
2985+
from this snapshot, so never guess past the end of next_input. */
2986+
skip_bytes = (Py_ssize_t) Py_MIN(self->b2cratio * chars_to_skip,
2987+
(double) PyBytes_GET_SIZE(next_input));
29862988
skip_back = 1;
2987-
assert(skip_bytes <= PyBytes_GET_SIZE(next_input));
29882989
input = PyBytes_AS_STRING(next_input);
29892990
while (skip_bytes > 0) {
29902991
/* Decode up to temptative start point */

0 commit comments

Comments
 (0)