From dcb7d2567b6f7df15bf4de9322a962e8ee592606 Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 13 Jun 2026 15:06:31 -0700 Subject: [PATCH 1/2] gh-151454: Accept Header values in email.message.Message.add_header Message.items() can yield (name, Header) pairs under the compat32 policy (for a header carrying 8-bit data), and Message[name] = value accepts such Header values, but add_header() raised "TypeError: sequence item 0: expected str instance, Header found" because it assembled the value with SEMISPACE.join(). When no extra parameters are given, add_header() now mirrors __setitem__ and stores the value (Header or str) unmodified, fixing the reported header-copy loop. None is still coerced to '' to preserve add_header()'s historical behavior. The parameterized branch is unchanged: it still builds the header as a string and so continues to require a str value. Co-Authored-By: Claude Opus 4.8 --- Doc/library/email.compat32-message.rst | 9 +++++++++ Lib/email/message.py | 7 +++++++ Lib/test/test_email/test_email.py | 17 +++++++++++++++++ ...26-06-13-15-00-38.gh-issue-151454.BQQ7up.rst | 5 +++++ 4 files changed, 38 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst diff --git a/Doc/library/email.compat32-message.rst b/Doc/library/email.compat32-message.rst index 5754c2b65b239f9..53d05e7f1da8399 100644 --- a/Doc/library/email.compat32-message.rst +++ b/Doc/library/email.compat32-message.rst @@ -419,6 +419,15 @@ Here are the methods of the :class:`Message` class: Content-Disposition: attachment; filename*="iso-8859-1''Fu%DFballer.ppt" + When called without any *_params*, this method stores *_value* the same + way :meth:`__setitem__` does, so *_value* may also be an + :class:`~email.header.Header` instance, such as those returned by + :meth:`items` under a ``compat32`` policy. + + .. versionchanged:: next + When no parameters are given, an :class:`~email.header.Header` + *_value* is now accepted, consistent with :meth:`__setitem__`. + .. method:: replace_header(_name, _value) diff --git a/Lib/email/message.py b/Lib/email/message.py index 641fb2e944d4311..b0ab7412bdcbcc7 100644 --- a/Lib/email/message.py +++ b/Lib/email/message.py @@ -576,6 +576,13 @@ def add_header(self, _name, _value, **_params): msg.add_header('content-disposition', 'attachment', filename='Fußballer.ppt')) """ + if not _params: + # With no parameters, mirror __setitem__ so add_header() accepts + # the same values, e.g. the Header instances that items() returns + # under the compat32 policy. None is coerced to '' to preserve + # add_header()'s historical behavior (__setitem__ would store None). + self[_name] = '' if _value is None else _value + return parts = [] for k, v in _params.items(): if v is None: diff --git a/Lib/test/test_email/test_email.py b/Lib/test/test_email/test_email.py index 19555d87085e176..950e4d3d35e04fa 100644 --- a/Lib/test/test_email/test_email.py +++ b/Lib/test/test_email/test_email.py @@ -839,6 +839,23 @@ def test_add_header_with_no_value(self): msg.add_header('X-Status', None) self.assertEqual('', msg['X-Status']) + def test_add_header_copies_Header_returned_by_items(self): + # gh-151454: items() returns (name, Header) under the compat32 policy + # for a header carrying 8-bit data. Copying such headers with the + # bug report's loop (newmsg.add_header(h, v)) used to raise + # "TypeError: sequence item 0: expected str instance, Header found". + old = email.message_from_bytes(b'X-Ham-Report: spam \xff report\n\n') + name, value = old.items()[0] + self.assertIsInstance(value, Header) + msg = Message() + msg.add_header(name, value) + # The Header is stored unmodified, exactly as __setitem__ does (rather + # than stringified, which would lose the 8-bit bytes to U+FFFD). + self.assertIs(next(msg.raw_items())[1], value) + ref = Message() + ref[name] = value + self.assertEqual(msg.as_bytes(), ref.as_bytes()) + # Issue 5871: reject an attempt to embed a header inside a header value # (header injection attack). def test_embedded_header_via_Header_rejected(self): diff --git a/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst b/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst new file mode 100644 index 000000000000000..e5f07fee66d616d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst @@ -0,0 +1,5 @@ +When called without extra parameters, :meth:`email.message.Message.add_header` +now stores the value the same way ``msg[name] = value`` does, so it accepts the +:class:`~email.header.Header` instances that :meth:`~email.message.Message.items` +returns under the ``compat32`` policy. Previously it raised ``TypeError: +sequence item 0: expected str instance, Header found``. From 1ac364a6b2d656223ae3e6ed5b67f85d3cdaf79d Mon Sep 17 00:00:00 2001 From: harjoth Date: Fri, 14 Aug 2026 23:06:07 -0700 Subject: [PATCH 2/2] gh-151454: Raise a clear error when add_header() gets a Header with params Combining a Header value with parameters was already rejected, but with the confusing internal TypeError from SEMISPACE.join(). Raise a clear one instead. --- Doc/library/email.compat32-message.rst | 6 +++++- Lib/email/message.py | 8 ++++++++ Lib/test/test_email/test_email.py | 9 +++++++++ .../2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst | 4 +++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Doc/library/email.compat32-message.rst b/Doc/library/email.compat32-message.rst index 53d05e7f1da8399..b8710314aa05c7e 100644 --- a/Doc/library/email.compat32-message.rst +++ b/Doc/library/email.compat32-message.rst @@ -422,11 +422,15 @@ Here are the methods of the :class:`Message` class: When called without any *_params*, this method stores *_value* the same way :meth:`__setitem__` does, so *_value* may also be an :class:`~email.header.Header` instance, such as those returned by - :meth:`items` under a ``compat32`` policy. + :meth:`items` under a ``compat32`` policy. Combining a + :class:`~email.header.Header` *_value* with *_params* raises + :exc:`TypeError`, since the parameters have no defined meaning for it. .. versionchanged:: next When no parameters are given, an :class:`~email.header.Header` *_value* is now accepted, consistent with :meth:`__setitem__`. + Combining a :class:`~email.header.Header` *_value* with parameters + now raises a clear :exc:`TypeError` instead of an internal one. .. method:: replace_header(_name, _value) diff --git a/Lib/email/message.py b/Lib/email/message.py index b0ab7412bdcbcc7..06a6baedf37c7c3 100644 --- a/Lib/email/message.py +++ b/Lib/email/message.py @@ -583,6 +583,14 @@ def add_header(self, _name, _value, **_params): # add_header()'s historical behavior (__setitem__ would store None). self[_name] = '' if _value is None else _value return + if _value is not None and not isinstance(_value, str): + # Combining a non-str value (e.g. a Header instance) with + # parameters isn't supported: raise a clear error instead of + # failing inside SEMISPACE.join() below. + raise TypeError( + f'add_header() value must be a str when parameters are ' + f'given, not {type(_value).__name__}; pass a str, or omit ' + f'the parameters and set the header with msg[name] = value') parts = [] for k, v in _params.items(): if v is None: diff --git a/Lib/test/test_email/test_email.py b/Lib/test/test_email/test_email.py index 950e4d3d35e04fa..0d586f15ece14e5 100644 --- a/Lib/test/test_email/test_email.py +++ b/Lib/test/test_email/test_email.py @@ -856,6 +856,15 @@ def test_add_header_copies_Header_returned_by_items(self): ref[name] = value self.assertEqual(msg.as_bytes(), ref.as_bytes()) + def test_add_header_with_Header_value_and_params_raises_clear_error(self): + # gh-151454: a Header combined with params isn't supported (the + # params would have no defined meaning for it), so this should raise + # a clear TypeError rather than fail inside SEMISPACE.join(). + msg = Message() + header = Header('spam \xff report', charset='unknown-8bit') + with self.assertRaisesRegex(TypeError, 'must be a str'): + msg.add_header('X-Ham-Report', header, foo='bar') + # Issue 5871: reject an attempt to embed a header inside a header value # (header injection attack). def test_embedded_header_via_Header_rejected(self): diff --git a/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst b/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst index e5f07fee66d616d..33418824598d641 100644 --- a/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst +++ b/Misc/NEWS.d/next/Library/2026-06-13-15-00-38.gh-issue-151454.BQQ7up.rst @@ -2,4 +2,6 @@ When called without extra parameters, :meth:`email.message.Message.add_header` now stores the value the same way ``msg[name] = value`` does, so it accepts the :class:`~email.header.Header` instances that :meth:`~email.message.Message.items` returns under the ``compat32`` policy. Previously it raised ``TypeError: -sequence item 0: expected str instance, Header found``. +sequence item 0: expected str instance, Header found``. Combining a +:class:`~email.header.Header` value with parameters now raises a clear +``TypeError`` instead of that same confusing message.