Skip to content

Commit a899e9c

Browse files
committed
fix: fix review comment
1 parent 6aa8b23 commit a899e9c

File tree

2 files changed

+90
-63
lines changed

2 files changed

+90
-63
lines changed

Lib/poplib.py

Lines changed: 86 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
# Imports
1515

16+
import binascii
1617
import errno
1718
import re
1819
import socket
@@ -47,6 +48,8 @@ class error_proto(Exception): pass
4748
# 512 characters, including CRLF. We have selected 2048 just to be on
4849
# the safe side.
4950
_MAXLINE = 2048
51+
# maximum number of AUTH challenges we are willing to process (parity with smtplib)
52+
_MAXCHALLENGE = 5
5053

5154

5255
class POP3:
@@ -218,61 +221,6 @@ def pass_(self, pswd):
218221
"""
219222
return self._shortcmd('PASS %s' % pswd)
220223

221-
def auth(self, mechanism, authobject=None, initial_response=None):
222-
"""Authenticate to the POP3 server using the AUTH command (RFC 5034).
223-
224-
Result is 'response'.
225-
"""
226-
if authobject is not None and initial_response is not None:
227-
raise ValueError('authobject and initial_response are mutually exclusive')
228-
229-
if initial_response is not None:
230-
if isinstance(initial_response, str):
231-
initial_response = initial_response.encode(self.encoding)
232-
b64 = base64.b64encode(initial_response).decode('ascii')
233-
return self._shortcmd(f'AUTH {mechanism} {b64}'.rstrip())
234-
235-
if authobject is None:
236-
return self._shortcmd(f'AUTH {mechanism}')
237-
238-
self._putcmd(f'AUTH {mechanism}')
239-
while True:
240-
line, _ = self._getline()
241-
if line.startswith(b'+OK'):
242-
return line
243-
if line.startswith(b'-ERR'):
244-
while self._getline() != b'.\r\n':
245-
pass
246-
raise error_proto(line.decode('ascii', 'replace'))
247-
248-
if not line.startswith(b'+ '):
249-
raise error_proto(f'malformed challenge line: {line!r}')
250-
251-
challenge_b64 = line[2:]
252-
challenge_b64 = challenge_b64.rstrip(b'\r\n')
253-
254-
if challenge_b64:
255-
try:
256-
challenge = base64.b64decode(challenge_b64)
257-
except Exception:
258-
padded = challenge_b64 + b'=' * (-len(challenge_b64) % 4)
259-
challenge = base64.b64decode(padded)
260-
else:
261-
challenge = b''
262-
263-
response = authobject(challenge)
264-
if response is None:
265-
response = b''
266-
if isinstance(response, str):
267-
response = response.encode(self.encoding)
268-
269-
if response == b'*':
270-
self._putcmd('*')
271-
err_line, _ = self._getline()
272-
raise error_proto(err_line.decode('ascii', 'replace'))
273-
274-
self._putcmd(base64.b64encode(response).decode('ascii'))
275-
276224
def stat(self):
277225
"""Get mailbox status.
278226
@@ -479,6 +427,88 @@ def stls(self, context=None):
479427
self._tls_established = True
480428
return resp
481429

430+
def auth(self, mechanism, authobject, *, initial_response_ok=True):
431+
"""Authenticate to the POP3 server using AUTH (RFC 5034).
432+
433+
Result is 'response'.
434+
"""
435+
mech = mechanism.upper()
436+
437+
initial = None
438+
if initial_response_ok:
439+
try:
440+
initial = authobject()
441+
except TypeError:
442+
initial = None
443+
if isinstance(initial, str):
444+
initial = initial.encode('ascii', 'strict')
445+
if initial is not None and not isinstance(initial, (bytes, bytearray)):
446+
raise TypeError('authobject() must return str or bytes for initial response')
447+
448+
if initial is not None:
449+
b64 = base64.b64encode(initial).decode('ascii')
450+
cmd = f'AUTH {mech} {b64}'
451+
if len(cmd.encode('ascii')) + 2 <= 255:
452+
self._putcmd(cmd)
453+
else:
454+
self._putcmd(f'AUTH {mech}')
455+
else:
456+
self._putcmd(f'AUTH {mech}')
457+
458+
auth_challenge_count = 0
459+
while True:
460+
line, _ = self._getline()
461+
462+
if line.startswith(b'+OK'):
463+
return line
464+
465+
if line.startswith(b'-ERR'):
466+
raise error_proto(line.decode('ascii', 'replace'))
467+
# Challenge line: "+ <b64>" or just "+" (empty challenge)
468+
if not (line == b'+' or line.startswith(b'+ ')):
469+
raise error_proto(f'malformed AUTH challenge line: {line!r}')
470+
471+
auth_challenge_count += 1
472+
if auth_challenge_count > _MAXCHALLENGE:
473+
raise error_proto('Server AUTH mechanism infinite loop')
474+
475+
chal = line[1:]
476+
if chal.startswith(b' '):
477+
chal = chal[1:]
478+
chal = chal.rstrip(b'\r\n')
479+
if chal:
480+
try:
481+
challenge = base64.b64decode(chal, validate=True)
482+
except (binascii.Error, ValueError):
483+
self._putcmd('*')
484+
line, _ = self._getline()
485+
raise error_proto(line.decode('ascii', 'replace'))
486+
else:
487+
challenge = b''
488+
489+
resp = authobject(challenge)
490+
if resp is None:
491+
resp = b''
492+
if isinstance(resp, str):
493+
resp = resp.encode('ascii', 'strict')
494+
if not isinstance(resp, (bytes, bytearray)):
495+
raise TypeError('authobject(challenge) must return str or bytes')
496+
497+
if resp == b'*':
498+
self._putcmd('*')
499+
else:
500+
self._putcmd(base64.b64encode(resp).decode('ascii'))
501+
502+
def auth_plain(self, user, password, authzid=''):
503+
"""Return an authobject suitable for SASL PLAIN.
504+
505+
Result is 'str'.
506+
"""
507+
def _auth_plain(challenge=None):
508+
# Per RFC 4616, the response is: authzid UTF8 NUL authcid UTF8 NUL passwd UTF8
509+
return f"{authzid}\0{user}\0{password}"
510+
return _auth_plain
511+
482512

483513
if HAVE_SSL:
484514

@@ -513,6 +543,7 @@ def stls(self, context=None):
513543
SSL/TLS session.
514544
"""
515545
raise error_proto('-ERR TLS session already established')
546+
516547

517548
__all__.append("POP3_SSL")
518549

Lib/test/test_poplib.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ def test_pass_(self):
326326

327327
def test_auth_plain_initial_response(self):
328328
secret = b"user\x00adminuser\x00password"
329-
resp = self.client.auth("PLAIN", initial_response=secret)
329+
resp = self.client.auth("PLAIN", authobject=lambda: secret)
330330
self.assertStartsWith(resp, b"+OK")
331331

332332
def test_auth_plain_challenge_response(self):
@@ -336,13 +336,9 @@ def authobject(challenge):
336336
resp = self.client.auth("PLAIN", authobject=authobject)
337337
self.assertStartsWith(resp, b"+OK")
338338

339-
def test_auth_rejects_conflicting_args(self):
340-
with self.assertRaises(ValueError):
341-
self.client.auth("PLAIN", authobject=lambda c: b"x", initial_response=b"y")
342-
343339
def test_auth_unsupported_mechanism(self):
344340
with self.assertRaises(poplib.error_proto):
345-
self.client.auth("FOO")
341+
self.client.auth("FOO", authobject=lambda: b"")
346342

347343
def test_auth_cancel(self):
348344
def authobject(_challenge):
@@ -353,12 +349,12 @@ def authobject(_challenge):
353349
def test_auth_mechanism_case_insensitive(self):
354350
secret = b"user\x00adminuser\x00password"
355351
# use lowercase mechanism name to ensure server accepts
356-
resp = self.client.auth("plain", initial_response=secret)
352+
resp = self.client.auth("plain", authobject=lambda: secret)
357353
self.assertStartsWith(resp, b"+OK")
358354

359355
def test_auth_initial_response_str(self):
360356
secret = "user\x00adminuser\x00password" # str, not bytes
361-
resp = self.client.auth("PLAIN", initial_response=secret)
357+
resp = self.client.auth("PLAIN", authobject=lambda: secret)
362358
self.assertStartsWith(resp, b"+OK")
363359

364360
def test_auth_authobject_returns_str(self):

0 commit comments

Comments
 (0)