Skip to content

Commit 4d221e7

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 4d221e7

File tree

2 files changed

+206
-5
lines changed

2 files changed

+206
-5
lines changed

ibm_cloud_sdk_core/base_service.py

+45-4
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
25+
from urllib.parse import urlparse
2626

2727
import requests
2828
from requests.structures import CaseInsensitiveDict
2929
from requests.exceptions import JSONDecodeError
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:
@@ -96,7 +102,7 @@ def __init__(
96102
service_url: str = None,
97103
authenticator: Authenticator = None,
98104
disable_ssl_verification: bool = False,
99-
enable_gzip_compression: bool = False
105+
enable_gzip_compression: bool = False,
100106
) -> None:
101107
self.set_service_url(service_url)
102108
self.http_client = requests.Session()
@@ -280,6 +286,7 @@ def set_default_headers(self, headers: Dict[str, str]) -> None:
280286
else:
281287
raise TypeError("headers parameter must be a dictionary")
282288

289+
# pylint: disable=too-many-branches
283290
def send(self, request: requests.Request, **kwargs) -> DetailedResponse:
284291
"""Send a request and wrap the response in a DetailedResponse or APIException.
285292
@@ -294,7 +301,9 @@ def send(self, request: requests.Request, **kwargs) -> DetailedResponse:
294301
"""
295302
# Use a one minute timeout when our caller doesn't give a timeout.
296303
# http://docs.python-requests.org/en/master/user/quickstart/#timeouts
297-
kwargs = dict({"timeout": 60}, **kwargs)
304+
# We also disable the default redirection, to have more granular control
305+
# over the headers sent in each request.
306+
kwargs = dict({'timeout': 60, 'allow_redirects': False}, **kwargs)
298307
kwargs = dict(kwargs, **self.http_config)
299308

300309
if self.disable_ssl_verification:
@@ -314,6 +323,38 @@ def send(self, request: requests.Request, **kwargs) -> DetailedResponse:
314323
try:
315324
response = self.http_client.request(**request, cookies=self.jar, **kwargs)
316325

326+
# Handle HTTP redirects.
327+
redirects_count = 0
328+
# Check if the response is a redirect to another host.
329+
while response.is_redirect and response.next is not None and redirects_count < MAX_REDIRECTS:
330+
redirects_count += 1
331+
332+
# urllib3 has already prepared a request that can almost be used as-is.
333+
next_request = response.next
334+
335+
# If both the original and the redirected URL are under the `.cloud.ibm.com` domain,
336+
# copy the safe headers that are used for authentication purposes,
337+
if self.service_url.endswith('.cloud.ibm.com') and urlparse(next_request.url).netloc.endswith(
338+
'.cloud.ibm.com'
339+
):
340+
original_headers = request.get('headers')
341+
for header, value in original_headers.items():
342+
if header.lower() in SAFE_HEADERS:
343+
next_request.headers[header] = value
344+
# otherwise remove them manually, because `urllib3` doesn't strip all of them.
345+
else:
346+
for header in SAFE_HEADERS:
347+
next_request.headers.pop(header, None)
348+
349+
response = self.http_client.send(next_request, **kwargs)
350+
351+
# If we reached the max number of redirects and the last response is still a redirect
352+
# stop processing the response and return an error to the user.
353+
if redirects_count == MAX_REDIRECTS and response.is_redirect:
354+
raise MaxRetryError(
355+
None, response.url, reason=f'reached the maximum number of redirects: {MAX_REDIRECTS}'
356+
)
357+
317358
# Process a "success" response.
318359
if 200 <= response.status_code <= 299:
319360
if response.status_code == 204 or request['method'] == 'HEAD':
@@ -362,7 +403,7 @@ def prepare_request(
362403
params: Optional[dict] = None,
363404
data: Optional[Union[str, dict]] = None,
364405
files: Optional[Union[Dict[str, Tuple[str]], List[Tuple[str, Tuple[str, ...]]]]] = None,
365-
**kwargs
406+
**kwargs,
366407
) -> dict:
367408
"""Build a dict that represents an HTTP service request.
368409

test/test_base_service.py

+161-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# coding=utf-8
2-
# pylint: disable=missing-docstring,protected-access,too-few-public-methods
2+
# pylint: disable=missing-docstring,protected-access,too-few-public-methods,too-many-lines
33
import gzip
44
import json
55
import os
@@ -788,6 +788,166 @@ 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(
804+
responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect'
805+
)
806+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
807+
808+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
809+
810+
prepped = service.prepare_request('GET', '', headers=safe_headers)
811+
response = service.send(prepped)
812+
result = response.get_result()
813+
814+
assert result.status_code == 200
815+
assert result.url == url_to
816+
assert result.text == 'successfully redirected'
817+
818+
# Make sure the headers are included in the 2nd, redirected request.
819+
redirected_headers = responses.calls[1].request.headers
820+
for key in safe_headers:
821+
assert key in redirected_headers
822+
823+
824+
@responses.activate
825+
def test_redirect_not_ibm_to_ibm_fail():
826+
url_from = 'http://region1.notcloud.ibm.com/'
827+
url_to = 'http://region2.cloud.ibm.com/'
828+
829+
safe_headers = {
830+
'Authorization': 'foo',
831+
'WWW-Authenticate': 'bar',
832+
'Cookie': 'baz',
833+
'Cookie2': 'baz2',
834+
}
835+
836+
responses.add(
837+
responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect'
838+
)
839+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
840+
841+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
842+
843+
prepped = service.prepare_request('GET', '', headers=safe_headers)
844+
response = service.send(prepped)
845+
result = response.get_result()
846+
847+
assert result.status_code == 200
848+
assert result.url == url_to
849+
assert result.text == 'successfully redirected'
850+
851+
# Make sure the headers have been excluded from the 2nd, redirected request.
852+
redirected_headers = responses.calls[1].request.headers
853+
for key in safe_headers:
854+
assert key not in redirected_headers
855+
856+
857+
@responses.activate
858+
def test_redirect_ibm_to_not_ibm_fail():
859+
url_from = 'http://region1.cloud.ibm.com/'
860+
url_to = 'http://region2.notcloud.ibm.com/'
861+
862+
safe_headers = {
863+
'Authorization': 'foo',
864+
'WWW-Authenticate': 'bar',
865+
'Cookie': 'baz',
866+
'Cookie2': 'baz2',
867+
}
868+
869+
responses.add(
870+
responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect'
871+
)
872+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
873+
874+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
875+
876+
prepped = service.prepare_request('GET', '', headers=safe_headers)
877+
response = service.send(prepped)
878+
result = response.get_result()
879+
880+
assert result.status_code == 200
881+
assert result.url == url_to
882+
assert result.text == 'successfully redirected'
883+
884+
# Make sure the headers have been excluded from the 2nd, redirected request.
885+
redirected_headers = responses.calls[1].request.headers
886+
for key in safe_headers:
887+
assert key not in redirected_headers
888+
889+
890+
@responses.activate
891+
def test_redirect_not_ibm_to_not_ibm_fail():
892+
url_from = 'http://region1.notcloud.ibm.com/'
893+
url_to = 'http://region2.notcloud.ibm.com/'
894+
895+
safe_headers = {
896+
'Authorization': 'foo',
897+
'WWW-Authenticate': 'bar',
898+
'Cookie': 'baz',
899+
'Cookie2': 'baz2',
900+
}
901+
902+
responses.add(
903+
responses.GET, url_from, status=302, adding_headers={'Location': url_to}, body='just about to redirect'
904+
)
905+
responses.add(responses.GET, url_to, status=200, body='successfully redirected')
906+
907+
service = BaseService(service_url=url_from, authenticator=NoAuthAuthenticator())
908+
909+
prepped = service.prepare_request('GET', '', headers=safe_headers)
910+
response = service.send(prepped)
911+
result = response.get_result()
912+
913+
assert result.status_code == 200
914+
assert result.url == url_to
915+
assert result.text == 'successfully redirected'
916+
917+
# Make sure the headers have been excluded from the 2nd, redirected request.
918+
redirected_headers = responses.calls[1].request.headers
919+
for key in safe_headers:
920+
assert key not in redirected_headers
921+
922+
923+
@responses.activate
924+
def test_redirect_ibm_to_ibm_exhausted_fail():
925+
redirects = 11
926+
safe_headers = {
927+
'Authorization': 'foo',
928+
'WWW-Authenticate': 'bar',
929+
'Cookie': 'baz',
930+
'Cookie2': 'baz2',
931+
}
932+
933+
for i in range(redirects):
934+
responses.add(
935+
responses.GET,
936+
f'http://region{i+1}.cloud.ibm.com/',
937+
status=302,
938+
adding_headers={'Location': f'http://region{i+2}.cloud.ibm.com/'},
939+
body='just about to redirect',
940+
)
941+
942+
service = BaseService(service_url='http://region1.cloud.ibm.com/', authenticator=NoAuthAuthenticator())
943+
944+
with pytest.raises(MaxRetryError) as ex:
945+
prepped = service.prepare_request('GET', '', headers=safe_headers)
946+
service.send(prepped)
947+
948+
assert ex.value.reason == 'reached the maximum number of redirects: 10'
949+
950+
791951
@responses.activate
792952
def test_user_agent_header():
793953
service = AnyServiceV1('2018-11-20', authenticator=NoAuthAuthenticator())

0 commit comments

Comments
 (0)