From dabdd4f53c48116b19744e3099647e15c8d68ab1 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sat, 11 Jul 2026 22:57:17 +0800 Subject: [PATCH 1/3] gh-153605: Fix gettext.GNUTranslations struct.error on a malformed .mo file GNUTranslations._parse unpacked the .mo header and seek tables without checking the file was long enough, so a truncated file or one whose seek tables point past the end leaked a struct.error instead of the OSError the module raises for other malformed input. Validate the lengths first. --- Lib/gettext.py | 14 ++++++-- Lib/test/test_gettext.py | 34 +++++++++++++++++++ ...-07-11-16-00-00.gh-issue-153605.Rk7Xz3.rst | 2 ++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-11-16-00-00.gh-issue-153605.Rk7Xz3.rst diff --git a/Lib/gettext.py b/Lib/gettext.py index 2f77f0e849e9aeb..5f449d54c76fab5 100644 --- a/Lib/gettext.py +++ b/Lib/gettext.py @@ -360,21 +360,31 @@ def _parse(self, fp): buf = fp.read() buflen = len(buf) # Are we big endian or little endian? + if buflen < 4: + raise OSError(0, 'File is corrupt', filename) magic = unpack('4I', buf[4:20]) ii = '>II' + hdr = '>4I' else: raise OSError(0, 'Bad magic number', filename) + if buflen < 20: + raise OSError(0, 'File is corrupt', filename) + version, msgcount, masteridx, transidx = unpack(hdr, buf[4:20]) major_version, minor_version = self._get_versions(version) if major_version not in self.VERSIONS: raise OSError(0, 'Bad version number ' + str(major_version), filename) + # The seek tables must lie within the file. + if (masteridx + 8 * msgcount > buflen + or transidx + 8 * msgcount > buflen): + raise OSError(0, 'File is corrupt', filename) + # Now put all messages from the .mo file buffer into the catalog # dictionary. for i in range(0, msgcount): diff --git a/Lib/test/test_gettext.py b/Lib/test/test_gettext.py index 9ad37909a8ec4e0..126cfc6d394b84e 100644 --- a/Lib/test/test_gettext.py +++ b/Lib/test/test_gettext.py @@ -1,6 +1,7 @@ import os import base64 import gettext +import io import unittest import unittest.mock from functools import partial @@ -314,6 +315,39 @@ def test_corrupt_file(self): self.assertEqual(exception.strerror, "File is corrupt") self.assertEqual(exception.filename, MOFILE_CORRUPT) + def test_truncated_header(self): + magic = b'\xde\x12\x04\x95' + for buf in (b'', magic, magic + bytes(15)): + with self.subTest(buflen=len(buf)): + with self.assertRaises(OSError) as cm: + gettext.GNUTranslations(io.BytesIO(buf)) + self.assertEqual(cm.exception.errno, 0) + self.assertEqual(cm.exception.strerror, "File is corrupt") + + def test_offsets_out_of_bounds(self): + buf = bytes([ + 0xde, 0x12, 0x04, 0x95, # Magic + 0x00, 0x00, 0x00, 0x00, # Version + 0x01, 0x00, 0x00, 0x00, # Message count + 0xe8, 0x03, 0x00, 0x00, # Message offset (past EOF) + 0xd0, 0x07, 0x00, 0x00, # Translation offset (past EOF) + ]) + with self.assertRaises(OSError) as cm: + gettext.GNUTranslations(io.BytesIO(buf)) + self.assertEqual(cm.exception.errno, 0) + self.assertEqual(cm.exception.strerror, "File is corrupt") + + def test_empty_catalog(self): + buf = bytes([ + 0xde, 0x12, 0x04, 0x95, # Magic + 0x00, 0x00, 0x00, 0x00, # Version + 0x00, 0x00, 0x00, 0x00, # Message count + 0x14, 0x00, 0x00, 0x00, # Message offset + 0x14, 0x00, 0x00, 0x00, # Translation offset + ]) + t = gettext.GNUTranslations(io.BytesIO(buf)) + self.assertEqual(t.gettext('foo'), 'foo') + def test_big_endian_file(self): with open(MOFILE_BIG_ENDIAN, 'rb') as fp: t = gettext.GNUTranslations(fp) diff --git a/Misc/NEWS.d/next/Library/2026-07-11-16-00-00.gh-issue-153605.Rk7Xz3.rst b/Misc/NEWS.d/next/Library/2026-07-11-16-00-00.gh-issue-153605.Rk7Xz3.rst new file mode 100644 index 000000000000000..a08fde3045c0778 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-11-16-00-00.gh-issue-153605.Rk7Xz3.rst @@ -0,0 +1,2 @@ +Fix :class:`gettext.GNUTranslations` raising :exc:`struct.error` instead of +:exc:`OSError` when reading a malformed ``.mo`` file. Patch by tonghuaroot. From 85c6bfd985b377895b14b554e56cd79939cbeeed Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sun, 25 Oct 2026 17:31:12 +0000 Subject: [PATCH 2/3] Simplify logic --- Lib/gettext.py | 11 ++++------- Lib/test/test_gettext.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Lib/gettext.py b/Lib/gettext.py index 5f449d54c76fab5..8624ce5e490c0e1 100644 --- a/Lib/gettext.py +++ b/Lib/gettext.py @@ -359,21 +359,18 @@ def _parse(self, fp): self.plural = lambda n: int(n != 1) # germanic plural by default buf = fp.read() buflen = len(buf) - # Are we big endian or little endian? - if buflen < 4: + if buflen < 20: raise OSError(0, 'File is corrupt', filename) + # Are we big endian or litle endian? magic = unpack('4I', buf[4:20]) ii = '>II' - hdr = '>4I' else: raise OSError(0, 'Bad magic number', filename) - if buflen < 20: - raise OSError(0, 'File is corrupt', filename) - version, msgcount, masteridx, transidx = unpack(hdr, buf[4:20]) major_version, minor_version = self._get_versions(version) diff --git a/Lib/test/test_gettext.py b/Lib/test/test_gettext.py index 126cfc6d394b84e..b7b4539f67bffe9 100644 --- a/Lib/test/test_gettext.py +++ b/Lib/test/test_gettext.py @@ -40,7 +40,7 @@ ''' # .mo file with an invalid magic number -GNU_MO_DATA_BAD_MAGIC_NUMBER = base64.b64encode(b'ABCD') +GNU_MO_DATA_BAD_MAGIC_NUMBER = base64.b64encode(b'ABCD' + bytes(16)) # This data contains an invalid major version number (5) # An unexpected major version number should be treated as an error when From ff3b99c2ea7d9e1ddf75e84bcdfc259f6ad3a133 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sun, 25 Oct 2026 17:32:36 +0000 Subject: [PATCH 3/3] Oops typo --- Lib/gettext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/gettext.py b/Lib/gettext.py index 8624ce5e490c0e1..e4df7cf94381b65 100644 --- a/Lib/gettext.py +++ b/Lib/gettext.py @@ -361,7 +361,7 @@ def _parse(self, fp): buflen = len(buf) if buflen < 20: raise OSError(0, 'File is corrupt', filename) - # Are we big endian or litle endian? + # Are we big endian or little endian? magic = unpack('