Skip to content

Commit 0234e2c

Browse files
medmundsbitdancer
authored andcommitted
[3.10] gh-80222: Fix email address header folding with long quoted-string (GH-122753)
Email generators using email.policy.default could incorrectly omit the quote ('"') characters from a quoted-string during header refolding, leading to invalid address headers and enabling header spoofing. This change restores the quote characters on a bare-quoted-string as the header is refolded, and escapes backslash and quote chars in the string. (cherry picked from commit 5aaf416) Co-authored-by: Mike Edmunds <medmunds@gmail.com>
1 parent 8773554 commit 0234e2c

File tree

3 files changed

+64
-1
lines changed

3 files changed

+64
-1
lines changed

Lib/email/_header_value_parser.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,16 @@
9595
NLSET = {'\n', '\r'}
9696
SPECIALSNL = SPECIALS | NLSET
9797

98+
99+
def make_quoted_pairs(value):
100+
"""Escape dquote and backslash for use within a quoted-string."""
101+
return str(value).replace('\\', '\\\\').replace('"', '\\"')
102+
103+
98104
def quote_string(value):
99-
return '"'+str(value).replace('\\', '\\\\').replace('"', r'\"')+'"'
105+
escaped = make_quoted_pairs(value)
106+
return f'"{escaped}"'
107+
100108

101109
# Match a RFC 2047 word, looks like =?utf-8?q?someword?=
102110
rfc2047_matcher = re.compile(r'''
@@ -2848,6 +2856,15 @@ def _refold_parse_tree(parse_tree, *, policy):
28482856
if not hasattr(part, 'encode'):
28492857
# It's not a terminal, try folding the subparts.
28502858
newparts = list(part)
2859+
if part.token_type == 'bare-quoted-string':
2860+
# To fold a quoted string we need to create a list of terminal
2861+
# tokens that will render the leading and trailing quotes
2862+
# and use quoted pairs in the value as appropriate.
2863+
newparts = (
2864+
[ValueTerminal('"', 'ptext')] +
2865+
[ValueTerminal(make_quoted_pairs(p), 'ptext')
2866+
for p in newparts] +
2867+
[ValueTerminal('"', 'ptext')])
28512868
if not part.as_ew_allowed:
28522869
wrap_as_ew_blocked += 1
28532870
newparts.append(end_ew_not_allowed)

Lib/test/test_email/test__header_value_parser.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2946,6 +2946,46 @@ def test_address_list_with_unicode_names_in_quotes(self):
29462946
'=?utf-8?q?H=C3=BCbsch?= Kaktus <beautiful@example.com>,\n'
29472947
' =?utf-8?q?bei=C3=9Ft_bei=C3=9Ft?= <biter@example.com>\n')
29482948

2949+
def test_address_list_with_list_separator_after_fold(self):
2950+
a = 'x' * 66 + '@example.com'
2951+
to = f'{a}, "Hübsch Kaktus" <beautiful@example.com>'
2952+
self._test(parser.get_address_list(to)[0],
2953+
f'{a},\n =?utf-8?q?H=C3=BCbsch?= Kaktus <beautiful@example.com>\n')
2954+
2955+
a = '.' * 79 # ('.' is a special, so must be in quoted-string.)
2956+
to = f'"{a}" <xyz@example.com>, "Hübsch Kaktus" <beautiful@example.com>'
2957+
self._test(parser.get_address_list(to)[0],
2958+
f'"{a}"\n'
2959+
' <xyz@example.com>, =?utf-8?q?H=C3=BCbsch?= Kaktus '
2960+
'<beautiful@example.com>\n')
2961+
2962+
def test_address_list_with_specials_in_long_quoted_string(self):
2963+
# Regression for gh-80222.
2964+
policy = self.policy.clone(max_line_length=40)
2965+
cases = [
2966+
# (to, folded)
2967+
('"Exfiltrator <spy@example.org> (unclosed comment?" <to@example.com>',
2968+
'"Exfiltrator <spy@example.org> (unclosed\n'
2969+
' comment?" <to@example.com>\n'),
2970+
('"Escaped \\" chars \\\\ in quoted-string stay escaped" <to@example.com>',
2971+
'"Escaped \\" chars \\\\ in quoted-string\n'
2972+
' stay escaped" <to@example.com>\n'),
2973+
('This long display name does not need quotes <to@example.com>',
2974+
'This long display name does not need\n'
2975+
' quotes <to@example.com>\n'),
2976+
('"Quotes are not required but are retained here" <to@example.com>',
2977+
'"Quotes are not required but are\n'
2978+
' retained here" <to@example.com>\n'),
2979+
('"A quoted-string, it can be a valid local-part"@example.com',
2980+
'"A quoted-string, it can be a valid\n'
2981+
' local-part"@example.com\n'),
2982+
('"local-part-with-specials@but-no-fws.cannot-fold"@example.com',
2983+
'"local-part-with-specials@but-no-fws.cannot-fold"@example.com\n'),
2984+
]
2985+
for (to, folded) in cases:
2986+
with self.subTest(to=to):
2987+
self._test(parser.get_address_list(to)[0], folded, policy=policy)
2988+
29492989
# XXX Need tests with comments on various sides of a unicode token,
29502990
# and with unicode tokens in the comments. Spaces inside the quotes
29512991
# currently don't do the right thing.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fix bug in the folding of quoted strings when flattening an email message using
2+
a modern email policy. Previously when a quoted string was folded so that
3+
it spanned more than one line, the surrounding quotes and internal escapes
4+
would be omitted. This could theoretically be used to spoof header lines
5+
using a carefully constructed quoted string if the resulting rendered email
6+
was transmitted or re-parsed.

0 commit comments

Comments
 (0)