diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 05ceb865cffc3f..d4b3cd36e6d329 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -795,6 +795,34 @@ def test_invalid_transition_index(self): with self.assertRaises(ValueError): self.klass.from_file(f) + def test_invalid_charcnt(self): + # 128 local time types whose abbreviations are the suffixes of a + # single long abbreviation spanning the entire block: every index + # would get its own copy of most of the block. + types = b"".join(struct.pack(">lbb", 3600, 0, i) for i in range(128)) + chars = b"A" * 65535 + b"\x00" + + # With a negative charcnt the block would be the rest of the file + for charcnt in (len(chars), -1): + with self.subTest(charcnt=charcnt): + header = b"TZif" + b"\x00" * 16 + header += struct.pack(">6l", 0, 0, 0, 0, 128, charcnt) + f = io.BytesIO(header + types + chars) + + with self.assertRaises(ValueError): + self.klass.from_file(f) + + def test_abbreviation_index_above_127(self): + chars = b"X" * 129 + b"\x00" + b"ABC\x00" + header = b"TZif" + b"\x00" * 16 + header += struct.pack(">6l", 0, 0, 0, 0, 1, len(chars)) + types = struct.pack(">lbB", 3600, 0, 130) + zi = self.klass.from_file(io.BytesIO(header + types + chars)) + + dt = datetime(2026, 1, 1, tzinfo=zi) + self.assertEqual(dt.tzname(), "ABC") + self.assertEqual(dt.utcoffset(), ONE_H) + def test_transition_lookahead_out_of_bounds(self): STD = ZoneOffset("STD", ZERO) DST = ZoneOffset("DST", ONE_H, ONE_H) diff --git a/Lib/zoneinfo/_common.py b/Lib/zoneinfo/_common.py index 98668c15d8bf94..d761fdce2c7a67 100644 --- a/Lib/zoneinfo/_common.py +++ b/Lib/zoneinfo/_common.py @@ -78,7 +78,7 @@ def load_data(fobj): # Read the ttinfo struct, (utoff, isdst, abbrind) if typecnt: utcoff, isdst, abbrind = zip( - *(struct.unpack(">lbb", fobj.read(6)) for i in range(typecnt)) + *(struct.unpack(">lbB", fobj.read(6)) for i in range(typecnt)) ) else: utcoff = () @@ -89,6 +89,14 @@ def load_data(fobj): # not by position in the array but by position in the unsplit # abbreviation string. I suppose this makes more sense in C, which uses # null to terminate the strings, but it's inconvenient here... + # + # Each abbreviation index is a single unsigned byte, so only about the + # first 256 characters of the block can be referenced. A much longer + # block holds nothing usable, and reading it would copy most of the + # block for every local time type. + if not 0 <= charcnt <= 512: + raise ValueError(f"Invalid TZif file: invalid charcnt: {charcnt}") + abbr_vals = {} abbr_chars = fobj.read(charcnt) diff --git a/Misc/NEWS.d/next/Library/2026-10-22-05-20-52.gh-issue-161096.vkhfyy.rst b/Misc/NEWS.d/next/Library/2026-10-22-05-20-52.gh-issue-161096.vkhfyy.rst new file mode 100644 index 00000000000000..bae5465f42c6a1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-05-20-52.gh-issue-161096.vkhfyy.rst @@ -0,0 +1,4 @@ +Fix :meth:`zoneinfo.ZoneInfo.from_file` accepting TZif data with a negative or +oversized abbreviation block, which could use hundreds of times the file size +in memory; it now raises :exc:`ValueError`. Abbreviation indexes above 127 now +also give the correct time zone abbreviation.