├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO.rst ├── djproxy ├── __init__.py ├── headers.py ├── models.py ├── proxy_middleware.py ├── request.py ├── urls.py ├── util.py └── views.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── header_passthrough_tests.py ├── header_tests.py ├── helpers.py ├── middleware_tests.py ├── request_tests.py ├── response_construction_tests.py ├── test_settings.py ├── test_urls.py ├── test_views.py ├── url_generate_routes_tests.py └── view_configuration_tests.py └── tube.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */python?.?/* 4 | */site-packages/nose/* 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | .eggs 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | /docs/_build 39 | .idea 40 | *.iml 41 | .noseids 42 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | language: python 4 | cache: pip 5 | python: 6 | - 3.5 7 | - 3.6 8 | - 3.7 9 | - 3.8 10 | - pypy3.5 11 | env: 12 | - DJANGO_VERSION=1.11 13 | - DJANGO_VERSION=2.1 14 | - DJANGO_VERSION=2.2 15 | - DJANGO_VERSION=3.0 16 | - DJANGO_VERSION=3.1 17 | - DJANGO_VERSION='latest' 18 | matrix: 19 | include: 20 | # 1.11 is the last Django version to support python 2.7 21 | - python: 2.7 22 | env: DJANGO_VERSION=1.11 23 | exclude: 24 | - python: 3.5 25 | env: DJANGO_VERSION=3.0 26 | - python: 3.5 27 | env: DJANGO_VERSION=3.1 28 | - python: pypy3.5 29 | env: DJANGO_VERSION=3.0 30 | - python: pypy3.5 31 | env: DJANGO_VERSION=3.1 32 | install: 33 | - pip install -r requirements.txt 34 | - if [[ $DJANGO_VERSION != 'latest' ]]; then pip install django==$DJANGO_VERSION; fi 35 | script: 36 | - python setup.py nosetests 37 | after_success: 38 | - coveralls 39 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.3.6 5 | ----- 6 | 7 | - Declares Django 3.1 as supported versions 8 | - Fixes a bug that changed the names of HTTP headers that contained http. For 9 | example, X-Http-Method was incorrectly changed to Method. 10 | 11 | 2.3.5 12 | ----- 13 | 14 | - Declares Django 3.0 and Python 3.8 as supported versions 15 | - Drop support for Python 2.6 16 | - Drop support for django < 1.11 17 | 18 | 2.3.4 19 | ----- 20 | 21 | - Make package description less than 200 characters. This seems to be breaking 22 | the package metadata on pypi suddenly, even though it was fine before. This 23 | release contains no functional changes. 24 | 25 | 2.3.3 26 | ----- 27 | - Fixes a bug that could interfere with subclassing HttpProxy based generic 28 | views. 29 | 30 | 2.3.2 31 | ----- 32 | 33 | - Add Django 1.10.x support. 34 | - Add workaround for https://code.djangoproject.com/ticket/27005 but 35 | restrict it to Django 1.10 specifically. The issue should be fixed in 36 | 1.10.1. 37 | 38 | 2.3.1 39 | ----- 40 | 41 | - Readme updates so that pypi can render it. 42 | 43 | 2.3.0 44 | ----- 45 | 46 | - Add a ``timeout`` configuration to HttpProxy views allowing 47 | configuration of how quickly HttpProxy views give up on slow upstream 48 | responses. 49 | - Add a ``cert`` configuration option to HttpProxy views. 50 | - Update ``generate_routes`` and ``generate_proxy`` to support new 51 | configuration options. 52 | - Documentation updates. 53 | - Correct a development environment issue with six: a version that was 54 | too low was specified in requirements.txt which caused test failures 55 | in certain cases. 56 | 57 | 2.2.0 58 | ----- 59 | 60 | - Adds python 3 support. 61 | 62 | 2.1.0 63 | ----- 64 | 65 | - Adds a middleware that sends an X-Forwarded-Proto header to upstream 66 | endpoints based on whether or not the incoming connection is https or 67 | http. 68 | - Adds the ``X-Forwarded-Proto`` middleware to HttpProxy views by 69 | default. 70 | - Resolves an issue that would cause djproxy to fail to install in 71 | python 2.6 if django wasn't already installed. 72 | - Adds a MANIFEST.in file so that relevant assets are bundled with 73 | dists. 74 | 75 | 2.0.0 76 | ----- 77 | 78 | - Renamed ``HttpProxy.igorned_downstream_headers`` to 79 | ``ignored_upstream_headers`` 80 | - Added middleware proxy functionality for modifying content, headers 81 | before requests/responses are sent upstream or downstream. 82 | - Moved XFF header functionality to a middleware 83 | - Moved XFH header functionality to a middleware 84 | - Moved reverse proxy functionality to a middleware 85 | - Updated generate routes to support configuring middleware 86 | - Reorganized the internal structure of the app for sanity's sake. 87 | 88 | 1.4.0 89 | ----- 90 | 91 | - Disable CSRF checks by default for proxies created with 92 | ``generate_routes`` 93 | 94 | 1.3.0 95 | ----- 96 | 97 | - Makes HttpProxy SSL verification configurable via the ``verify_ssl`` 98 | class variable. 99 | 100 | 1.2.0 101 | ----- 102 | 103 | - Fix X-Forwarded-For handling: The XFF header is now calculated 104 | correctly and sent to the proxied resource. Previously, the XFF 105 | header was being amended with the server's currenet IP address and it 106 | was being sent to the requesting client during the response phase. 107 | Now, the original request's header is amended with the client's IP 108 | address before it is sent upstream. 109 | - djproxy now sends the X-Forwarded-Host header to the proxied resource 110 | 111 | 1.1.0 112 | ----- 113 | 114 | - Fix proxying of redirects. Previously, djproxy would follow redirects 115 | and render the content of the final destination. This was wrong. It 116 | now dutifully passes the redirect to the requester. 117 | - Add ProxyPassReverse-like functionality via the reverse\_urls class 118 | variable (which is now necessary because redirects are handled 119 | correctly). 120 | - Adds the ability to generate proxies and url patterns for them from a 121 | configuration dict via the new ``generate_routes`` method. 122 | 123 | 1.0.0 124 | ----- 125 | 126 | - Fix file uploads 127 | 128 | 0.2.0 129 | ----- 130 | 131 | - Allow requests 2.1.0 132 | - Allow Django 1.6 and fix tests to compensate for request factory 133 | changes 134 | 135 | 0.1.0 136 | ----- 137 | 138 | - Initial release 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Thomas Welfley 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include requirements.txt 3 | include setup.cfg 4 | include *.rst 5 | recursive-include tests *.py 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | djproxy 2 | ======= 3 | 4 | |Build Status| |Coverage Status| |Latest Version| |PyPI - Python Version| |PyPI - Downloads| 5 | 6 | .. |Build Status| image:: https://img.shields.io/travis/com/thomasw/djproxy.svg 7 | :target: https://travis-ci.com/thomasw/djproxy 8 | .. |Coverage Status| image:: https://img.shields.io/coveralls/thomasw/djproxy.svg 9 | :target: https://coveralls.io/r/thomasw/djproxy 10 | .. |Latest Version| image:: https://img.shields.io/pypi/v/djproxy.svg 11 | :target: https://pypi.python.org/pypi/djproxy/ 12 | .. |PyPI - Python Version| image:: https://img.shields.io/pypi/pyversions/djproxy.svg 13 | :target: https://pypi.python.org/pypi/djproxy/ 14 | .. |PyPI - Downloads| image:: https://img.shields.io/pypi/dm/djproxy.svg 15 | :target: https://pypi.python.org/pypi/djproxy/ 16 | 17 | Why? 18 | ---- 19 | 20 | If an application depends on a proxy (to get around Same Origin Policy 21 | issues in JavaScript, perhaps), djproxy can be used to provide that 22 | functionality in a web server agnostic way. This allows developers to 23 | keep local development environments for proxy dependent applications 24 | fully functional without needing to run anything other than the django 25 | development server. 26 | 27 | djproxy is also suitable for use in production environments and has been 28 | proven to be performant in large scale deployments. However, a web 29 | server's proxy capabilities will be *more* performant in many cases. If 30 | one needs to use this in production, it should be fine as long as 31 | upstream responses aren't large. Performance can be further increased by 32 | aggressively caching upstream responses. 33 | 34 | Note that djproxy doesn't currently support websockets. 35 | 36 | Installation 37 | ------------ 38 | 39 | :: 40 | 41 | pip install djproxy 42 | 43 | djproxy requires requests >= 1.0.0, django >= 1.11 and python >= 2.7. The goal 44 | is to maintain compatibility with all versions of `Django that are still 45 | officially supported 46 | `_. However, djproxy 47 | may still work with older versions. 48 | 49 | If you encounter issues using djproxy with a supported version of django, please 50 | report it. 51 | 52 | Usage 53 | ----- 54 | 55 | Start by defining a new proxy: 56 | 57 | .. code:: python 58 | 59 | from djproxy.views import HttpProxy 60 | 61 | class LocalProxy(HttpProxy): 62 | base_url = 'https://google.com/' 63 | 64 | Add a url pattern that points at the proxy view. The ``url`` kwarg will 65 | be urljoined with base\_url: 66 | 67 | .. code:: python 68 | 69 | urlpatterns = [ 70 | url(r'^local_proxy/(?P.*)$', LocalProxy.as_view(), name='proxy') 71 | ] 72 | 73 | ``/local_proxy/some/content`` will now proxy 74 | ``https://google.com/some/content/``. 75 | 76 | Additional examples can be found here: 77 | `views `_, 78 | `urls `_. 79 | 80 | HttpProxy configuration: 81 | ~~~~~~~~~~~~~~~~~~~~~~~~ 82 | 83 | ``HttpProxy`` view's behavior can be further customized by overriding 84 | the following class attributes. 85 | 86 | - ``base_url``: The proxy url is formed by 87 | ``urlparse.urljoin(base_url, url_kwarg)`` 88 | - ``ignored_upstream_headers``: A list of headers that shouldn't be 89 | forwarded to the browser from the proxied endpoint. 90 | - ``ignored_request_headers``: A list of headers that shouldn't be 91 | forwarded to the proxied endpoint from the browser. 92 | - ``proxy_middleware``: A list of proxy middleware to apply to request 93 | and response data. 94 | - ``pass_query_string``: A boolean indicating whether the query string 95 | should be sent to the proxied endpoint. 96 | - ``reverse_urls``: An iterable of location header replacements to be 97 | made on the constructed response (similar to Apache's 98 | ``ProxyPassReverse`` directive). 99 | - ``verify_ssl``\*: This attribute corresponds to `requests' verify 100 | parameter `_. 101 | It may be either a boolean, which toggles SSL certificate 102 | verification on or off, or the path to a CA\_BUNDLE file for private 103 | certificates. 104 | - ``cert``\*: This attribute corresponds to `requests' cert 105 | parameter `_. 106 | If a string is specified, it will be treated as a path to an ssl 107 | client cert file (.pem). If a tuple is specified, it will be treated 108 | as a ('cert', 'key') pair. 109 | - ``timeout``\*: This attribute corresponds to `requests' timeout 110 | parameter `_. 111 | It is used to specify how long to wait for the upstream server to 112 | send data before giving up. The value must be either a float 113 | representing the total timeout time in seconds, or a (connect timeout 114 | float, read timeout float) tuple. 115 | 116 | \* The behavior changes that result from configuring ``verify_ssl``, 117 | ``cert``, and ``timeout`` will ultimately be dependent on the specific 118 | version of requests that's installed. For example, in older versions of 119 | requests, tuple values are not supported for the ``cert`` and 120 | ``timeout`` properties. 121 | 122 | Adjusting location headers (ProxyPassReverse) 123 | --------------------------------------------- 124 | 125 | Apache has a directive called ``ProxyPassReverse`` that makes 126 | replacements to three location headers: ``URI``, ``Location``, and 127 | ``Content-Location``. Without this functionality, proxying an endpoint 128 | that returns a redirect with a ``Location`` header of 129 | ``http://foo.bar/go/cats/`` would cause a downstream requestor to be 130 | redirected away from the proxy. djproxy has a similar mechanism which is 131 | exposed via the ``reverse_urls`` class variable. The following proxies 132 | are equivalent: 133 | 134 | Djproxy: 135 | 136 | .. code:: python 137 | 138 | 139 | class ReverseProxy(HttpProxy): 140 | base_url = 'https://google.com/' 141 | reverse_urls = [ 142 | ('/google/', 'https://google.com/') 143 | ] 144 | 145 | urlpatterns = patterns[ 146 | url(r'^google/(?P.*)$', ReverseProxy.as_view(), name='gproxy') 147 | ] 148 | 149 | Apache: 150 | 151 | :: 152 | 153 | 154 | Order deny,allow 155 | Allow from all 156 | 157 | ProxyPass /google/ https://google.com/ 158 | ProxyPassReverse /google/ https://google.com/ 159 | 160 | HttpProxy dynamic configuration and route generation helper: 161 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 162 | 163 | To specify the configuration for a set of proxies, without having to 164 | maintain specific classes and url routes, one can use 165 | ``djproxy.helpers.generate_routes`` as follows: 166 | 167 | In ``urls.py``, pass ``generate_routes`` a ``configuration`` dict to 168 | configure a set of proxies: 169 | 170 | .. code:: python 171 | 172 | from djproxy.urls import generate_routes 173 | 174 | configuration = { 175 | 'test_proxy': { 176 | 'base_url': 'https://google.com/', 177 | 'prefix': '/test_prefix/', 178 | }, 179 | 'service_name': { 180 | 'base_url': 'https://service.com/', 181 | 'prefix': '/service_prefix/', 182 | 'verify_ssl': False, 183 | 'append_middlware': ['myapp.proxy_middleware.add_headers'] 184 | } 185 | } 186 | 187 | urlpatterns += generate_routes(configuration) 188 | 189 | Using the snippet above will enable a Django app to proxy 190 | ``https://google.com/X`` at ``/test_prefix/X`` and 191 | ``https://service.com/Y`` at ``/service_prefix/Y``. 192 | 193 | These correspond to the following production Apache proxy configuration: 194 | 195 | :: 196 | 197 | 198 | Order deny,allow 199 | Allow from all 200 | 201 | ProxyPass /test_prefix/ https://google.com/ 202 | ProxyPassReverse /test_prefix/ https://google.com/ 203 | 204 | 205 | 206 | Order deny,allow 207 | Allow from all 208 | 209 | ProxyPass /service_prefix/ http://service.com/ 210 | ProxyPassReverse /service_prefix/ http://service.com/ 211 | 212 | Required configuration keys: 213 | 214 | - ``base_url`` 215 | - ``prefix`` 216 | 217 | Optional configuration keys: 218 | 219 | - ``verify_ssl``: defaults to ``True``. 220 | - ``csrf_exempt``: defaults to ``True``. 221 | - ``cert``: defaults to ``None``. 222 | - ``timeout``: defaults to ``None``. 223 | - ``middleware``: Defaults to ``None``. Specifying ``None`` causes 224 | djproxy to use the default middleware set. If a list is passed, the 225 | default middleware list specified by the HttpProxy definition will be 226 | replaced with the provided list. 227 | - ``append_middleware``: Defaults to ``None``. ``None`` results in no 228 | changes to the default middleware set. If a list is specified, the 229 | list will be appended to the default middleware list specified in the 230 | HttpProxy definition or, if provided, the middleware key specified in 231 | the config dict. 232 | 233 | Proxy middleware 234 | ---------------- 235 | 236 | HttpProxys support custom middleware for preprocessing data from 237 | downstream to be sent to upstream endpoints and for preprocessing 238 | response data before it is sent back downstream. ``X-Forwarded-Host``, 239 | ``X-Forwarded-For``, ``X-Forwarded-Proto`` and the ``ProxyPassRevere`` 240 | functionality area all implemented as middleware. 241 | 242 | HttProxy views are configured to execute particular middleware by 243 | setting their ``proxy_middleware`` attribute. The following HttpProxy 244 | would attach XFF and XFH headers, but not preform the ProxyPassReverse 245 | header translation or attach an XFP header: 246 | 247 | .. code:: python 248 | 249 | class ReverseProxy(HttpProxy): 250 | base_url = 'https://google.com/' 251 | reverse_urls = [ 252 | ('/google/', 'https://google.com/') 253 | ] 254 | proxy_middleware = [ 255 | 'djproxy.proxy_middleware.AddXFF', 256 | 'djproxy.proxy_middleware.AddXFH' 257 | ] 258 | 259 | If a custom middleware is needed to modify content, headers, cookies, 260 | etc before the content is sent upstream of if one needs to make similar 261 | modifications before the content is sent back downstream, a custom 262 | middleware can be written and proxy views can be configured to use it. 263 | djproxy contains a `middleware 264 | template `_ 265 | to make this process easier. 266 | 267 | Terminology 268 | ----------- 269 | 270 | It is important to understand the meaning of these terms in the context 271 | of this project: 272 | 273 | **upstream**: The destination that is being proxied. 274 | 275 | **downstream**: The agent that initiated the request to djproxy. 276 | 277 | Contributing 278 | ------------ 279 | 280 | To run the tests, first install development dependencies: 281 | 282 | :: 283 | 284 | pip install -r requirements.txt 285 | 286 | To test this against a version of Django other than the latest supported 287 | on the test environment's Python version, wipe out the 288 | ``requirements.txt`` installation by pip installing the desired version. 289 | 290 | Run ``nosetests`` to execute the test suite. 291 | 292 | To automatically run the test suite, flake8, and pep257 checks whenever python 293 | files change use testtube by executing ``stir`` in the top level djproxy 294 | directory. 295 | 296 | To run a Django dev server that proxies itself, execute the following: 297 | 298 | :: 299 | 300 | django-admin.py runserver --settings=tests.test_settings --pythonpath="./" 301 | 302 | Similarly, to run a configure Django shell, execute the following: 303 | 304 | :: 305 | 306 | django-admin.py shell --settings=tests.test_settings --pythonpath="./" 307 | 308 | See ``tests/test_settings.py`` and ``tests/test_urls.py`` for 309 | configuration information. 310 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | - Add integration tests. 2 | - Add support for something like Apache 3 | ``ProxyPassReverseCookieDomain`` 4 | - Add support for something like Apache's 5 | ``ProxyPassReverseCookiePath`` 6 | -------------------------------------------------------------------------------- /djproxy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | djproxy is a simple reverse proxy class-based generic view for Django apps. 3 | 4 | If an application depends on a proxy (to get around Same Origin Policy issues 5 | in JavaScript, perhaps), djproxy can be used to provide that functionality in 6 | a web server agnostic way. This allows developers to keep local development 7 | environments for proxy dependent applications fully functional without needing 8 | to run anything other than the django development server. 9 | 10 | djproxy is also suitable for use in production environments and has been proven 11 | to be performant in large scale deployments. However, a web server's proxy 12 | capabilities will be *more* performant in many cases. If one needs to use this 13 | in production, it should be fine as long as upstream responses aren't large. 14 | Performance can be further increased by aggressively caching upstream 15 | responses. 16 | """ 17 | __author__ = "Thomas Welfley" 18 | __version__ = "2.3.6" 19 | -------------------------------------------------------------------------------- /djproxy/headers.py: -------------------------------------------------------------------------------- 1 | """Utilities for handling sets of headers.""" 2 | from six import iteritems 3 | 4 | 5 | class HeaderDict(dict): 6 | """A dict containing header, value pairings.""" 7 | 8 | @staticmethod 9 | def _normalize_django_header_name(header): 10 | """Unmunge header names modified by Django.""" 11 | new_header = header 12 | # HTTP header keys in Django's HttpRequest.META dict (except 13 | # "CONTENT_TYPE" and "CONTENT_LENGTH") are prefixed with "HTTP_", so it 14 | # needs to be removed if present. 15 | prefix = 'HTTP_' 16 | if new_header.startswith(prefix): 17 | new_header = new_header[len(prefix):] 18 | # Camel case and replace _ with - 19 | new_header = '-'.join( 20 | x.capitalize() for x in new_header.split('_')) 21 | 22 | return new_header 23 | 24 | @classmethod 25 | def from_request(cls, request): 26 | """Generate a HeaderDict based on django request object meta data.""" 27 | request_headers = HeaderDict() 28 | other_headers = ['CONTENT_TYPE', 'CONTENT_LENGTH'] 29 | 30 | for header, value in iteritems(request.META): 31 | is_header = header.startswith('HTTP_') or header in other_headers 32 | normalized_header = cls._normalize_django_header_name(header) 33 | 34 | if is_header and value: 35 | request_headers[normalized_header] = value 36 | 37 | return request_headers 38 | 39 | def filter(self, exclude): 40 | """Return a HeaderSet excluding the headers in the exclude list.""" 41 | filtered_headers = HeaderDict() 42 | lowercased_ignore_list = [x.lower() for x in exclude] 43 | 44 | for header, value in iteritems(self): 45 | if header.lower() not in lowercased_ignore_list: 46 | filtered_headers[header] = value 47 | 48 | return filtered_headers 49 | -------------------------------------------------------------------------------- /djproxy/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomasw/djproxy/e7f0c6ef536c01fa8eab193d613ba71406a11cde/djproxy/models.py -------------------------------------------------------------------------------- /djproxy/proxy_middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .util import import_string 4 | 5 | 6 | class MiddlewareSet(object): 7 | def __init__(self, middleware): 8 | self.middleware = [import_string(x)() for x in middleware] 9 | 10 | def process_request(self, proxy, request, **kwargs): 11 | for x in self.middleware: 12 | if not getattr(x, 'process_request', False): 13 | continue 14 | 15 | kwargs = x.process_request(proxy, request, **kwargs) 16 | 17 | return kwargs 18 | 19 | def process_response(self, proxy, request, upstream_response, response): 20 | for x in self.middleware: 21 | if not getattr(x, 'process_response', False): 22 | continue 23 | 24 | response = x.process_response( 25 | proxy=proxy, request=request, 26 | upstream_response=upstream_response, 27 | response=response) 28 | 29 | return response 30 | 31 | 32 | class ProxyMiddlewareTemplate(object): 33 | 34 | """An example proxy middleware that does nothing. 35 | 36 | process_request(): This is called before the proxy makes a request to 37 | the upstream proxied endpoint. Use this method to modify what is sent 38 | upstream. 39 | 40 | process_response(): This is called before the proxy returns the results 41 | to the client. Use this method to modify what is sent back downstream (to 42 | the user). 43 | 44 | """ 45 | 46 | def process_request(self, proxy, request, **kwargs): 47 | """Modify the keyword arguments for requests. 48 | 49 | proxy - the HttpProxy instance calling this method 50 | request - an DownstreamRequest wrapper for the django request 51 | kwargs - the keyword arguments to be passed to requests.request 52 | 53 | Returns a modified kwargs dict that will then be passed to 54 | request.requests. 55 | 56 | """ 57 | pass 58 | 59 | def process_response(self, proxy, request, upstream_response, response): 60 | """Modify the HttpResponse object before sending it downstream. 61 | 62 | proxy - the HttpProxy instance calling thid method 63 | request - a DownstreamRequest wrapper for the django request 64 | upstream_response - the response object resulting from requesting the 65 | proxied endpoint 66 | response - the django HttpResponse object to be send downstream 67 | 68 | Returns a modified django HttpResponse object that is then sent to 69 | the end user. 70 | 71 | """ 72 | pass 73 | 74 | 75 | class AddXFF(object): 76 | 77 | """Add an updated X-Forwarded-For header to the upstream request.""" 78 | 79 | def process_request(self, proxy, request, **kwargs): 80 | kwargs['headers']['X-Forwarded-For'] = request.x_forwarded_for 81 | 82 | return kwargs 83 | 84 | 85 | class AddXFH(object): 86 | """Add a X-Forwarded-Host header to the upstream request.""" 87 | 88 | def process_request(self, proxy, request, **kwargs): 89 | kwargs['headers']['X-Forwarded-Host'] = request.get_host() 90 | 91 | return kwargs 92 | 93 | 94 | class AddXFP(object): 95 | """Add a X-Forwarded-Proto header to the upstream request.""" 96 | 97 | def process_request(self, proxy, request, **kwargs): 98 | proto = 'https' if request.is_secure() else 'http' 99 | kwargs['headers']['X-Forwarded-Proto'] = proto 100 | 101 | return kwargs 102 | 103 | 104 | class ProxyPassReverse(object): 105 | 106 | """Applies reverse url rules to location headers like ProxyPassReverse. 107 | 108 | If the proxies reverse urls are defined as: 109 | 110 | [ 111 | ('/yay/', 'http://backend.example.com/') 112 | ] 113 | 114 | Then, this middleware will search the given response object's Location, 115 | Content-Location, and URI headers for '^http://backend.example.com/' 116 | and replace matches with current hostname + /yay/. 117 | 118 | This is similar to Apache's ProxyPassReverse directive. 119 | 120 | """ 121 | 122 | location_headers = ['URI', 'Location', 'Content-Location'] 123 | 124 | def process_response(self, proxy, request, upstream_response, response): 125 | for replacement, pattern in proxy.reverse_urls: 126 | pattern = r'^%s' % re.escape(pattern) 127 | replacement = request.build_absolute_uri(replacement) 128 | 129 | for loc in self.location_headers: 130 | if not response.has_header(loc): 131 | continue 132 | 133 | response[loc] = re.sub(pattern, replacement, response[loc]) 134 | 135 | return response 136 | -------------------------------------------------------------------------------- /djproxy/request.py: -------------------------------------------------------------------------------- 1 | """Utlities for making Django request objects more useful.""" 2 | 3 | from .headers import HeaderDict 4 | 5 | 6 | class DownstreamRequest(object): 7 | """A Django request wrapper that provides utilities for proxies. 8 | 9 | Attributes that do not exist on this class are deferred to the Django 10 | request object used to create the instance. 11 | 12 | """ 13 | 14 | def __init__(self, request): 15 | """Generate a DownstreamRequest object given a Django request.""" 16 | self._request = request 17 | 18 | @property 19 | def headers(self): 20 | """Request headers.""" 21 | return HeaderDict.from_request(self._request) 22 | 23 | @property 24 | def query_string(self): 25 | """Request query string.""" 26 | return self._request.META['QUERY_STRING'] 27 | 28 | @property 29 | def x_forwarded_for(self): 30 | """X-Forwarded-For header value. 31 | 32 | This is the amended header so that it contains the previous IP address 33 | in the forwarding change. 34 | 35 | """ 36 | ip = self._request.META.get('REMOTE_ADDR') 37 | current_xff = self.headers.get('X-Forwarded-For') 38 | 39 | return '%s, %s' % (current_xff, ip) if current_xff else ip 40 | 41 | def __getattr__(self, name): 42 | """Proxy the Django request object for missing attributes.""" 43 | try: 44 | return self.__getattribute__(name) 45 | except AttributeError: 46 | return getattr(self._request, name) 47 | -------------------------------------------------------------------------------- /djproxy/urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf.urls import url 4 | from six import iteritems 5 | 6 | from djproxy.views import HttpProxy 7 | 8 | 9 | def generate_proxy( 10 | prefix, base_url='', verify_ssl=True, middleware=None, 11 | append_middleware=None, cert=None, timeout=None): 12 | """Generate a ProxyClass based view that uses the passed base_url.""" 13 | middleware = list(middleware or HttpProxy.proxy_middleware) 14 | middleware += list(append_middleware or []) 15 | 16 | return type('ProxyClass', (HttpProxy,), { 17 | 'base_url': base_url, 18 | 'reverse_urls': [(prefix, base_url)], 19 | 'verify_ssl': verify_ssl, 20 | 'proxy_middleware': middleware, 21 | 'cert': cert, 22 | 'timeout': timeout 23 | }) 24 | 25 | 26 | def generate_routes(config): 27 | """Generate a list of urls that map to generated proxy views. 28 | 29 | generate_routes({ 30 | 'test_proxy': { 31 | 'base_url': 'https://google.com/', 32 | 'prefix': '/test_prefix/', 33 | 'verify_ssl': False, 34 | 'csrf_exempt: False', 35 | 'middleware': ['djproxy.proxy_middleware.AddXFF'], 36 | 'append_middleware': ['djproxy.proxy_middleware.AddXFF'], 37 | 'timeout': 3.0, 38 | 'cert': None 39 | } 40 | }) 41 | 42 | Required configuration keys: 43 | 44 | * `base_url` 45 | * `prefix` 46 | 47 | Optional configuration keys: 48 | 49 | * `verify_ssl`: defaults to `True`. 50 | * `csrf_exempt`: defaults to `True`. 51 | * `cert`: defaults to `None`. 52 | * `timeout`: defaults to `None`. 53 | * `middleware`: Defaults to `None`. Specifying `None` causes djproxy to use 54 | the default middleware set. If a list is passed, the default middleware 55 | list specified by the HttpProxy definition will be replaced with the 56 | provided list. 57 | * `append_middleware`: Defaults to `None`. `None` results in no changes to 58 | the default middleware set. If a list is specified, the list will be 59 | appended to the default middleware list specified in the HttpProxy 60 | definition or, if provided, the middleware key specificed in the config 61 | dict. 62 | 63 | Returns: 64 | 65 | [ 66 | url(r'^test_prefix/', GeneratedProxy.as_view(), name='test_proxy')), 67 | ] 68 | 69 | """ 70 | routes = [] 71 | 72 | for name, config in iteritems(config): 73 | pattern = r'^%s(?P.*)$' % re.escape(config['prefix'].lstrip('/')) 74 | proxy = generate_proxy( 75 | prefix=config['prefix'], base_url=config['base_url'], 76 | verify_ssl=config.get('verify_ssl', True), 77 | middleware=config.get('middleware'), 78 | append_middleware=config.get('append_middleware'), 79 | cert=config.get('cert'), 80 | timeout=config.get('timeout')) 81 | proxy_view_function = proxy.as_view() 82 | 83 | proxy_view_function.csrf_exempt = config.get('csrf_exempt', True) 84 | 85 | routes.append(url(pattern, proxy_view_function, name=name)) 86 | 87 | return routes 88 | -------------------------------------------------------------------------------- /djproxy/util.py: -------------------------------------------------------------------------------- 1 | # import_string was appropriated from django and then rewritten for broader 2 | # python support. The version of this method can't be imported from Django 3 | # directly because it didn't exist until 1.7. 4 | 5 | 6 | def import_string(dotted_path): 7 | """ 8 | Import a dotted module path. 9 | 10 | Returns the attribute/class designated by the last name in the path. 11 | 12 | Raises ImportError if the import fails. 13 | 14 | """ 15 | try: 16 | module_path, class_name = dotted_path.rsplit('.', 1) 17 | except ValueError: 18 | raise ImportError('%s doesn\'t look like a valid path' % dotted_path) 19 | 20 | module = __import__(module_path, fromlist=[class_name]) 21 | 22 | try: 23 | return getattr(module, class_name) 24 | except AttributeError: 25 | msg = 'Module "%s" does not define a "%s" attribute/class' % ( 26 | dotted_path, class_name) 27 | raise ImportError(msg) 28 | -------------------------------------------------------------------------------- /djproxy/views.py: -------------------------------------------------------------------------------- 1 | """HTTP Reverse Proxy class based generic view.""" 2 | from django import get_version as get_django_version 3 | from django.http import HttpResponse 4 | from django.views.generic import View 5 | from requests import request 6 | from six.moves.urllib.parse import urljoin 7 | from six import iteritems 8 | 9 | from .headers import HeaderDict 10 | from .proxy_middleware import MiddlewareSet 11 | from .request import DownstreamRequest 12 | 13 | 14 | class HttpProxy(View): 15 | """Reverse HTTP Proxy class-based generic view.""" 16 | 17 | base_url = None 18 | ignored_upstream_headers = [ 19 | 'Content-Length', 'Content-Encoding', 'Keep-Alive', 'Connection', 20 | 'Transfer-Encoding', 'Host', 'Expect', 'Upgrade'] 21 | ignored_request_headers = [ 22 | 'Content-Length', 'Content-Encoding', 'Keep-Alive', 'Connection', 23 | 'Transfer-Encoding', 'Host', 'Expect', 'Upgrade'] 24 | proxy_middleware = [ 25 | 'djproxy.proxy_middleware.AddXFF', 26 | 'djproxy.proxy_middleware.AddXFH', 27 | 'djproxy.proxy_middleware.AddXFP', 28 | 'djproxy.proxy_middleware.ProxyPassReverse' 29 | ] 30 | pass_query_string = True 31 | reverse_urls = [] 32 | verify_ssl = True 33 | cert = None 34 | timeout = None 35 | 36 | @property 37 | def proxy_url(self): 38 | """Return URL to the resource to proxy.""" 39 | return urljoin(self.base_url, self.kwargs.get('url', '')) 40 | 41 | def _verify_config(self): 42 | assert self.base_url, 'base_url must be set to generate a proxy url' 43 | 44 | for rule in self.reverse_urls: 45 | assert len(rule) == 2, 'reverse_urls must be 2 string iterables' 46 | 47 | iter(self.ignored_upstream_headers) 48 | iter(self.ignored_request_headers) 49 | iter(self.proxy_middleware) 50 | 51 | def dispatch(self, request, *args, **kwargs): 52 | """Dispatch all HTTP methods to the proxy.""" 53 | self.request = DownstreamRequest(request) 54 | self.args = args 55 | self.kwargs = kwargs 56 | 57 | self._verify_config() 58 | 59 | self.middleware = MiddlewareSet(self.proxy_middleware) 60 | 61 | return self.proxy() 62 | 63 | def proxy(self): 64 | """Retrieve the upstream content and build an HttpResponse.""" 65 | headers = self.request.headers.filter(self.ignored_request_headers) 66 | qs = self.request.query_string if self.pass_query_string else '' 67 | 68 | # Fix for django 1.10.0 bug https://code.djangoproject.com/ticket/27005 69 | if (self.request.META.get('CONTENT_LENGTH', None) == '' and 70 | get_django_version() == '1.10'): 71 | del self.request.META['CONTENT_LENGTH'] 72 | 73 | request_kwargs = self.middleware.process_request( 74 | self, self.request, method=self.request.method, url=self.proxy_url, 75 | headers=headers, data=self.request.body, params=qs, 76 | allow_redirects=False, verify=self.verify_ssl, cert=self.cert, 77 | timeout=self.timeout) 78 | 79 | result = request(**request_kwargs) 80 | 81 | response = HttpResponse(result.content, status=result.status_code) 82 | 83 | # Attach forwardable headers to response 84 | forwardable_headers = HeaderDict(result.headers).filter( 85 | self.ignored_upstream_headers) 86 | for header, value in iteritems(forwardable_headers): 87 | response[header] = value 88 | 89 | return self.middleware.process_response( 90 | self, self.request, result, response) 91 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Testing 2 | coveralls==1.5.1 3 | flake8==3.6.0 4 | mock==2.0.0 5 | nose==1.3.7 6 | pep257==0.7.0 7 | pyflakes==2.0.0 8 | spec==1.4.1 9 | testtube==1.1.0 10 | unittest2==1.1.0 11 | twine 12 | wheel 13 | 14 | # Functional requirements 15 | django 16 | requests>=1.0.0 17 | six>=1.9.0 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-specplugin=1 3 | nocapture=1 4 | with-coverage=1 5 | cover-package=djproxy 6 | 7 | [wheel] 8 | universal=1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from setuptools import setup, find_packages 3 | 4 | from djproxy import __author__, __doc__, __version__ 5 | 6 | install_requires = ['requests>=1.0.0', 'django>=1.11', 'six>=1.9.0'] 7 | tests_require = [ 8 | 'mock==2.0.0', 'nose==1.3.7', 'unittest2==1.1.0', 'spec==1.4.1', 9 | 'requests>=1.0.0'] 10 | 11 | with open('README.rst', 'r', 'utf-8') as f: 12 | readme = f.read() 13 | 14 | setup( 15 | name="djproxy", 16 | version=__version__, 17 | url='https://github.com/thomasw/djproxy', 18 | author=__author__, 19 | author_email='thomas.welfley+djproxy@gmail.com', 20 | description=__doc__.strip().split('\n')[0], 21 | long_description=readme, 22 | license='MIT', 23 | packages=find_packages(exclude=['tests', 'tests.*']), 24 | tests_require=tests_require, 25 | install_requires=install_requires, 26 | classifiers=[ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Topic :: Software Development :: Libraries', 31 | 'Framework :: Django :: 1.11', 32 | 'Framework :: Django :: 2.1', 33 | 'Framework :: Django :: 2.2', 34 | 'Framework :: Django :: 3.0', 35 | 'Framework :: Django :: 3.1', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3.5', 39 | 'Programming Language :: Python :: 3.6', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: 3.8', 42 | 'Programming Language :: Python :: Implementation :: PyPy' 43 | ], 44 | test_suite='nose.collector' 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.conf import settings 5 | 6 | # Configure django so that our tests work correctly 7 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 8 | 9 | try: 10 | # This is how we setup django apps in scripts for django >1.7 11 | django.setup() 12 | except AttributeError: 13 | # This is how we do it for older versions of Django 14 | settings._setup() 15 | -------------------------------------------------------------------------------- /tests/header_passthrough_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | from mock import Mock 3 | from unittest2 import TestCase 4 | 5 | from .helpers import RequestPatchMixin 6 | from .test_views import TestProxy 7 | 8 | 9 | class HttpProxyHeaderPassThrough(TestCase, RequestPatchMixin): 10 | """HttpProxy header pass through""" 11 | def setUp(self): 12 | self.proxy = TestProxy.as_view() 13 | self.browser_request = RequestFactory().get('/') 14 | 15 | # Fake headers that are representative of how Django munges them when 16 | # it sticks them into the META dict. 17 | self.browser_request.META['HTTP_Host'] = 'cnn.com' 18 | self.browser_request.META['HTTP_Fake_Header'] = 'header_value' 19 | self.browser_request.META['HTTP_X_Forwarded_For'] = 'ipaddr 1' 20 | self.browser_request.META['HTTP_UNNORMALIZED_HEADER'] = 'header value' 21 | self.browser_request.META['CONTENT_TYPE'] = 'header value' 22 | 23 | self.request = self.patch_request( 24 | Mock(status_code=400, headers={'Fake-Header': '123'})) 25 | 26 | self.proxy(self.browser_request) 27 | 28 | # The value of the headers kwarg that gets passed to request_methd 29 | self.headers = self.request.mock_calls[0][2]['headers'] 30 | 31 | def test_filters_disallowed_headers(self): 32 | self.assertNotIn('Host', self.headers) 33 | -------------------------------------------------------------------------------- /tests/header_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | from unittest2 import TestCase 3 | 4 | from djproxy.headers import HeaderDict 5 | 6 | 7 | class HeaderDictTests(TestCase): 8 | def setUp(self): 9 | self.headers = HeaderDict({ 10 | 'My-Fake-Header': 1, 11 | 'X-Forwarded-For': 'Cats' 12 | }) 13 | 14 | def test_filter_method_exludes_headers_in_ignore_list(self): 15 | self.assertEqual( 16 | self.headers.filter(['X-Forwarded-For']), { 17 | 'My-Fake-Header': 1 18 | }) 19 | 20 | 21 | class HeaderDictFromRequestMethod(TestCase): 22 | """HeaderDict.from_request() generates an HttpDict that""" 23 | def setUp(self): 24 | self.request = RequestFactory().get('/') 25 | 26 | # Fake headers that are representative of how Django munges them when 27 | # it sticks them into the META dict. 28 | self.request.META['HTTP_Host'] = 'cnn.com' 29 | self.request.META['HTTP_Fake_Header'] = 'header_value' 30 | self.request.META['HTTP_X_Forwarded_For'] = 'ipaddr 1' 31 | self.request.META['HTTP_UNNORMALIZED_HEADER'] = 'header value' 32 | self.request.META['HTTP_X_HTTP_METHOD'] = 'MERGE' 33 | self.request.META['CONTENT_TYPE'] = 'header value' 34 | 35 | self.headers = HeaderDict.from_request(self.request) 36 | 37 | def test_contains_non_http_prefixed_headers(self): 38 | self.assertIn('Content-Type', self.headers) 39 | 40 | def test_contains_http_prefixed_headers(self): 41 | self.assertIn('Fake-Header', self.headers) 42 | 43 | def test_contains_http_prefixed_header_containing_http(self): 44 | self.assertIn('X-Http-Method', self.headers) 45 | 46 | def test_contains_normalized_header_names(self): 47 | self.assertIn('Unnormalized-Header', self.headers) 48 | 49 | def test_has_unmodified_header_values(self): 50 | self.assertEqual(self.headers['Unnormalized-Header'], 'header value') 51 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | 3 | 4 | class RequestPatchMixin(object): 5 | def patch_request(self, mock_proxy_response=None): 6 | """patches requests.request and sets its return_value""" 7 | request_patcher = patch('djproxy.views.request') 8 | request = request_patcher.start() 9 | request.return_value = mock_proxy_response 10 | 11 | self.addCleanup(request_patcher.stop) 12 | 13 | return request 14 | -------------------------------------------------------------------------------- /tests/middleware_tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test.client import RequestFactory 3 | from mock import Mock 4 | from unittest2 import TestCase 5 | from six import iteritems 6 | 7 | from djproxy.proxy_middleware import AddXFF, AddXFH, AddXFP, ProxyPassReverse 8 | from djproxy.request import DownstreamRequest 9 | 10 | 11 | class AddXFFMiddlewareTest(TestCase): 12 | def setUp(self): 13 | self.request = DownstreamRequest(RequestFactory().get('/')) 14 | self.middleware = AddXFF() 15 | self.kwargs = self.middleware.process_request( 16 | Mock(), self.request, headers={}) 17 | 18 | def test_adds_an_XFF_header(self): 19 | self.assertEqual( 20 | self.kwargs['headers']['X-Forwarded-For'], '127.0.0.1') 21 | 22 | 23 | class AddXFHMiddlewareTest(TestCase): 24 | def setUp(self): 25 | self.request = DownstreamRequest(RequestFactory().get('/')) 26 | self.middleware = AddXFH() 27 | self.kwargs = self.middleware.process_request( 28 | Mock(), self.request, headers={}) 29 | 30 | def test_adds_an_XFH_header(self): 31 | self.assertEqual( 32 | self.kwargs['headers']['X-Forwarded-Host'], 'testserver') 33 | 34 | 35 | class AddXFPMiddlewareTest(TestCase): 36 | def get_kwargs(self, secure): 37 | request = Mock() 38 | request.is_secure.return_value = secure 39 | return AddXFP().process_request(Mock(), request, headers={}) 40 | 41 | def test_sets_XFP_header_to_https_if_request_is_secure(self): 42 | kwargs = self.get_kwargs(secure=True) 43 | self.assertEqual( 44 | kwargs['headers']['X-Forwarded-Proto'], 'https') 45 | 46 | def test_sets_XFP_header_to_http_if_request_is_not_secure(self): 47 | kwargs = self.get_kwargs(secure=False) 48 | self.assertEqual( 49 | kwargs['headers']['X-Forwarded-Proto'], 'http') 50 | 51 | 52 | class ProxyPassReverseTest(TestCase): 53 | def setUp(self): 54 | self.request = DownstreamRequest(RequestFactory().get('/')) 55 | self.middleware = ProxyPassReverse() 56 | 57 | # requests' response objects act like dicts of headers, so we can 58 | # take a little shortcut here. 59 | self.upstream_response = { 60 | 'URI': 'http://upstream.tld/go/', 61 | 'Location': 'http://upstream.tld/go/', 62 | 'Content-Location': 'http://upstream.tld/go/', 63 | 'Location-Foo': 'http://upstream.tld/go/' 64 | } 65 | self.view = Mock() 66 | self.view.reverse_urls = [('/yay/', 'http://upstream.tld/')] 67 | 68 | self.response = HttpResponse() 69 | 70 | # By default, the response objects headers will match the upstream 71 | # response object's headers. 72 | for key, value in iteritems(self.upstream_response): 73 | self.response[key] = value 74 | 75 | self.proxy_response = self.middleware.process_response( 76 | proxy=self.view, request=self.request, 77 | upstream_response=self.upstream_response, response=self.response) 78 | 79 | def test_patches_URI_header(self): 80 | self.assertEqual( 81 | self.proxy_response['URI'], 'http://testserver/yay/go/') 82 | 83 | def test_patches_Location_header(self): 84 | self.assertEqual( 85 | self.proxy_response['Location'], 'http://testserver/yay/go/') 86 | 87 | def test_patches_Content_Location_header(self): 88 | self.assertEqual( 89 | self.proxy_response['Content-Location'], 90 | 'http://testserver/yay/go/') 91 | 92 | def test_leaves_unrelated_headers_alone(self): 93 | self.assertEqual( 94 | self.proxy_response['Location-Foo'], 'http://upstream.tld/go/') 95 | -------------------------------------------------------------------------------- /tests/request_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | from unittest2 import TestCase 3 | 4 | from djproxy.request import DownstreamRequest 5 | 6 | 7 | class DownstreamRequestsTest(TestCase): 8 | def setUp(self): 9 | self.request = RequestFactory().get('/foo?bar=1') 10 | self.request.META['HTTP_my_header'] = 'foo' 11 | self.downstream_request = DownstreamRequest(self.request) 12 | 13 | def test_has_a_valid_query_string_attribute(self): 14 | self.assertEqual(self.downstream_request.query_string, 'bar=1') 15 | 16 | def test_proxy_attributes_to_their_Django_request_isntance(self): 17 | self.assertEqual( 18 | self.request.get_host(), self.downstream_request.get_host()) 19 | 20 | def test_x_forwarded_for_attribute_returns_requestor(self): 21 | self.assertEqual( 22 | self.downstream_request.x_forwarded_for, '127.0.0.1') 23 | 24 | def test_header_attribute_returns_header_set_containing_http_headers(self): 25 | self.assertEqual(self.downstream_request.headers['My-Header'], 'foo') 26 | 27 | 28 | class PreviouslyForwardedDownstreamRequestsTest(TestCase): 29 | def setUp(self): 30 | self.request = RequestFactory().get('/foo?bar=1') 31 | self.request.META['HTTP_X_FORWARDED_FOR'] = '127.0.0.2' 32 | self.downstream_request = DownstreamRequest(self.request) 33 | 34 | def test_x_forwarded_for_attribute_appends_last_requestor(self): 35 | self.assertEqual( 36 | self.downstream_request.x_forwarded_for, '127.0.0.2, 127.0.0.1') 37 | -------------------------------------------------------------------------------- /tests/response_construction_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | from mock import Mock 3 | from unittest2 import TestCase 4 | 5 | from .helpers import RequestPatchMixin 6 | from .test_views import TestProxy 7 | 8 | 9 | class ResponseConstructionTest(TestCase, RequestPatchMixin): 10 | def get_request(self): 11 | return RequestFactory().get('/') 12 | 13 | def setUp(self): 14 | self.proxy = TestProxy.as_view() 15 | self.browser_request = self.get_request() 16 | 17 | self.proxy_stub = Mock( 18 | content='upstream content', headers={ 19 | 'Fake-Header': '123', 20 | 'Transfer-Encoding': 'foo' 21 | }, status_code=201) 22 | self.patch_request(self.proxy_stub) 23 | 24 | self.response = self.proxy(self.browser_request) 25 | 26 | 27 | class HttpProxyContentPassThrough(ResponseConstructionTest): 28 | def test_creates_response_object_with_proxied_content(self): 29 | self.assertEqual( 30 | self.response.content.decode('utf-8'), 'upstream content') 31 | 32 | def test_creates_response_object_with_proxied_status(self): 33 | self.assertEqual(self.response.status_code, 201) 34 | 35 | 36 | class HttpProxyHeaderPassThrough(ResponseConstructionTest): 37 | def test_sets_upstream_headers_on_response_object(self): 38 | self.assertEqual(self.response['Fake-Header'], '123') 39 | 40 | def test_doesnt_set_ignored_upstream_headers_on_response_obj(self): 41 | self.assertFalse(self.response.has_header('Transfer-Encoding')) 42 | 43 | 44 | class HttpProxyEmptyContentLengthHandling(ResponseConstructionTest): 45 | def get_request(self): 46 | request = RequestFactory().get('/') 47 | request.META['CONTENT_LENGTH'] = '' 48 | 49 | return request 50 | 51 | def test_succeeds(self): 52 | self.assertEqual(self.response.status_code, 201) 53 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | TEMPLATE_DEBUG = DEBUG 3 | 4 | ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1', '::1'] 5 | 6 | SECRET_KEY = 'fake_secret' 7 | 8 | ROOT_URLCONF = 'tests.test_urls' 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:', 14 | } 15 | } 16 | 17 | MIDDLEWARE_CLASSES = [] 18 | 19 | INSTALLED_APPS = ( 20 | 'djproxy', 21 | ) 22 | 23 | STATIC_ROOT = '' 24 | STATIC_URL = '/' 25 | 26 | APPEND_SLASH = False 27 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from djproxy.urls import generate_routes 4 | from .test_views import LocalProxy, QuickTimeoutProxy, index 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^some/content/.*$', index, name='index'), 9 | url(r'^local_proxy/(?P.*)$', LocalProxy.as_view(), name='proxy'), 10 | url(r'^quick/(?P.*)$', QuickTimeoutProxy.as_view(), name='proxy'), 11 | ] 12 | 13 | urlpatterns += generate_routes({ 14 | 'service_one': { 15 | 'base_url': 'https://www.yahoo.com/', 16 | 'prefix': '/yahoo/' 17 | }, 18 | 'service_two': { 19 | 'base_url': 'http://www.google.com/', 20 | 'prefix': '/google/' 21 | }, 22 | 'service_three': { 23 | 'base_url': 'http://big.faker/', 24 | 'prefix': '/fakey/', 25 | 'csrf_exempt': False 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from djproxy.views import HttpProxy 4 | 5 | 6 | class LocalProxy(HttpProxy): 7 | base_url = 'http://localhost:8000/some/content/' 8 | 9 | 10 | def index(request): 11 | return HttpResponse('Some content!', status=200) 12 | 13 | 14 | class TestProxy(HttpProxy): 15 | base_url = 'https://google.com/' 16 | 17 | 18 | class UnverifiedSSLProxy(HttpProxy): 19 | base_url = 'https://google.com/' 20 | verify_ssl = False 21 | 22 | 23 | class QuickTimeoutProxy(HttpProxy): 24 | base_url = 'https://google.com/' 25 | verify_ssl = False 26 | timeout = (0.1, 0.1) 27 | 28 | 29 | class SpecifiedCertProxy(HttpProxy): 30 | base_url = 'https://google.com/' 31 | cert = ('cert.pem', 'key.pem') 32 | 33 | 34 | class ReverseProxy(HttpProxy): 35 | base_url = 'https://google.com/' 36 | reverse_urls = [ 37 | ('/google/', 'https://google.com/') 38 | ] 39 | -------------------------------------------------------------------------------- /tests/url_generate_routes_tests.py: -------------------------------------------------------------------------------- 1 | from django.urls import get_resolver 2 | from mock import patch 3 | from unittest2 import TestCase 4 | 5 | from djproxy.urls import generate_routes, generate_proxy 6 | from djproxy.views import HttpProxy 7 | 8 | 9 | class GenerateRoutes(TestCase): 10 | """generate_routes returns routes that""" 11 | def setUp(self): 12 | self.resolver = get_resolver(None) 13 | 14 | def test_enable_proxy_prefixes_to_resolve(self): 15 | self.resolver.resolve('/yahoo/') 16 | 17 | def test_behave_correctly_when_passed_multiple_proxy_dicts(self): 18 | self.resolver.resolve('/google/') 19 | 20 | def test_enable_suffixes_of_proxy_prefixes_resolve(self): 21 | self.resolver.resolve('/google/kittens') 22 | 23 | def test_pass_proxy_url_suffixes_to_the_view_as_url_kwarg( 24 | self): 25 | self.assertEqual( 26 | self.resolver.resolve('/google/kittens/').kwargs, 27 | {'url': 'kittens/'}) 28 | 29 | def test_csrf_exempt_is_set_to_true_by_default(self): 30 | view_func = self.resolver.resolve('/google/kittens').func 31 | self.assertTrue(view_func.csrf_exempt) 32 | 33 | def test_csrf_exempt_can_be_configured_to_false(self): 34 | view_func = self.resolver.resolve('/fakey/').func 35 | self.assertFalse(view_func.csrf_exempt) 36 | 37 | 38 | class GenerateRoutesProxyViewGeneration(TestCase): 39 | """generate_routes build proxy views that""" 40 | def setUp(self): 41 | generate_proxy_patcher = patch('djproxy.urls.generate_proxy') 42 | 43 | self.generate_proxy_mock = generate_proxy_patcher.start() 44 | 45 | self.addCleanup(generate_proxy_patcher.stop) 46 | 47 | self.routes = generate_routes({ 48 | 'yahoo_proxy': { 49 | 'base_url': 'https://yahoo.com/', 50 | 'prefix': '/yahoo/', 51 | 'verify_ssl': False, 52 | 'middleware': ['foo'], 53 | 'append_middleware': ['bar'], 54 | 'cert': 'yay.pem', 55 | 'timeout': 5.0 56 | }, 57 | }) 58 | 59 | def test_are_configured_using_the_configuration_dict(self): 60 | self.generate_proxy_mock.assert_called_once_with( 61 | prefix='/yahoo/', base_url='https://yahoo.com/', verify_ssl=False, 62 | middleware=['foo'], append_middleware=['bar'], cert='yay.pem', 63 | timeout=5.0) 64 | 65 | 66 | class GenerateProxy(TestCase): 67 | def setUp(self): 68 | self.proxy = generate_proxy( 69 | '/google/', 'http://google.com/', False, ['foo'], ['bar'], 70 | 'yay.pem', 5.0) 71 | 72 | def test_yields_an_HttpProxy_CBGV(self): 73 | self.assertTrue(issubclass(self.proxy, HttpProxy)) 74 | 75 | def test_sets_the_base_url_to_the_passed_value(self): 76 | self.assertEqual(self.proxy.base_url, 'http://google.com/') 77 | 78 | def test_sets_the_verify_ssl_flag_to_the_passed_value(self): 79 | self.assertFalse(self.proxy.verify_ssl) 80 | 81 | def test_sets_the_proxy_middleware_list_to_the_proper_middleware(self): 82 | self.assertEqual(self.proxy.proxy_middleware, ['foo', 'bar']) 83 | 84 | def test_sets_timeout_to_the_specified_value(self): 85 | self.assertEqual(self.proxy.timeout, 5.0) 86 | 87 | def test_sets_cert_to_the_specified_value(self): 88 | self.assertEqual(self.proxy.cert, 'yay.pem') 89 | -------------------------------------------------------------------------------- /tests/view_configuration_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | from mock import ANY, Mock 3 | from unittest2 import TestCase 4 | 5 | from .helpers import RequestPatchMixin 6 | from .test_views import ( 7 | QuickTimeoutProxy, SpecifiedCertProxy, TestProxy, UnverifiedSSLProxy) 8 | 9 | 10 | class HttpProxyConfigVerification(TestCase, RequestPatchMixin): 11 | def setUp(self): 12 | self.fake_request = RequestFactory().get('/') 13 | self.proxy = TestProxy.as_view() 14 | 15 | self.orig_base_url = TestProxy.base_url 16 | self.orig_upstream_headers = TestProxy.ignored_upstream_headers 17 | self.orig_request_headers = TestProxy.ignored_request_headers 18 | 19 | # Keep things fast by making sure that proxying doesn't actually 20 | # happen in these tests: 21 | self.patch_request(Mock(raw='', status_code=200, headers={})) 22 | 23 | def tearDown(self): 24 | TestProxy.base_url = self.orig_base_url 25 | TestProxy.ignored_upstream_headers = self.orig_upstream_headers 26 | TestProxy.ignored_request_headers = self.orig_request_headers 27 | 28 | def test_raises_an_exception_if_the_proxy_has_no_base_url(self): 29 | TestProxy.base_url = '' 30 | self.assertRaises(AssertionError, self.proxy, self.fake_request) 31 | 32 | def test_raises_an_exception_if_upstream_ignore_list_not_iterable(self): 33 | TestProxy.ignored_upstream_headers = None 34 | self.assertRaises(TypeError, self.proxy, self.fake_request) 35 | 36 | def test_raises_an_exception_if_request_headers_ignore_list_not_iterable( 37 | self): 38 | TestProxy.ignored_request_headers = None 39 | self.assertRaises(TypeError, self.proxy, self.fake_request) 40 | 41 | def test_passes_if_the_base_url_is_set(self): 42 | self.proxy(self.fake_request) 43 | 44 | 45 | class HttpProxyUrlConstructionWithoutURLKwarg(TestCase, RequestPatchMixin): 46 | """HttpProxy proxy url construction without a URL kwarg""" 47 | def setUp(self): 48 | self.fake_request = RequestFactory().get('/yay/') 49 | self.proxy = TestProxy.as_view() 50 | 51 | self.request = self.patch_request( 52 | Mock(raw='', status_code=200, headers={})) 53 | 54 | self.proxy(self.fake_request) 55 | 56 | def test_only_contains_base_url_if_no_default_url_configured(self): 57 | """only contains base_url""" 58 | self.request.assert_called_once_with( 59 | method=ANY, url='https://google.com/', data=ANY, headers=ANY, 60 | params=ANY, allow_redirects=ANY, verify=ANY, cert=ANY, timeout=ANY) 61 | 62 | 63 | class HttpProxyUrlConstructionWithURLKwarg(TestCase, RequestPatchMixin): 64 | """HttpProxy proxy url construction with a URL kwarg""" 65 | def setUp(self): 66 | self.fake_request = RequestFactory().get('/yay/') 67 | self.proxy = TestProxy.as_view() 68 | 69 | self.request = self.patch_request( 70 | Mock(raw='', status_code=200, headers={})) 71 | 72 | self.proxy(self.fake_request, url='yay/') 73 | 74 | def test_urljoins_base_url_and_url_kwarg(self): 75 | """urljoins base_url and url kwarg""" 76 | self.request.assert_called_once_with( 77 | method=ANY, url='https://google.com/yay/', data=ANY, headers=ANY, 78 | params=ANY, allow_redirects=ANY, verify=ANY, cert=ANY, timeout=ANY) 79 | 80 | 81 | class HttpProxyUrlConstructionWithQueryStringPassingEnabled( 82 | TestCase, RequestPatchMixin): 83 | """HttpProxy URL construction with query string passing enabled""" 84 | def setUp(self): 85 | self.fake_request = RequestFactory().get('/yay/?yay=foo,bar') 86 | self.proxy = TestProxy.as_view() 87 | 88 | self.request = self.patch_request( 89 | Mock(raw='', status_code=200, headers={})) 90 | 91 | self.proxy(self.fake_request, url='yay/') 92 | 93 | def test_sends_query_string_to_proxied_endpoint(self): 94 | self.request.assert_called_once_with( 95 | method=ANY, url=ANY, data=ANY, headers=ANY, params='yay=foo,bar', 96 | allow_redirects=ANY, verify=ANY, cert=ANY, timeout=ANY) 97 | 98 | 99 | class HttpProxyUrlConstructionWithoutQueryStringPassingEnabled( 100 | TestCase, RequestPatchMixin): 101 | """HttpProxy URL construction without query string passing enabled""" 102 | def setUp(self): 103 | TestProxy.pass_query_string = False 104 | self.fake_request = RequestFactory().get('/yay/?yay=foo,bar') 105 | self.proxy = TestProxy.as_view() 106 | 107 | self.request = self.patch_request( 108 | Mock(raw='', status_code=200, headers={})) 109 | 110 | self.proxy(self.fake_request, url='yay/') 111 | 112 | def tearDown(self): 113 | TestProxy.pass_query_string = True 114 | 115 | def test_doesnt_sends_query_string_to_proxied_endpoint(self): 116 | self.request.assert_called_once_with( 117 | method=ANY, url=ANY, data=ANY, headers=ANY, params='', 118 | allow_redirects=ANY, verify=ANY, cert=ANY, timeout=ANY) 119 | 120 | 121 | class HttpProxyFetchingWithVerifySSL(TestCase, RequestPatchMixin): 122 | """HttpProxy content fetching with verify_ssl enabled""" 123 | def setUp(self): 124 | self.fake_request = RequestFactory().get('/') 125 | self.proxy = TestProxy.as_view() 126 | 127 | self.request = self.patch_request( 128 | Mock(raw='', status_code=200, headers={})) 129 | 130 | self.proxy(self.fake_request, url='yay/') 131 | 132 | def test_tells_requests_to_verify_the_SSL_certs(self): 133 | self.request.assert_called_once_with( 134 | method=ANY, url=ANY, data=ANY, headers=ANY, params=ANY, 135 | allow_redirects=ANY, verify=True, cert=ANY, timeout=ANY) 136 | 137 | 138 | class HttpProxyFetchingWithoutVerifySSL(TestCase, RequestPatchMixin): 139 | """HttpProxy content fetching with verify_ssl disabled""" 140 | def setUp(self): 141 | self.fake_request = RequestFactory().get('/') 142 | self.proxy = UnverifiedSSLProxy.as_view() 143 | 144 | self.request = self.patch_request( 145 | Mock(raw='', status_code=200, headers={})) 146 | 147 | self.proxy(self.fake_request, url='yay/') 148 | 149 | def test_tells_requests_not_to_verify_the_ssl_certs(self): 150 | self.request.assert_called_once_with( 151 | method=ANY, url=ANY, data=ANY, headers=ANY, params=ANY, 152 | allow_redirects=ANY, verify=False, cert=ANY, timeout=ANY) 153 | 154 | 155 | class HttpProxyFetchingWithCert(TestCase, RequestPatchMixin): 156 | """HttpProxy fetching with cert specified""" 157 | def setUp(self): 158 | self.fake_request = RequestFactory().get('/') 159 | self.proxy = SpecifiedCertProxy.as_view() 160 | 161 | self.request = self.patch_request( 162 | Mock(raw='', status_code=200, headers={})) 163 | 164 | self.proxy(self.fake_request, url='yay/') 165 | 166 | def test_tells_requests_to_use_specific_cert(self): 167 | self.request.assert_called_once_with( 168 | method=ANY, url=ANY, data=ANY, headers=ANY, params=ANY, 169 | allow_redirects=ANY, verify=ANY, cert=('cert.pem', 'key.pem'), 170 | timeout=ANY) 171 | 172 | 173 | class HttpProxyFetchingWithTimeoutConfigured(TestCase, RequestPatchMixin): 174 | """HttpProxy fetching with timeout specified""" 175 | def setUp(self): 176 | self.fake_request = RequestFactory().get('/') 177 | self.proxy = QuickTimeoutProxy.as_view() 178 | 179 | self.request = self.patch_request( 180 | Mock(raw='', status_code=200, headers={})) 181 | 182 | self.proxy(self.fake_request, url='yay/') 183 | 184 | def test_tells_requests_to_use_specific_timeout(self): 185 | self.request.assert_called_once_with( 186 | method=ANY, url=ANY, data=ANY, headers=ANY, params=ANY, 187 | allow_redirects=ANY, verify=ANY, cert=ANY, 188 | timeout=(0.1, 0.1)) 189 | -------------------------------------------------------------------------------- /tube.py: -------------------------------------------------------------------------------- 1 | from testtube.helpers import Flake8, Nosetests, Pep257 2 | 3 | 4 | PATTERNS = ( 5 | (r'((?!_tests\.py)(?!tube\.py).)*\.py$', [Pep257(bells=0)]), 6 | (r'.*\.py$', [Flake8(all_files=True)], {'fail_fast': True}), 7 | (r'(.*setup\.cfg$)|(.*\.coveragerc)|(.*\.py$)', [Nosetests()]) 8 | ) 9 | --------------------------------------------------------------------------------