Skip to content

Commit dc1d65d

Browse files
committed
feat: improve behavior of HTTP redirects
This commit modifies the Python core so that it will include "safe" headers when performing a cross-site redirect where both the original and redirected hosts are within IBM's "cloud.ibm.com" domain. Signed-off-by: Norbert Biczo <[email protected]>
1 parent c552ce3 commit dc1d65d

File tree

2 files changed

+184
-2
lines changed

2 files changed

+184
-2
lines changed

ibm_cloud_sdk_core/base_service.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
from http.cookiejar import CookieJar
2323
from os.path import basename
2424
from typing import Dict, List, Optional, Tuple, Union
25-
from urllib3.util.retry import Retry
2625

2726
import requests
2827
from requests.structures import CaseInsensitiveDict
2928
from requests.exceptions import JSONDecodeError
29+
from urllib.parse import urlparse
30+
from urllib3.exceptions import MaxRetryError
31+
from urllib3.util.retry import Retry
3032

3133
from ibm_cloud_sdk_core.authenticators import Authenticator
3234
from .api_exception import ApiException
@@ -52,6 +54,10 @@
5254
logger = logging.getLogger(__name__)
5355

5456

57+
MAX_REDIRECTS = 10
58+
SAFE_HEADERS = ['authorization', 'www-authenticate', 'cookie', 'cookie2']
59+
60+
5561
# pylint: disable=too-many-instance-attributes
5662
# pylint: disable=too-many-locals
5763
class BaseService:
@@ -294,7 +300,9 @@ def send(self, request: requests.Request, **kwargs) -> DetailedResponse:
294300
"""
295301
# Use a one minute timeout when our caller doesn't give a timeout.
296302
# http://docs.python-requests.org/en/master/user/quickstart/#timeouts
297-
kwargs = dict({"timeout": 60}, **kwargs)
303+
# We also disable the default redirection, to have more granular control
304+
# over the headers sent in each request.
305+
kwargs = dict({'timeout': 60, 'allow_redirects': False}, **kwargs)
298306
kwargs = dict(kwargs, **self.http_config)
299307

300308
if self.disable_ssl_verification:
@@ -314,6 +322,34 @@ def send(self, request: requests.Request, **kwargs) -> DetailedResponse:
314322
try:
315323
response = self.http_client.request(**request, cookies=self.jar, **kwargs)
316324

325+
# Handle HTTP redirects.
326+
redirects_count = 0
327+
# Check if the response is a redirect to another host.
328+
while response.is_redirect and response.next is not None and redirects_count < MAX_REDIRECTS:
329+
redirects_count += 1
330+
331+
# urllib3 has already prepared a request that can almost be used as-is.
332+
next_request = response.next
333+
334+
# If both the original and the redirected URL are under the `.cloud.ibm.com` domain,
335+
# copy the safe headers that are used for authentication purposes,
336+
if self.service_url.endswith('.cloud.ibm.com') and urlparse(next_request.url).netloc.endswith('.cloud.ibm.com'):
337+
original_headers = request.get('headers')
338+
for header, value in original_headers.items():
339+
if header.lower() in SAFE_HEADERS:
340+
next_request.headers[header] = value
341+
# otherwise remove them manually, because `urllib3` doesn't strip all of them.
342+
else:
343+
for header in SAFE_HEADERS:
344+
next_request.headers.pop(header, None)
345+
346+
response = self.http_client.send(next_request, **kwargs)
347+
348+
# If we reached the max number of redirects and the last response is still a redirect
349+
# stop processing the response and return an error to the user.
350+
if redirects_count == MAX_REDIRECTS and response.is_redirect:
351+
raise MaxRetryError(None, response.url, reason=f'reached the maximum number of redirects: {MAX_REDIRECTS}')
352+
317353
# Process a "success" response.
318354
if 200 <= response.status_code <= 299:
319355
if response.status_code == 204 or request['method'] == 'HEAD':

test/test_base_service.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,152 @@ def test_retry_config_external():
788788
assert retry_err.value.reason == error
789789

790790

791+
@responses.activate
792+
def test_redirect_ibm_to_ibm_success():
793+
url_from = 'http://region1.cloud.ibm.com/'
794+
url_to = 'http://region2.cloud.ibm.com/'
795+
796+
safe_headers = {
797+
'Authorization': 'foo',
798+
'WWW-Authenticate': 'bar',
799+
'Cookie': 'baz',
800+
'Cookie2': 'baz2',
801+
}
802+
803+
responses.add(responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect')
804+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
805+
806+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
807+
808+
prepped = service.prepare_request('GET', '', headers=safe_headers)
809+
response = service.send(prepped)
810+
result = response.get_result()
811+
812+
assert result.status_code == 200
813+
assert result.url == url_to
814+
assert result.text == 'successfully redirected'
815+
816+
# Make sure the headers are included in the 2nd, redirected request.
817+
redirected_headers = responses.calls[1].request.headers
818+
for key in safe_headers:
819+
assert key in redirected_headers
820+
821+
822+
@responses.activate
823+
def test_redirect_not_ibm_to_ibm_fail():
824+
url_from = 'http://region1.notcloud.ibm.com/'
825+
url_to = 'http://region2.cloud.ibm.com/'
826+
827+
safe_headers = {
828+
'Authorization': 'foo',
829+
'WWW-Authenticate': 'bar',
830+
'Cookie': 'baz',
831+
'Cookie2': 'baz2',
832+
}
833+
834+
responses.add(responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect')
835+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
836+
837+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
838+
839+
prepped = service.prepare_request('GET', '', headers=safe_headers)
840+
response = service.send(prepped)
841+
result = response.get_result()
842+
843+
assert result.status_code == 200
844+
assert result.url == url_to
845+
assert result.text == 'successfully redirected'
846+
847+
# Make sure the headers have been excluded from the 2nd, redirected request.
848+
redirected_headers = responses.calls[1].request.headers
849+
for key in safe_headers:
850+
assert key not in redirected_headers
851+
852+
853+
@responses.activate
854+
def test_redirect_ibm_to_not_ibm_fail():
855+
url_from = 'http://region1.cloud.ibm.com/'
856+
url_to = 'http://region2.notcloud.ibm.com/'
857+
858+
safe_headers = {
859+
'Authorization': 'foo',
860+
'WWW-Authenticate': 'bar',
861+
'Cookie': 'baz',
862+
'Cookie2': 'baz2',
863+
}
864+
865+
responses.add(responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect')
866+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
867+
868+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
869+
870+
prepped = service.prepare_request('GET', '', headers=safe_headers)
871+
response = service.send(prepped)
872+
result = response.get_result()
873+
874+
assert result.status_code == 200
875+
assert result.url == url_to
876+
assert result.text == 'successfully redirected'
877+
878+
# Make sure the headers have been excluded from the 2nd, redirected request.
879+
redirected_headers = responses.calls[1].request.headers
880+
for key in safe_headers:
881+
assert key not in redirected_headers
882+
883+
884+
@responses.activate
885+
def test_redirect_not_ibm_to_not_ibm_fail():
886+
url_from = 'http://region1.notcloud.ibm.com/'
887+
url_to = 'http://region2.notcloud.ibm.com/'
888+
889+
safe_headers = {
890+
'Authorization': 'foo',
891+
'WWW-Authenticate': 'bar',
892+
'Cookie': 'baz',
893+
'Cookie2': 'baz2',
894+
}
895+
896+
responses.add(responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect')
897+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
898+
899+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
900+
901+
prepped = service.prepare_request('GET', '', headers=safe_headers)
902+
response = service.send(prepped)
903+
result = response.get_result()
904+
905+
assert result.status_code == 200
906+
assert result.url == url_to
907+
assert result.text == 'successfully redirected'
908+
909+
# Make sure the headers have been excluded from the 2nd, redirected request.
910+
redirected_headers = responses.calls[1].request.headers
911+
for key in safe_headers:
912+
assert key not in redirected_headers
913+
914+
915+
@responses.activate
916+
def test_redirect_ibm_to_ibm_exhausted_fail():
917+
redirects = 11
918+
safe_headers = {
919+
'Authorization': 'foo',
920+
'WWW-Authenticate': 'bar',
921+
'Cookie': 'baz',
922+
'Cookie2': 'baz2',
923+
}
924+
925+
for i in range(redirects):
926+
responses.add(responses.GET, f'http://region{i+1}.cloud.ibm.com/', status=302, adding_headers={'Location': f'http://region{i+2}.cloud.ibm.com/'}, body='just about to redirect')
927+
928+
service = BaseService(service_url=f'http://region1.cloud.ibm.com/', authenticator=NoAuthAuthenticator())
929+
930+
with pytest.raises(MaxRetryError) as ex:
931+
prepped = service.prepare_request('GET', '', headers=safe_headers)
932+
service.send(prepped)
933+
934+
assert ex.value.reason == 'reached the maximum number of redirects: 10'
935+
936+
791937
@responses.activate
792938
def test_user_agent_header():
793939
service = AnyServiceV1('2018-11-20', authenticator=NoAuthAuthenticator())

0 commit comments

Comments
 (0)