From f31dc7ef68cd095d29f18839af6fc3f00315053c Mon Sep 17 00:00:00 2001 From: ZHOU Zijun Date: Sat, 17 Oct 2026 15:56:07 +0800 Subject: [PATCH] gh-159475: Make parseaddr() reject inline comments in strict mode email.utils.parseaddr() and getaddresses(), in strict mode (the default), silently dropped a comment embedded inside a dot-atom and spliced the surrounding atoms together (e.g. 'u(comment)ser@d.com' -> 'user@d.com'). Strict mode now returns ('', '') for such addresses; strict=False keeps the historical lenient behavior. Signed-off-by: ZHOU Zijun --- Lib/email/_parseaddr.py | 13 +++++++ Lib/email/utils.py | 7 +++- Lib/test/test_email/test_email.py | 38 +++++++++++++++++++ ...0-15-00-00-00.gh-issue-159475.fd6201b5.rst | 8 ++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-15-00-00-00.gh-issue-159475.fd6201b5.rst diff --git a/Lib/email/_parseaddr.py b/Lib/email/_parseaddr.py index e311948cb09a7d6..b913a7174390596 100644 --- a/Lib/email/_parseaddr.py +++ b/Lib/email/_parseaddr.py @@ -242,6 +242,7 @@ def __init__(self, field): self.phraseends = self.atomends.replace('.', '') self.field = field self.commentlist = [] + self._inline_comment = False def gotonext(self): """Skip white space and extract comments.""" @@ -383,9 +384,18 @@ def getaddrspec(self): break else: aslist.append(self.getatom()) + ncomments = len(self.commentlist) ws = self.gotonext() if preserve_ws and ws: aslist.append(ws) + if (len(self.commentlist) > ncomments + and self.pos < len(self.field) + and self.field[self.pos] not in self.atomends): + # A comment was consumed inside the addr-spec and is + # followed directly by an atom, so it sits between two + # atoms and will be silently dropped, splicing them + # together. Record this so a strict caller can reject. + self._inline_comment = True if self.pos >= len(self.field) or self.field[self.pos] != '@': return EMPTYSTRING.join(aslist) @@ -408,6 +418,9 @@ def getdomain(self): self.pos += 1 elif self.field[self.pos] == '(': self.commentlist.append(self.getcomment()) + if (sdlist and self.pos < len(self.field) + and self.field[self.pos] not in self.atomends): + self._inline_comment = True elif self.field[self.pos] == '[': sdlist.append(self.getdomainliteral()) elif self.field[self.pos] == '.': diff --git a/Lib/email/utils.py b/Lib/email/utils.py index c8a6d0af37532c9..aa7b11909670dfd 100644 --- a/Lib/email/utils.py +++ b/Lib/email/utils.py @@ -180,6 +180,8 @@ def getaddresses(fieldvalues, *, strict=True): fieldvalues = _pre_parse_validation(fieldvalues) addr = COMMASPACE.join(fieldvalues) a = _AddressList(addr) + if a._inline_comment: + return [('', '')] result = _post_parse_validation(a.addresslist) # Treat output as invalid if the number of addresses is not equal to the @@ -355,7 +357,10 @@ def parseaddr(addr, *, strict=True): return ('', '') addr = _pre_parse_validation([addr])[0] - addrs = _post_parse_validation(_AddressList(addr).addresslist) + parsed = _AddressList(addr) + if parsed._inline_comment: + return ('', '') + addrs = _post_parse_validation(parsed.addresslist) if not addrs or len(addrs) > 1: return ('', '') diff --git a/Lib/test/test_email/test_email.py b/Lib/test/test_email/test_email.py index e40c82bba9af426..41285f7fa35790d 100644 --- a/Lib/test/test_email/test_email.py +++ b/Lib/test/test_email/test_email.py @@ -3546,6 +3546,44 @@ def test_getaddresses_embedded_comment(self): addrs = utils.getaddresses(['User ((nested comment)) ']) eq(addrs[0][1], 'foo@bar.com') + def test_parseaddr_inline_comment(self): + # An inline comment inside a dot-atom is invalid, but the lenient + # parser used to silently drop it and splice the surrounding atoms + # together, e.g. 'u(comment)ser@d.com' -> 'user@d.com'. That result + # disagrees with the strict parser, so in strict mode (the default) + # such addresses are now rejected. strict=False keeps the historical + # lenient behavior. + empty = ('', '') + for address, lenient in ( + # local-part comment spliced between two atoms + ('u(comment)ser@d.com', ('comment comment', 'user@d.com')), + ('u()ser@d.com', (' ', 'user@d.com')), + ('u(c(d)e)ser@d.com', ('cde cde', 'user@d.com')), + ('a.b(comment)c@d.com', ('comment comment', 'a.bc@d.com')), + # domain comment spliced between two atoms + ('user@exa(comment)mple.com', ('comment', 'user@example.com')), + ('user@exa.(comment)com', ('comment', 'user@exa.com')), + ): + with self.subTest(address=address): + self.assertEqual(utils.parseaddr(address), empty) + self.assertEqual(utils.parseaddr(address, strict=False), lenient) + self.assertEqual(utils.getaddresses([address]), [empty]) + self.assertEqual(utils.getaddresses([address], strict=False), + [lenient]) + + # Leading and trailing comments are valid CFWS and stay accepted. + self.assertEqual(utils.parseaddr('(comment)user@d.com'), + ('comment', 'user@d.com')) + self.assertEqual(utils.parseaddr('user(comment)@d.com'), + ('comment comment', 'user@d.com')) + self.assertEqual(utils.parseaddr('user@(comment)example.com'), + ('comment', 'user@example.com')) + self.assertEqual(utils.parseaddr('user@example.com(comment)'), + ('comment', 'user@example.com')) + # A comment inside a quoted string is literal content, not a comment. + self.assertEqual(utils.parseaddr('"u(comment)ser"@d.com'), + ('', '"u(comment)ser"@d.com')) + def test_getaddresses_header_obj(self): """Test the handling of a Header object.""" addrs = utils.getaddresses([Header('Al Person ')]) diff --git a/Misc/NEWS.d/next/Library/2026-10-15-00-00-00.gh-issue-159475.fd6201b5.rst b/Misc/NEWS.d/next/Library/2026-10-15-00-00-00.gh-issue-159475.fd6201b5.rst new file mode 100644 index 000000000000000..68ee52f240fac4d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-15-00-00-00.gh-issue-159475.fd6201b5.rst @@ -0,0 +1,8 @@ +:func:`email.utils.parseaddr` and :func:`email.utils.getaddresses` now +reject, in the default ``strict=True`` mode, addresses in which a comment +appears immediately before an atom (i.e. a comment embedded inside a +dot-atom). Such comments were previously silently dropped and the +surrounding atoms spliced together: ``parseaddr('u(comment)ser@d.com')`` +returned ``('comment comment', 'user@d.com')`` even though the strict parser +rejects the address. The historical lenient behavior remains available via +``strict=False``.