Skip to content

Commit 344f5cd

Browse files
committed
gh-49288: Resend POST body on HTTP auth retry
Fix :mod:`urllib.request` HTTP authentication retries sending an empty POST body when :attr:`~urllib.request.Request.data` is a file-like object. The body is now rewound to the position it was in before the original request was sent; if it is not seekable, the retry is abandoned and the ``401`` response is returned to the caller instead.
1 parent 1159836 commit 344f5cd

4 files changed

Lines changed: 353 additions & 4 deletions

File tree

‎Doc/library/urllib.request.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,9 @@ HTTPBasicAuthHandler Objects
10761076

10771077
Retry the request with authentication information, if available.
10781078

1079+
The :attr:`Request.data` of a POST or PUT request is resent. However,
1080+
if it is a file-like object that is not seekable, the retry is
1081+
abandoned and the ``401`` response is returned to the caller instead.
10791082

10801083
.. _proxy-basic-auth-handler:
10811084

@@ -1115,6 +1118,10 @@ HTTPDigestAuthHandler Objects
11151118

11161119
Retry the request with authentication information, if available.
11171120

1121+
The :attr:`Request.data` of a POST or PUT request is resent. However,
1122+
if it is a file-like object that is not seekable, the retry is
1123+
abandoned and the ``401`` response is returned to the caller instead.
1124+
11181125

11191126
.. _proxy-digest-auth-handler:
11201127

‎Lib/test/test_urllib2.py‎

Lines changed: 309 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import os
1010
import io
11+
import mmap
1112
import ftplib
1213
import socket
1314
import array
@@ -18,10 +19,11 @@
1819
import urllib.request
1920
# The proxy bypass method imported below has logic specific to the
2021
# corresponding system but is testable on all platforms.
21-
from urllib.request import (Request, OpenerDirector, HTTPBasicAuthHandler,
22-
HTTPPasswordMgrWithPriorAuth, _parse_proxy,
23-
_proxy_bypass_winreg_override,
24-
_proxy_bypass_macosx_sysconf,
22+
from urllib.request import (HTTPDefaultErrorHandler, HTTPErrorProcessor,
23+
Request, OpenerDirector, HTTPBasicAuthHandler,
24+
HTTPPasswordMgrWithPriorAuth,
25+
_parse_proxy, _proxy_bypass_winreg_override,
26+
_proxy_bypass_macosx_sysconf, _rewind_auth_retry_body,
2527
AbstractDigestAuthHandler)
2628
from urllib.parse import urlsplit
2729
import urllib.error
@@ -599,6 +601,113 @@ def http_open(self, req):
599601
return MockResponse(self.code, name, MockFile(), "", req.get_full_url())
600602

601603

604+
class _AuthRetrySocket:
605+
def __init__(self, response, sent_requests):
606+
self._response = io.BytesIO(response)
607+
self._sent_requests = sent_requests
608+
self._closed = False
609+
610+
def sendall(self, data):
611+
self._sent_requests.append(data)
612+
613+
def makefile(self, mode='r', buffering=None, *, encoding=None, errors=None):
614+
return self
615+
616+
def read(self, amt=-1):
617+
if self._closed:
618+
return b""
619+
return self._response.read(amt)
620+
621+
def readline(self, length=-1):
622+
if self._closed:
623+
return b""
624+
return self._response.readline(length)
625+
626+
def flush(self):
627+
self._response.flush()
628+
629+
def close(self):
630+
self._closed = True
631+
632+
def _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, data):
633+
conn_cls.sent_requests.clear()
634+
conn_cls._request_count = 0
635+
opener = OpenerDirector()
636+
opener.add_handler(auth_handler)
637+
opener.add_handler(http_handler_cls())
638+
opener.add_handler(HTTPErrorProcessor())
639+
opener.add_handler(HTTPDefaultErrorHandler())
640+
opener.open(Request(url, data, method="POST"))
641+
return conn_cls.sent_requests
642+
643+
def _decode_chunked_http_body(raw):
644+
body = raw
645+
result = b""
646+
while body:
647+
size_line, sep, body = body.partition(b"\r\n")
648+
if not sep:
649+
break
650+
size = int(size_line.split(b";", 1)[0], 16)
651+
if size == 0:
652+
break
653+
result += body[:size]
654+
body = body[size+2:]
655+
return result
656+
657+
def _group_sent_by_request(sent):
658+
groups = []
659+
current = []
660+
for chunk in sent:
661+
if chunk.startswith(b"POST ") and current:
662+
groups.append(current)
663+
current = [chunk]
664+
else:
665+
current.append(chunk)
666+
667+
if current:
668+
groups.append(current)
669+
return groups
670+
671+
def _body_from_request_chunks(chunks):
672+
return _body_from_sent_http_request(b"".join(chunks))
673+
674+
def _body_from_sent_http_request(raw):
675+
_, sep, body = raw.partition(b"\r\n\r\n")
676+
if not sep:
677+
return b""
678+
header_block = raw[:raw.index(sep)].lower()
679+
if b"transfer-encoding: chunked" in header_block:
680+
return _decode_chunked_http_body(body)
681+
if b"content-length: 0" in header_block:
682+
return b""
683+
return body.split(b"\r\n", 1)[0]
684+
685+
def _make_auth_retry_http_connection(responses):
686+
class AuthRetryHTTPConnection(http.client.HTTPConnection):
687+
sent_requests = []
688+
_request_count = 0
689+
690+
def connect(self):
691+
request_index = type(self)._request_count
692+
type(self)._request_count += 1
693+
response = responses[request_index]
694+
self.sock = _AuthRetrySocket(response, type(self).sent_requests)
695+
696+
return AuthRetryHTTPConnection
697+
698+
def _make_auth_retry_http_handler(responses):
699+
conn_cls = _make_auth_retry_http_connection(responses)
700+
701+
class AuthRetryHTTPHandler(urllib.request.HTTPHandler):
702+
def __init__(self):
703+
super().__init__()
704+
self.httpconn = conn_cls
705+
706+
def http_open(self, req):
707+
return self.do_open(self.httpconn, req)
708+
709+
return AuthRetryHTTPHandler, conn_cls
710+
602711

603712
class MockPasswordManager:
604713
def add_password(self, realm, uri, user, password):
@@ -1921,6 +2030,202 @@ def test_basic_prior_auth_send_after_first_success(self):
19212030
# expect request to be sent with auth header
19222031
self.assertTrue(http_handler.has_auth_header)
19232032

2033+
def test_rewind_auth_retry_body_no_data(self):
2034+
req = Request("http://example.com/")
2035+
self.assertTrue(_rewind_auth_retry_body(req))
2036+
2037+
def test_rewind_auth_retry_body_non_file_like(self):
2038+
req = Request("http://example.com/", b"abc")
2039+
self.assertTrue(_rewind_auth_retry_body(req))
2040+
2041+
def test_rewind_auth_retry_body(self):
2042+
data = io.BytesIO(b"xxxabc")
2043+
data.seek(3)
2044+
req = Request("http://example.com/", data)
2045+
req._body_start_pos = 3
2046+
data.read()
2047+
self.assertTrue(_rewind_auth_retry_body(req))
2048+
self.assertEqual(data.tell(), 3)
2049+
self.assertEqual(data.read(), b"abc")
2050+
2051+
def test_rewind_auth_retry_body_non_seekable(self):
2052+
class NonSeekableReader:
2053+
def read(self, n=-1):
2054+
return b"abc"
2055+
2056+
req = Request("http://example.com", NonSeekableReader())
2057+
self.assertFalse(_rewind_auth_retry_body(req))
2058+
2059+
def test_basic_auth_retry_file_body(self):
2060+
realm = "acme"
2061+
url = "http://example.com/"
2062+
body = b"basic auth retry file body"
2063+
auth_401 = (
2064+
b"HTTP/1.1 401 Unauthorized\r\n"
2065+
b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n'
2066+
b"Connection: close\r\n\r\n"
2067+
)
2068+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2069+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2070+
2071+
password_manager = MockPasswordManager()
2072+
password_manager.add_password(realm, url, "user", "pass")
2073+
auth_handler = HTTPBasicAuthHandler(password_manager)
2074+
2075+
sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, io.BytesIO(body))
2076+
groups = _group_sent_by_request(sent)
2077+
self.assertEqual(len(groups), 2)
2078+
self.assertEqual(_body_from_request_chunks(groups[0]), body)
2079+
self.assertEqual(_body_from_request_chunks(groups[1]), body)
2080+
2081+
def test_basic_auth_retry_file_body_from_nonzero_offset(self):
2082+
# The retry must resume from the position the body was in before
2083+
# the original request was sent, not from the start of the stream.
2084+
realm = "acme"
2085+
url = "http://example.com/"
2086+
prefix = b"SKIPME|"
2087+
body = b"basic auth retry file body"
2088+
auth_401 = (
2089+
b"HTTP/1.1 401 Unauthorized\r\n"
2090+
b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n'
2091+
b"Connection: close\r\n\r\n"
2092+
)
2093+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2094+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2095+
2096+
password_manager = MockPasswordManager()
2097+
password_manager.add_password(realm, url, "user", "pass")
2098+
auth_handler = HTTPBasicAuthHandler(password_manager)
2099+
2100+
data = io.BytesIO(prefix + body)
2101+
data.seek(len(prefix))
2102+
sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, data)
2103+
groups = _group_sent_by_request(sent)
2104+
self.assertEqual(len(groups), 2)
2105+
self.assertEqual(_body_from_request_chunks(groups[0]), body)
2106+
self.assertEqual(_body_from_request_chunks(groups[1]), body)
2107+
2108+
def test_basic_auth_retry_mmap_body(self):
2109+
realm = "acme"
2110+
url = "http://www.example.com/"
2111+
body = b"mmap auth retry file body"
2112+
auth_401 = (
2113+
b"HTTP/1.1 401 Unauthorized\r\n"
2114+
b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n'
2115+
b"Connection: close\r\n\r\n"
2116+
)
2117+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2118+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2119+
password_manager = MockPasswordManager()
2120+
password_manager.add_password(realm, url, "user", "pass")
2121+
auth_handler = HTTPBasicAuthHandler(password_manager)
2122+
2123+
with tempfile.NamedTemporaryFile() as tmp:
2124+
tmp.write(body)
2125+
tmp.flush()
2126+
with mmap.mmap(tmp.fileno(), 0, access=mmap.ACCESS_READ) as mm:
2127+
sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, mm)
2128+
2129+
groups = _group_sent_by_request(sent)
2130+
self.assertEqual(len(groups), 2)
2131+
self.assertEqual(_body_from_request_chunks(groups[0]), body)
2132+
self.assertEqual(_body_from_request_chunks(groups[1]), body)
2133+
2134+
def test_digest_auth_retry_file_body(self):
2135+
realm = "acme"
2136+
url = "http://example.com/"
2137+
body = b"digest auth retry file body"
2138+
auth_401 = (
2139+
b"HTTP/1.1 401 Unauthorized\r\n"
2140+
b'WWW-Authenticate: Digest realm="' + realm.encode() + b'", '
2141+
b'qop="auth", nonce="abc", opaque="def"\r\n'
2142+
b"Connection: close\r\n\r\n"
2143+
)
2144+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2145+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2146+
2147+
password_manager = MockPasswordManager()
2148+
password_manager.add_password(realm, url, "user", "pass")
2149+
auth_handler = urllib.request.HTTPDigestAuthHandler(password_manager)
2150+
2151+
sent = _open_with_auth_retry(auth_handler, http_handler_cls, conn_cls, url, io.BytesIO(body))
2152+
groups = _group_sent_by_request(sent)
2153+
self.assertEqual(len(groups), 2)
2154+
self.assertEqual(_body_from_request_chunks(groups[0]), body)
2155+
self.assertEqual(_body_from_request_chunks(groups[1]), body)
2156+
2157+
def test_basic_auth_retry_explicit_content_length(self):
2158+
realm = "acme"
2159+
url = "http://example.com/"
2160+
body = b"x" * 30
2161+
auth_401 = (
2162+
b"HTTP/1.1 401 Unauthorized\r\n"
2163+
b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n'
2164+
b"Connection: close\r\n\r\n"
2165+
)
2166+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2167+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2168+
2169+
password_manager = MockPasswordManager()
2170+
password_manager.add_password(realm, url, "user", "pass")
2171+
auth_handler = HTTPBasicAuthHandler(password_manager)
2172+
2173+
conn_cls.sent_requests.clear()
2174+
conn_cls._request_count = 0
2175+
opener = OpenerDirector()
2176+
opener.add_handler(auth_handler)
2177+
opener.add_handler(http_handler_cls())
2178+
opener.add_handler(HTTPErrorProcessor())
2179+
opener.add_handler(HTTPDefaultErrorHandler())
2180+
2181+
req = Request(url, io.BytesIO(body), {"Content-Length": "30"})
2182+
opener.open(req)
2183+
sent = conn_cls.sent_requests
2184+
2185+
groups = _group_sent_by_request(sent)
2186+
self.assertEqual(len(groups), 2)
2187+
self.assertEqual(_body_from_request_chunks(groups[0]), body)
2188+
self.assertEqual(_body_from_request_chunks(groups[1]), body)
2189+
2190+
def test_basic_auth_retry_non_seekable_body(self):
2191+
realm = "acme"
2192+
url = "http://example.com/"
2193+
auth_401 = (
2194+
b"HTTP/1.1 401 Unauthorized\r\n"
2195+
b'WWW-Authenticate: Basic realm="' + realm.encode() + b'"\r\n'
2196+
b"Connection: close\r\n\r\n"
2197+
)
2198+
auth_200 = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
2199+
http_handler_cls, conn_cls = _make_auth_retry_http_handler([auth_401, auth_200])
2200+
2201+
password_manager = MockPasswordManager()
2202+
password_manager.add_password(realm, url, "user", "pass")
2203+
auth_handler = HTTPBasicAuthHandler(password_manager)
2204+
2205+
class NonSeekableReader:
2206+
_data = b"pipe-data"
2207+
2208+
def read(self, n=-1):
2209+
if self._data is None:
2210+
return b""
2211+
chunk = self._data
2212+
self._data = None
2213+
return chunk
2214+
2215+
opener = OpenerDirector()
2216+
opener.add_handler(auth_handler)
2217+
opener.add_handler(http_handler_cls())
2218+
opener.add_handler(HTTPErrorProcessor())
2219+
opener.add_handler(HTTPDefaultErrorHandler())
2220+
2221+
req = Request(url, NonSeekableReader(), method="POST")
2222+
with self.assertRaises(urllib.error.HTTPError) as cm:
2223+
opener.open(req)
2224+
self.addCleanup(cm.exception.close)
2225+
self.assertEqual(cm.exception.code, 401)
2226+
# The retry was abandoned rather than resending a partial body.
2227+
self.assertEqual(len(_group_sent_by_request(conn_cls.sent_requests)), 1)
2228+
19242229
def test_http_closed(self):
19252230
"""Test the connection is cleaned up when the response is closed"""
19262231
for (transfer, data) in (

0 commit comments

Comments
 (0)