From 106114997c81632ebd800cb78cdbbb7f0f858f26 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Sat, 5 Nov 2022 14:46:08 +0100 Subject: [PATCH] implement ForwardSetCookie to allow a proxy to send multiple Set-cookie headers --- README.rst | 2 +- djproxy/proxy_middleware.py | 57 +++++++++++++++++++++++++++++ tests/middleware_tests.py | 72 ++++++++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3786a99..c528302 100644 --- a/README.rst +++ b/README.rst @@ -236,7 +236,7 @@ Proxy middleware HttpProxys support custom middleware for preprocessing data from downstream to be sent to upstream endpoints and for preprocessing response data before it is sent back downstream. ``X-Forwarded-Host``, -``X-Forwarded-For``, ``X-Forwarded-Proto`` and the ``ProxyPassRevere`` +``X-Forwarded-For``, ``X-Forwarded-Proto``, ``ForwardSetCookie`` and the ``ProxyPassRevere`` functionality area all implemented as middleware. HttProxy views are configured to execute particular middleware by diff --git a/djproxy/proxy_middleware.py b/djproxy/proxy_middleware.py index fbb30b0..c661e12 100644 --- a/djproxy/proxy_middleware.py +++ b/djproxy/proxy_middleware.py @@ -1,5 +1,13 @@ import re +# The Cookie module has been renamed to http.cookies in Python 3.0. +# We use its equivalent re_path to maintain compatibility +# with older versions of django +try: + from http.cookies import SimpleCookie +except ImportError as exception: + from Cookie import SimpleCookie + from .util import import_string @@ -101,6 +109,55 @@ def process_request(self, proxy, request, **kwargs): return kwargs +class ForwardSetCookie(object): + """ + Handles the transmission of multiple cookies returned by a server as multiple set-cookie headers. + + Request merges the set-cookie headers into one. The default behavior is ok + if the server returns only one cookie per http response. + + The browser will receive only one cookie. + + { + Set-Cookie: hello=world; Expires=Wed, 21 Oct 2015 07:28:00 GMT, world=hello + } + + instead + + { + Set-Cookie: hello=world; Expires=Wed, 21 Oct 2015 07:28:00 GMT + Set-Cookie: world=hello + } + + more about this behavior + + * https://github.com/urllib3/urllib3/commit/d8013cb111644a06eb5cb9bccce174a1a996078d + * https://stackoverflow.com/a/57131320 + * https://github.com/psf/requests/issues/3957#issuecomment-1047539652 + """ + def process_response(self, proxy, request, upstream_response, response): + if 'set-cookie' in response: + # The set-cookie headers are well present in the headers of urlib3 forwarded by request + # On its own interface, request has merged those cookies in one single header + for header, value in upstream_response.raw.headers.items(): + if header.lower() == 'set-cookie': + cookies = SimpleCookie(value) + for key in cookies.keys(): + cookie = cookies.get(key) + response.set_cookie(cookie.key, + value=cookie.value, + expires=cookie.get('expires'), + path=cookie.get('path'), + domain=cookie.get('domain'), + secure=True if cookie.get('secure') else False, + httponly=True if cookie.get('httponly') else False) + + # remove the default header added by request + del response['set-cookie'] + + return response + + class ProxyPassReverse(object): """Applies reverse url rules to location headers like ProxyPassReverse. diff --git a/tests/middleware_tests.py b/tests/middleware_tests.py index e5839d1..4e43359 100644 --- a/tests/middleware_tests.py +++ b/tests/middleware_tests.py @@ -3,8 +3,9 @@ from mock import Mock from unittest2 import TestCase from six import iteritems +from urllib3 import HTTPResponse -from djproxy.proxy_middleware import AddXFF, AddXFH, AddXFP, ProxyPassReverse +from djproxy.proxy_middleware import AddXFF, AddXFH, AddXFP, ProxyPassReverse, ForwardSetCookie from djproxy.request import DownstreamRequest @@ -49,6 +50,75 @@ def test_sets_XFP_header_to_http_if_request_is_not_secure(self): kwargs['headers']['X-Forwarded-Proto'], 'http') +class TestForwardSetCookie(TestCase): + + def setUp(self): + self.request = DownstreamRequest(RequestFactory().get('/')) + self.upstream_response = { + 'URI': 'http://upstream.tld/go/', + 'Location': 'http://upstream.tld/go/', + 'Content-Location': 'http://upstream.tld/go/', + 'Location-Foo': 'http://upstream.tld/go/' + } + self.proxy = Mock() + + self._tested = ForwardSetCookie() + + def test_middleware_should_do_nothing_when_there_is_no_header_set_cookie(self): + # Assign + response = HttpResponse() + + # Acts + response = self._tested.process_response(self.proxy, self.request, self.upstream_response, response) + + # Assert + self.assertNotIn('set-cookie', response) + + def test_middleware_should_move_cookie_into_cookies_and_remove_set_cookie_header(self): + # Assign + response = HttpResponse() + response['set-cookie'] = 'key=hello' + upstream_response = Mock() + upstream_response.raw = HTTPResponse() + upstream_response.raw.headers.add('set-cookie', 'key=hello') + + + # Acts + response = self._tested.process_response(self.proxy, self.request, upstream_response, response) + + # Assert + cookie = response.cookies.get('key') + self.assertEqual('hello', cookie.value) + self.assertNotIn('set-cookie', response) + + def test_middleware_should_move_multiple_cookies_into_cookies_and_remove_set_cookie_header(self): + # Assign + response = HttpResponse() + response['set-cookie'] = 'hello=world; Expires=Wed, 21 Oct 2015 07:28:00 GMT, world=hello' + upstream_response = Mock() + upstream_response.raw = HTTPResponse() + upstream_response.raw.headers.add('set-cookie', 'hello=world; Expires=Wed, 21 Oct 2015 07:28:00 GMT') + upstream_response.raw.headers.add('set-cookie', 'hello1=world; Domain=somecompany.co.uk') + upstream_response.raw.headers.add('set-cookie', 'hello2=world; Secure; Path=/') + upstream_response.raw.headers.add('set-cookie', 'world=hello') + + # Acts + response = self._tested.process_response(self.proxy, self.request, upstream_response, response) # typing: HttpResponse + + # Assert + cookie = response.cookies.get('hello') + self.assertEqual('world', cookie.value) + + cookie = response.cookies.get('hello1') + self.assertEqual('somecompany.co.uk', cookie.get('domain')) + + cookie = response.cookies.get('hello2') + self.assertEqual('/', cookie.get('path')) + self.assertEqual(True, cookie.get('secure')) + + self.assertNotIn('set-cookie', response) + + class ProxyPassReverseTest(TestCase): def setUp(self): self.request = DownstreamRequest(RequestFactory().get('/'))