├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .travis.yml ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO.rst ├── codecov.yml ├── docs ├── Makefile ├── _static │ └── revproxy.jpg ├── _templates │ └── delete_me ├── changelog.rst ├── conf.py ├── index.rst ├── introduction.rst ├── make.bat ├── modules.rst ├── proxyview.rst ├── quickstart.rst ├── requirements.txt └── settings.rst ├── pyproject.toml ├── revproxy ├── __init__.py ├── apps.py ├── connection.py ├── exceptions.py ├── response.py ├── settings.py ├── transformer.py ├── utils.py └── views.py ├── sample_project ├── db.sqlite3 ├── manage.py ├── requirements.txt ├── sample_app │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── urls.py │ └── views.py └── sample_project │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── setup.py ├── tests ├── __init__.py ├── custom_diazo.xml ├── diazo.xml ├── run.py ├── settings.py ├── templates │ ├── diazo.html │ └── diazo_with_context_data.html ├── test_connection.py ├── test_request.py ├── test_response.py ├── test_transformer.py ├── test_utils.py ├── test_views.py ├── urls.py └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = revproxy 3 | branch = 1 4 | 5 | [report] 6 | exclude_lines = 7 | .*:.* # Python \d.* 8 | .* # pragma: no cover.* 9 | except ImportError: 10 | omit = revproxy/*tests* 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-revproxy' 11 | runs-on: ubuntu-22.04 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.x" 22 | cache: pip 23 | cache-dependency-path: pyproject.toml 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install -U pip 28 | python -m pip install -U build twine wheel 29 | 30 | - name: Build package 31 | run: | 32 | python -m build 33 | twine check dist/* 34 | 35 | - name: Upload packages to Jazzband 36 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | with: 39 | user: jazzband 40 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 41 | repository_url: https://jazzband.co/projects/django-revproxy/upload 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-22.04 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | cache: pip 22 | cache-dependency-path: pyproject.toml 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install tox tox-gh-actions codecov 28 | 29 | - name: Test with tox 30 | run: | 31 | tox -v -- --cov --cov-append --cov-report term-missing --cov-report xml 32 | 33 | - name: Generate coverage XML report 34 | run: coverage xml 35 | 36 | - name: Upload coverage 37 | uses: codecov/codecov-action@v3 38 | with: 39 | name: Python ${{ matrix.python-version }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # OS X 4 | .DS_Store 5 | 6 | 7 | # vim 8 | *.swp 9 | *.swo 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Packages 15 | *.egg 16 | .eggs/ 17 | *.egg-info 18 | dist 19 | build 20 | eggs 21 | parts 22 | bin 23 | var 24 | sdist 25 | develop-eggs 26 | .installed.cfg 27 | lib 28 | lib64 29 | venv 30 | 31 | # Installer logs 32 | pip-log.txt 33 | 34 | # Tests / coverage reports 35 | tests/test.db 36 | test.db 37 | test.log 38 | .coverage 39 | .tox 40 | nosetests.xml 41 | htmlcov/ 42 | 43 | # Translations 44 | *.mo 45 | 46 | # Mr Developer 47 | .mr.developer.cfg 48 | .project 49 | .pydevproject 50 | 51 | # Docs 52 | docs/_build 53 | 54 | # IDEs 55 | .vscode/ 56 | .idea/ 57 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pygrep-hooks 3 | rev: v1.10.0 4 | hooks: 5 | - id: python-check-blanket-noqa 6 | 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-merge-conflict 11 | - id: check-yaml 12 | 13 | ci: 14 | autoupdate_schedule: quarterly 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | python: 34 | install: 35 | - requirements: docs/requirements.txt 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | 4 | cache: 5 | directories: 6 | - .eggs 7 | 8 | python: 9 | - "2.7" 10 | - "3.5" 11 | - "3.6" 12 | - "3.7" 13 | - "3.8" 14 | 15 | env: 16 | - DJANGO_VERSION=1.8 17 | - DJANGO_VERSION=1.9 18 | - DJANGO_VERSION=1.10 19 | - DJANGO_VERSION=1.11 20 | - DJANGO_VERSION=2.2 21 | - DJANGO_VERSION=3.0 22 | - DJANGO_VERSION=3.2 23 | 24 | jobs: 25 | exclude: 26 | - python: 2.7 27 | env: DJANGO_VERSION=2.2 28 | - python: 2.7 29 | env: DJANGO_VERSION=3.0 30 | - python: 3.5 31 | env: DJANGO_VERSION=3.0 32 | - python: 2.7 33 | env: DJANGO_VERSION=3.2 34 | - python: 3.5 35 | env: DJANGO_VERSION=3.2 36 | - python: 3.8 37 | env: DJANGO_VERSION=1.8 38 | - python: 3.8 39 | env: DJANGO_VERSION=1.9 40 | - python: 3.8 41 | env: DJANGO_VERSION=1.10 42 | - python: 3.8 43 | env: DJANGO_VERSION=1.11 44 | 45 | install: 46 | - pip install coveralls flake8 urllib3 47 | - pip install django==${DJANGO_VERSION} 48 | 49 | script: 50 | - coverage run --branch --source=revproxy setup.py test 51 | - flake8 revproxy 52 | - coverage report --fail-under=100 --show-missing 53 | 54 | after_script: 55 | - coveralls 56 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.13.0 (2024-11-05) 2 | =================== 3 | 4 | * Added `REVPROXY` settings dict #192 5 | * Let encode spaces as `%20` or `+` #191 6 | * Cast int cookie dict max_age #185 7 | * Replaced deprecated getheader in favor of headers #184 8 | 9 | 10 | 0.12.0 (2023-10-19) 11 | =================== 12 | 13 | * Declare Django 4.2 support in #167 14 | * Drop mock dependency in favor of unittest.mock in #168 15 | * Update README.rst with the correct Header name in #170. Thanks @adrgs ! 16 | * Fixed ignored headers issue in #172. Thanks for the detailed reporting @jagotu ! 17 | * Deprecated setup.py in github actions in #173 and #174 18 | 19 | 20 | 0.11.0 (2023-02-26) 21 | =================== 22 | 23 | * Add X-Forwarded-For and X-Forwarded-Proto headers. Fixes #79. 24 | * Add Django 3.2, 4.0 and 4.1 support. Fixes #126. 25 | * Add Python 3.8, 3.9, 3.10 and 3.11 support 26 | * Drop Python 3.4, 3.5 and 3.6 support 27 | * Drop Django <3.0 support 28 | * Fixed README badges 29 | 30 | 31 | 0.10.0 (2020-02-05) 32 | =================== 33 | 34 | * Fix add_remote_user when run without AuthenticationMiddleware. Fix #86 35 | * Add get_encoded_query_params method 36 | * Add support for Python 3.7 and 3.8. 37 | * Add support for Django 2.2 and 3.0. 38 | 39 | 40 | 0.9.15 (2018-05-30) 41 | =================== 42 | 43 | * Fix issues with latest urllib3. Fixes #75. 44 | * Fix issues with parsing cookies. Fixes #84. 45 | * Drop Python 3.3, 3.4, and PyPy support. 46 | * Add Python 3.6 support. 47 | 48 | 49 | 0.9.14 (2018-01-11) 50 | =================== 51 | 52 | * Move construction of proxied path to method [@dimrozakis] 53 | * User.get_username() rather than User.name to support custom User models [@acordiner] 54 | 55 | 56 | 0.9.13 (2016-10-31) 57 | =================== 58 | 59 | * Added support to Django 1.10 (support to 1.7 was dropped) 60 | 61 | 62 | 0.9.12 (2016-05-23) 63 | =================== 64 | 65 | * Fixed error 500 caused by content with wrong encoding [@lucaskanashiro, @macartur] 66 | 67 | 68 | 0.9.11 (2016-03-29) 69 | =================== 70 | 71 | * Updated urllib3 to 1.12 (at least) 72 | 73 | 74 | 0.9.10 (2016-02-03) 75 | =================== 76 | 77 | * Fixed Python 3 compatibility issue (see #59 and #61). Thanks @stefanklug and @macro1! 78 | 79 | 80 | 0.9.9 (2015-12-15) 81 | ================== 82 | 83 | * Reorder header prior to httplib request. `Host` should be always the first request header. 84 | 85 | 86 | 0.9.8 (2015-12-10) 87 | ================== 88 | 89 | * Added support to Django 1.9 (dropped support to Django 1.6) 90 | * Added `get_request_headers` to make easier to set and override request headers 91 | 92 | 93 | 0.9.7 (2015-09-17) 94 | ================== 95 | 96 | * Bug fixed: property preventing to set upstream and diazo_rules (#53, #54) [@vdemin] 97 | * Security issue fixed: when colon is present at URL path urljoin ignores the upstream and the request is redirected to the path itself allowing content injection 98 | 99 | 100 | 0.9.6 (2015-09-09) 101 | ================== 102 | 103 | * Fixed connections pools 104 | * Use wsgiref to check for hop-by-hop headers [#50] 105 | * Refactored tests 106 | * Fixed security issue that allowed remote-user header injection 107 | 108 | 109 | 0.9.5 (2015-09-02) 110 | ================== 111 | 112 | * Added extras_require to make easier diazo installation 113 | 114 | 115 | 0.9.4 (2015-08-27) 116 | ================== 117 | 118 | * Alow to send context dict to transformation template. [@chaws, @macartur] 119 | 120 | 121 | 0.9.3 (2015-06-12) 122 | ================== 123 | 124 | * Use StringIO intead of BytesIO on theme compilation (transformation) 125 | 126 | 127 | 0.9.2 (2015-06-09) 128 | ================== 129 | 130 | Thanks @rafamanzo for the reports. 131 | 132 | * Append a backslash on upstream when needed 133 | * Validate upstream URL to make sure it has a scheme 134 | * Added branch test coverage 135 | 136 | 137 | 0.9.1 (2015-05-18) 138 | ================== 139 | 140 | * More permissive URL scheme (#41). 141 | * Refactored code to allow setting custom headers by extending method (#40) [@marciomazza] 142 | 143 | 144 | 0.9.0 (2015-03-04) 145 | =================== 146 | 147 | * urllib2 replaced by urllib3 (#10) 148 | * No Diazo transformation if header X-Diazo-Off is set to true - either request or response (#15) 149 | * Removed double memory usage when reading response body (#16) 150 | * Fixed bug caused by many set-cookies coming from upstream (#23) - by @thiagovsk and @macartur 151 | * Added stream support for serving big files with an acceptable memory footprint (#17 and #24). Thanks to @lucasmoura, @macartur, @carloshfoliveira and @thiagovsk. 152 | * Moved Diazo functionalities to DiazoProxyView. 153 | * Logging improved (#21). 154 | * Added options for default_content_type and retries [@gldnspud]. 155 | * Sphinx docs (#25). 156 | * 100% test coverage. 157 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to django-revproxy 3 | ========================== 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :alt: Jazzband 7 | :target: https://jazzband.co/ 8 | 9 | .. image:: https://img.shields.io/pypi/v/django-revproxy.svg 10 | :alt: PyPI version 11 | :target: https://pypi.org/project/django-revproxy/ 12 | 13 | .. image:: https://img.shields.io/pypi/pyversions/django-revproxy.svg 14 | :alt: Supported Python versions 15 | :target: https://pypi.org/project/django-revproxy/ 16 | 17 | .. image:: https://github.com/jazzband/django-revproxy/workflows/Test/badge.svg 18 | :target: https://github.com/jazzband/django-revproxy/actions 19 | :alt: GitHub Actions 20 | 21 | .. image:: https://codecov.io/gh/jazzband/django-revproxy/branch/master/graph/badge.svg 22 | :target: https://codecov.io/gh/jazzband/django-revproxy 23 | :alt: Test Coverage 24 | 25 | 26 | A simple reverse proxy using Django. It allows to use Django as a 27 | reverse Proxy to HTTP requests. It also allows to use Django as an 28 | authentication Proxy. 29 | 30 | Documentation available at http://django-revproxy.readthedocs.org/ 31 | 32 | 33 | Features 34 | --------- 35 | 36 | * Proxies all HTTP methods: HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE, CONNECT and PATCH 37 | * Copy all http headers sent from the client to the proxied server 38 | * Copy all http headers sent from the proxied server to the client (except `hop-by-hop `_) 39 | * Basic URL rewrite 40 | * Sets the http header REMOTE_USER if the user is logged in Django 41 | * Sets the http headers X-Forwarded-For and X-Forwarded-Proto 42 | * Handles redirects 43 | * Few external dependencies 44 | * Apply XSLT transformation in the response (requires Diazo) 45 | 46 | 47 | Dependencies 48 | ------------ 49 | 50 | * django >= 3.0 51 | * urllib3 >= 1.12 52 | * diazo >= 1.0.5 (optional) 53 | * lxml >= 3.4, < 3.5 (optional, but diazo dependency) 54 | 55 | 56 | Install 57 | -------- 58 | 59 | ``pip install django-revproxy`` 60 | 61 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | TODO 2 | ===== 3 | 4 | * Update README adding configuration 5 | * Write tests for: 6 | * Diazo transformations 7 | * POST: 8 | * GET attributes 9 | * Using `multipart/form-data` mime 10 | * Using `application/x-www-form-urlencoded` 11 | * Using `text/plain` 12 | * Using `application/octet-stream` 13 | * Using a broken content-type 14 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: false 5 | tests: 6 | paths: tests 7 | informational: true 8 | revproxy: 9 | paths: revproxy 10 | informational: true 11 | patch: off 12 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/revproxy.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/revproxy.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/revproxy" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/revproxy" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/revproxy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/docs/_static/revproxy.jpg -------------------------------------------------------------------------------- /docs/_templates/delete_me: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/docs/_templates/delete_me -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | --------- 4 | 5 | .. include:: ../CHANGELOG.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-revproxy documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Mar 4 10:44:29 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # Add path to get modules 19 | sys.path.append(os.path.abspath('..')) 20 | 21 | from revproxy import __version__ 22 | 23 | # If extensions (or modules to document with autodoc) are in another directory, 24 | # add these directories to sys.path here. If the directory is relative to the 25 | # documentation root, use os.path.abspath to make it absolute, like shown here. 26 | #sys.path.insert(0, os.path.abspath('.')) 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | #needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.doctest', 39 | 'sphinx.ext.imgmath', 40 | 'sphinx.ext.ifconfig', 41 | 'sphinx.ext.viewcode', 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # The suffix of source filenames. 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | #source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = u'django-revproxy' 58 | copyright = u'2015, Sergio Oliveira' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = __version__ 66 | # The full version, including alpha/beta/rc tags. 67 | release = version 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | #language = None 72 | 73 | # There are two options for replacing |today|: either, you set today to some 74 | # non-false value, then it is used: 75 | #today = '' 76 | # Else, today_fmt is used as the format for a strftime call. 77 | #today_fmt = '%B %d, %Y' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | exclude_patterns = [] 82 | 83 | # The reST default role (used for this markup: `text`) to use for all 84 | # documents. 85 | #default_role = None 86 | 87 | # If true, '()' will be appended to :func: etc. cross-reference text. 88 | #add_function_parentheses = True 89 | 90 | # If true, the current module name will be prepended to all description 91 | # unit titles (such as .. function::). 92 | #add_module_names = True 93 | 94 | # If true, sectionauthor and moduleauthor directives will be shown in the 95 | # output. They are ignored by default. 96 | #show_authors = False 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = 'sphinx' 100 | 101 | # A list of ignored prefixes for module index sorting. 102 | #modindex_common_prefix = [] 103 | 104 | # If true, keep warnings as "system message" paragraphs in the built documents. 105 | #keep_warnings = False 106 | 107 | 108 | # -- Options for HTML output ---------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | html_theme = 'sphinx_rtd_theme' 113 | 114 | # Theme options are theme-specific and customize the look and feel of a theme 115 | # further. For a list of options available for each theme, see the 116 | # documentation. 117 | #html_theme_options = {} 118 | 119 | # Add any paths that contain custom themes here, relative to this directory. 120 | #html_theme_path = [] 121 | 122 | # The name for this set of Sphinx documents. If None, it defaults to 123 | # " v documentation". 124 | #html_title = None 125 | 126 | # A shorter title for the navigation bar. Default is the same as html_title. 127 | #html_short_title = None 128 | 129 | # The name of an image file (relative to this directory) to place at the top 130 | # of the sidebar. 131 | #html_logo = None 132 | 133 | # The name of an image file (within the static path) to use as favicon of the 134 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 135 | # pixels large. 136 | #html_favicon = None 137 | 138 | # Add any paths that contain custom static files (such as style sheets) here, 139 | # relative to this directory. They are copied after the builtin static files, 140 | # so a file named "default.css" will overwrite the builtin "default.css". 141 | html_static_path = ['_static'] 142 | 143 | # Add any extra paths that contain custom files (such as robots.txt or 144 | # .htaccess) here, relative to this directory. These files are copied 145 | # directly to the root of the documentation. 146 | #html_extra_path = [] 147 | 148 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 149 | # using the given strftime format. 150 | #html_last_updated_fmt = '%b %d, %Y' 151 | 152 | # If true, SmartyPants will be used to convert quotes and dashes to 153 | # typographically correct entities. 154 | #html_use_smartypants = True 155 | 156 | # Custom sidebar templates, maps document names to template names. 157 | #html_sidebars = {} 158 | 159 | # Additional templates that should be rendered to pages, maps page names to 160 | # template names. 161 | #html_additional_pages = {} 162 | 163 | # If false, no module index is generated. 164 | #html_domain_indices = True 165 | 166 | # If false, no index is generated. 167 | #html_use_index = True 168 | 169 | # If true, the index is split into individual pages for each letter. 170 | #html_split_index = False 171 | 172 | # If true, links to the reST sources are added to the pages. 173 | #html_show_sourcelink = True 174 | 175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 176 | #html_show_sphinx = True 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 179 | #html_show_copyright = True 180 | 181 | # If true, an OpenSearch description file will be output, and all pages will 182 | # contain a tag referring to it. The value of this option must be the 183 | # base URL from which the finished HTML is served. 184 | #html_use_opensearch = '' 185 | 186 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 187 | #html_file_suffix = None 188 | 189 | # Output file base name for HTML help builder. 190 | htmlhelp_basename = 'django-revproxydoc' 191 | 192 | 193 | # -- Options for LaTeX output --------------------------------------------- 194 | 195 | latex_elements = { 196 | # The paper size ('letterpaper' or 'a4paper'). 197 | #'papersize': 'letterpaper', 198 | 199 | # The font size ('10pt', '11pt' or '12pt'). 200 | #'pointsize': '10pt', 201 | 202 | # Additional stuff for the LaTeX preamble. 203 | #'preamble': '', 204 | } 205 | 206 | # Grouping the document tree into LaTeX files. List of tuples 207 | # (source start file, target name, title, 208 | # author, documentclass [howto, manual, or own class]). 209 | latex_documents = [ 210 | ('index', 'django-revproxy.tex', u'django-revproxy Documentation', 211 | u'Sergio Oliveira', 'manual'), 212 | ] 213 | 214 | # The name of an image file (relative to this directory) to place at the top of 215 | # the title page. 216 | #latex_logo = None 217 | 218 | # For "manual" documents, if this is true, then toplevel headings are parts, 219 | # not chapters. 220 | #latex_use_parts = False 221 | 222 | # If true, show page references after internal links. 223 | #latex_show_pagerefs = False 224 | 225 | # If true, show URL addresses after external links. 226 | #latex_show_urls = False 227 | 228 | # Documents to append as an appendix to all manuals. 229 | #latex_appendices = [] 230 | 231 | # If false, no module index is generated. 232 | #latex_domain_indices = True 233 | 234 | 235 | # -- Options for manual page output --------------------------------------- 236 | 237 | # One entry per manual page. List of tuples 238 | # (source start file, name, description, authors, manual section). 239 | man_pages = [ 240 | ('index', 'django-revproxy', u'django-revproxy Documentation', 241 | [u'Sergio Oliveira'], 1) 242 | ] 243 | 244 | # If true, show URL addresses after external links. 245 | #man_show_urls = False 246 | 247 | 248 | # -- Options for Texinfo output ------------------------------------------- 249 | 250 | # Grouping the document tree into Texinfo files. List of tuples 251 | # (source start file, target name, title, author, 252 | # dir menu entry, description, category) 253 | texinfo_documents = [ 254 | ('index', 'django-revproxy', u'django-revproxy Documentation', 255 | u'Sergio Oliveira', 'django-revproxy', 'One line description of project.', 256 | 'Miscellaneous'), 257 | ] 258 | 259 | # Documents to append as an appendix to all manuals. 260 | #texinfo_appendices = [] 261 | 262 | # If false, no module index is generated. 263 | #texinfo_domain_indices = True 264 | 265 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 266 | #texinfo_show_urls = 'footnote' 267 | 268 | # If true, do not generate a @detailmenu in the "Top" node's menu. 269 | #texinfo_no_detailmenu = False 270 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. revproxy documentation master file, created by 2 | sphinx-quickstart on Mon Feb 23 22:05:23 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../README.rst 7 | 8 | 9 | Contents: 10 | --------- 11 | 12 | .. toctree:: 13 | :maxdepth: 4 14 | 15 | introduction 16 | quickstart 17 | proxyview 18 | modules 19 | settings 20 | changelog 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | 30 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ================== 3 | 4 | How does it work? 5 | ----------------- 6 | 7 | .. image:: _static/revproxy.jpg 8 | :width: 600 px 9 | :align: center 10 | 11 | At a high level, this is what happens behind the scenes in a request proxied by django-revproxy: 12 | 13 | #. Django receives a request from the client and process it using a view that extends `revproxy.proxy.ProxyView`. 14 | 15 | #. Revproxy will clone the client request. 16 | 17 | #. If the user is authenticated in Django and `add_remote_user` attribute is set to `True` the HTTP header `REMOTE_USER` will be set with `request.user.username`. 18 | 19 | #. If the `add_x_forwarded` attribute is set to `True` the HTTP headers `X-Forwarded-For` and `X-Forwarded-Proto` will be set to the IP address of the requestor and the protocol (http or https), respectively. 20 | 21 | #. The cloned request is sent to the upstream server (set in the view). 22 | 23 | #. After receiving the response from upstream, the view will process it to make sure all headers are set properly. Some headers like `Location` are treated as special cases. 24 | 25 | #. The response received from the upstream server is transformed into a `django.http.HttpResponse`. For binary files `StreamingHttpResponse` is used instead to reduce memory usage. 26 | 27 | #. If the user has setted a set of diazo rules and a theme template, a diazo/XSLT transformation will be applied on the response body. 28 | 29 | #. Finally, the response will then be returned to the user 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\revproxy.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\revproxy.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | 5 | revproxy.views 6 | --------------------- 7 | 8 | .. automodule:: revproxy.views 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | :noindex: 13 | 14 | 15 | revproxy.response 16 | ------------------------ 17 | 18 | .. automodule:: revproxy.response 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | revproxy.transformer 25 | --------------------------- 26 | 27 | .. automodule:: revproxy.transformer 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | 33 | revproxy.utils 34 | --------------------- 35 | 36 | .. automodule:: revproxy.utils 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | -------------------------------------------------------------------------------- /docs/proxyview.rst: -------------------------------------------------------------------------------- 1 | 2 | Usage 3 | ===== 4 | 5 | ============ 6 | Proxy Views 7 | ============ 8 | 9 | This document covers the views provided by ``revproxy.views`` and all it's public attributes 10 | 11 | .. class:: revproxy.views.ProxyView 12 | 13 | Proxies requests to a given upstream server and returns a 14 | Django Response. 15 | 16 | **Example urls.py**:: 17 | 18 | from django.urls import re_path 19 | 20 | from revproxy.views import ProxyView 21 | 22 | urlpatterns = [ 23 | re_path(r'(?P.*)', ProxyView.as_view(upstream='http://example.com/')), 24 | ] 25 | 26 | 27 | **Attributes** 28 | 29 | .. attribute:: upstream 30 | 31 | The URL of the proxied server. Requests will be made to this URL 32 | with ``path`` (extracted from ``urls.py``) appended to it. 33 | This attribute is mandatory. 34 | 35 | .. attribute:: add_remote_user 36 | 37 | Whether to add the ``REMOTE_USER`` to the request in case of an 38 | authenticated user. Defaults to ``False``. 39 | 40 | .. attribute:: add_x_forwarded 41 | 42 | Whether to add the ``X-Forwarded-For`` and ``X-Forwarded-Proto`` 43 | headers to the request. Defaults to ``False``. 44 | 45 | .. attribute:: default_content_type 46 | 47 | The *Content-Type* that will be added to the response in case 48 | the upstream server doesn't send it and if ``mimetypes.guess_type`` 49 | is not able to guess. Defaults to ``'application/octet-stream'``. 50 | 51 | .. attribute:: retries 52 | 53 | The max number of attempts for a request. This can also be an 54 | instance of ``urllib3.Retry``. If set to None it will fail if 55 | the first attempt fails. The default value is None. 56 | 57 | .. attribute:: rewrite 58 | 59 | A list of tuples in the style ``(from, to)`` where ``from`` 60 | must by a valid regex expression and ``to`` a valid URL. If 61 | ``request.get_full_path`` matches the ``from`` expression the 62 | request will be redirected to ``to`` with an status code 63 | ``302``. Matches groups can be used to pass parts from the 64 | ``from`` URL to the ``to`` URL using numbered groups. 65 | By default no rewrite is set. 66 | 67 | **Example**:: 68 | 69 | class CustomProxyView(ProxyView): 70 | upstream = 'http://www.example.com' 71 | rewrite = ( 72 | (r'^/yellow/star/$', r'/black/hole/'), 73 | (r'^/red/?$', r'http://www.mozilla.org/'), 74 | 75 | # Example with numbered match groups 76 | (r'^/foo/(.*)$', r'/bar\1'), 77 | ) 78 | 79 | .. attribute:: strict_cookies 80 | 81 | Whether to only accept RFC-compliant cookies. If set to ``True``, 82 | any cookies received from the upstream server that do not conform to 83 | the RFC will be dropped. 84 | 85 | .. attribute:: streaming_amount 86 | 87 | The buffering amount for streaming HTTP response(in bytes), response will 88 | be buffered until it's length exceeds this value. ``None`` means using 89 | default value, override this variable to change. 90 | 91 | **Methods** 92 | 93 | .. automethod:: revproxy.views.ProxyView.get_request_headers 94 | 95 | Extend this method can be particularly useful to add or 96 | remove headers from your proxy request. See the example bellow:: 97 | 98 | class CustomProxyView(ProxyView): 99 | upstream = 'http://www.example.com' 100 | 101 | def get_request_headers(self): 102 | # Call super to get default headers 103 | headers = super(CustomProxyView, self).get_request_headers() 104 | # Add new header 105 | headers['DNT'] = 1 106 | return headers 107 | 108 | .. class:: revproxy.views.DiazoProxyView 109 | 110 | In addition to ProxyView behavior this view also performs Diazo 111 | transformations on the response before sending it back to the 112 | original client. Furthermore, it's possible to pass context data 113 | to the view thanks to ContextMixin behavior through 114 | ``get_context_data()`` method. 115 | 116 | .. seealso:: 117 | 118 | Diazo is an awesome tool developed by Plone Community to 119 | perform XSLT transformations in a simpler way. In order to 120 | use all Diazo power please refer to: http://docs.diazo.org/en/latest/ 121 | 122 | 123 | **Example urls.py**:: 124 | 125 | from django.urls import re_path 126 | 127 | from revproxy.views import DiazoProxyView 128 | 129 | proxy_view = DiazoProxyView.as_view( 130 | upstream='http://example.com/', 131 | html5=True, 132 | diazo_theme_template='base.html', 133 | ) 134 | 135 | urlpatterns = [ 136 | re_path(r'(?P.*)', proxy_view), 137 | ] 138 | 139 | 140 | **Example base.html** 141 | 142 | .. code-block:: html 143 | 144 | 145 | ... 146 | 147 | ... 148 |
149 | ...Fix all links in the docs (and README file etc) from old to new repo 150 | 151 | 152 | **Example diazo.xml** 153 | 154 | .. code-block:: xml 155 | 156 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | **Attributes** 168 | 169 | .. attribute:: diazo_theme_template 170 | 171 | The Django template to be used as Diazo theme. If set to 172 | ``None`` Diazo will be disabled. By default ``diazo.html`` 173 | will be used. 174 | 175 | .. attribute:: diazo_rules 176 | 177 | The absolute path for the diazo rules file. By default it 178 | will look for the file ``diazo.xml`` on the Django 179 | application directory. If set to ``None`` Diazo will be 180 | disabled. 181 | 182 | .. attribute:: html5 183 | 184 | By default Diazo changes the doctype for html5 to html4. If 185 | this attribute is set to ``True`` the doctype will be kept. 186 | This attribute only works if Diazo transformations are enabled. 187 | 188 | 189 | **Methods** 190 | 191 | .. automethod:: revproxy.views.DiazoProxyView.get_context_data 192 | 193 | Extend this method if you need to send context variables to the 194 | template before it's used in the proxied response transformation. 195 | This method was inherited from ContextMixin. 196 | 197 | .. versionadded:: 0.9.4 198 | 199 | See the example bellow:: 200 | 201 | from django.urls import re_path 202 | 203 | from revproxy.views import DiazoProxyView 204 | 205 | class CustomProxyView(DiazoProxyView): 206 | upstream = 'http://example.com/' 207 | custom_attribute = 'hello' 208 | 209 | def get_context_data(self, **kwargs): 210 | context_data = super(CustomProxyView, self).get_context_data(**kwargs) 211 | context_data.update({'foo': 'bar'}) 212 | return context_data 213 | 214 | 215 | # urls.py 216 | urlpatterns = [ 217 | re_path(r'(?P.*)', proxy_view), 218 | ] 219 | 220 | 221 | And than the data will be available in the template as follow: 222 | 223 | .. code-block:: html 224 | 225 | 226 | ... 227 | 228 | ... 229 |
230 | {{ view.custom_attribute }} 231 | {{ foo }} 232 |
233 | ... 234 | 235 | 236 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ============= 3 | 4 | Installation 5 | -------------- 6 | 7 | .. code-block:: sh 8 | 9 | $ pip install django-revproxy 10 | 11 | If you want to use DiazoProxyView you will also need to install Diazo. In that case you can use the following handy shortcurt: 12 | 13 | .. code-block:: sh 14 | 15 | $ pip install django-revproxy[diazo] 16 | 17 | 18 | Configuration 19 | -------------- 20 | 21 | After installation, you'll need to configure your application to use django-revproxy. 22 | Start by adding revproxy to your ``settings.py`` file as follows: 23 | 24 | .. code-block:: python 25 | 26 | #Add 'revproxy' to INSTALLED_APPS. 27 | INSTALLED_APPS = ( 28 | # ... 29 | 'django.contrib.auth', 30 | 'revproxy.apps.RevProxyConfig', 31 | # ... 32 | ) 33 | 34 | 35 | Next, you'll need to create a View that extends ``revproxy.views.ProxyView`` and set the upstrem attribute: 36 | 37 | .. code-block:: python 38 | 39 | from revproxy.views import ProxyView 40 | 41 | class TestProxyView(ProxyView): 42 | upstream = 'http://example.com' 43 | 44 | 45 | And now add your view in the ``urls.py``: 46 | 47 | .. code-block:: python 48 | 49 | from django.urls import re_path 50 | 51 | from myapp.views import TestProxyView 52 | 53 | urlpatterns = [ 54 | re_path(r'(?P.*)', TestProxyView.as_view()), 55 | ] 56 | 57 | Alternatively you could just use the default ProxyView as follow: 58 | 59 | .. code-block:: python 60 | 61 | from django.urls import re_path 62 | 63 | from revproxy.views import ProxyView 64 | 65 | urlpatterns = [ 66 | re_path(r'(?P.*)', ProxyView.as_view(upstream='http://example.com/')), 67 | ] 68 | 69 | 70 | 71 | After starting your test server you should see the content of `http://example.com/` on `http://localhost:8000/`. 72 | 73 | .. seealso:: 74 | 75 | An example of a project can be found here: 76 | https://github.com/seocam/revproxy-test 77 | 78 | The provided test project is a simple Django project that makes 79 | uses of revproxy. It basically possess a view.py that extends 80 | from ProxyView and sets the upstream address to 'httpbin.org'. 81 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.2.6 2 | sphinx_rtd_theme==1.3.0 3 | readthedocs-sphinx-search==0.3.2 4 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | Our configurations are all namespaced under the ``REVPROXY`` settings. 5 | 6 | For example: 7 | 8 | .. code-block:: python 9 | 10 | REVPROXY = { 11 | 'QUOTE_SPACES_AS_PLUS': True, 12 | } 13 | 14 | 15 | List of available settings 16 | -------------------------- 17 | 18 | QUOTE_SPACES_AS_PLUS 19 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 20 | 21 | Default: ``True`` 22 | 23 | Indicates whether spaces should be replaced by %20 or + when parsing a URL. 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-revproxy" 7 | authors = [ 8 | { name = "Sergio Oliveira", email = "sergio@tracy.com.br" }, 9 | ] 10 | 11 | description = "Yet another Django reverse proxy application" 12 | readme = "README.rst" 13 | 14 | requires-python = ">=3.7" 15 | keywords = ["django", "reverse proxy", "revproxy"] 16 | license = { text = "MPL v2.0" } 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Environment :: Web Environment", 20 | "Framework :: Django", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Framework :: Django", 31 | "Framework :: Django :: 3.0", 32 | "Framework :: Django :: 3.1", 33 | "Framework :: Django :: 3.2", 34 | "Framework :: Django :: 4.0", 35 | "Framework :: Django :: 4.1", 36 | "Framework :: Django :: 4.2", 37 | "Programming Language :: Python :: Implementation :: CPython", 38 | "Programming Language :: Python :: Implementation :: PyPy", 39 | "Topic :: Internet :: Proxy Servers", 40 | "Topic :: Internet :: WWW/HTTP", 41 | "Topic :: Software Development :: Libraries", 42 | ] 43 | dependencies = [ 44 | "Django>=3.0", 45 | "urllib3>=1.12", 46 | ] 47 | optional-dependencies.diazo = [ 48 | "diazo>=1.0.5", 49 | "lxml>=3.4", 50 | ] 51 | optional-dependencies.tests = [ 52 | "diazo", 53 | "lxml>=3.4", 54 | "coverage", 55 | "flake8", 56 | ] 57 | 58 | dynamic = ["version"] 59 | 60 | [tool.setuptools.dynamic] 61 | version = { attr = "revproxy.__version__" } 62 | 63 | [project.urls] 64 | homepage = "https://github.com/jazzband/django-revproxy" 65 | download = "https://pypi.org/project/django-revproxy/" 66 | documentation = "https://django-revproxy.readthedocs.io/en/stable/" 67 | changelog = "https://django-revproxy.readthedocs.io/en/latest/changelog.html" 68 | issues = "https://github.com/jazzband/django-revproxy/issues" 69 | 70 | [tool.setuptools] 71 | packages = ["revproxy"] 72 | 73 | [tool.setuptools_scm] 74 | version_scheme = "post-release" 75 | -------------------------------------------------------------------------------- /revproxy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.13.0' 2 | 3 | default_app_config = 'revproxy.apps.RevProxyConfig' 4 | -------------------------------------------------------------------------------- /revproxy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.utils.translation import gettext_lazy as _ 4 | from .settings import REVPROXY_DEFAULT_SETTINGS 5 | 6 | 7 | class RevProxyConfig(AppConfig): 8 | name = 'revproxy' 9 | verbose_name = _('Revproxy') 10 | 11 | def ready(self): 12 | super().ready() 13 | default_settings = { 14 | 'REVPROXY': REVPROXY_DEFAULT_SETTINGS 15 | } 16 | 17 | if not hasattr(settings, 'REVPROXY'): 18 | setattr(settings, 'REVPROXY', default_settings['REVPROXY']) 19 | else: 20 | for key, value in default_settings['REVPROXY'].items(): 21 | settings.REVPROXY.setdefault(key, value) 22 | -------------------------------------------------------------------------------- /revproxy/connection.py: -------------------------------------------------------------------------------- 1 | 2 | from urllib3.connection import HTTPConnection, HTTPSConnection 3 | from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool 4 | 5 | 6 | def _output(self, s): 7 | """Host header should always be first""" 8 | 9 | if s.lower().startswith(b'host: '): 10 | self._buffer.insert(1, s) 11 | else: 12 | self._buffer.append(s) 13 | 14 | 15 | HTTPConnectionPool.ConnectionCls = type( 16 | 'RevProxyHTTPConnection', 17 | (HTTPConnection,), 18 | {'_output': _output}, 19 | ) 20 | 21 | HTTPSConnectionPool.ConnectionCls = type( 22 | 'RevProxyHTTPSConnection', 23 | (HTTPSConnection,), 24 | {'_output': _output} 25 | ) 26 | -------------------------------------------------------------------------------- /revproxy/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ReverseProxyException(Exception): 4 | """Base for revproxy exception""" 5 | 6 | 7 | class InvalidUpstream(ReverseProxyException): 8 | """Invalid upstream set""" 9 | -------------------------------------------------------------------------------- /revproxy/response.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .utils import cookie_from_string, should_stream, set_response_headers 4 | 5 | from django.http import HttpResponse, StreamingHttpResponse 6 | 7 | logger = logging.getLogger('revproxy.response') 8 | 9 | 10 | def get_django_response( 11 | proxy_response, strict_cookies=False, streaming_amount=None 12 | ): 13 | """This method is used to create an appropriate response based on the 14 | Content-Length of the proxy_response. If the content is bigger than 15 | MIN_STREAMING_LENGTH, which is found on utils.py, 16 | than django.http.StreamingHttpResponse will be created, 17 | else a django.http.HTTPResponse will be created instead 18 | 19 | :param proxy_response: An Instance of urllib3.response.HTTPResponse that 20 | will create an appropriate response 21 | :param strict_cookies: Whether to only accept RFC-compliant cookies 22 | :param streaming_amount: The amount for streaming HTTP response, if not 23 | given, use a dynamic value -- 1("no-buffering") 24 | for "text/event-stream" content type, 65535 for 25 | other types. 26 | :returns: Returns an appropriate response based on the proxy_response 27 | content-length 28 | """ 29 | status = proxy_response.status 30 | headers = proxy_response.headers 31 | 32 | logger.debug('Proxy response headers: %s', headers) 33 | 34 | content_type = headers.get('Content-Type') 35 | 36 | logger.debug('Content-Type: %s', content_type) 37 | 38 | if should_stream(proxy_response): 39 | if streaming_amount is None: 40 | amt = get_streaming_amt(proxy_response) 41 | else: 42 | amt = streaming_amount 43 | 44 | logger.info(('Starting streaming HTTP Response, buffering amount=' 45 | '"%s bytes"'), amt) 46 | response = StreamingHttpResponse(proxy_response.stream(amt), 47 | status=status, 48 | content_type=content_type) 49 | else: 50 | content = proxy_response.data or b'' 51 | response = HttpResponse(content, status=status, 52 | content_type=content_type) 53 | 54 | logger.info('Normalizing response headers') 55 | set_response_headers(response, headers) 56 | 57 | cookies = proxy_response.headers.getlist('set-cookie') 58 | logger.info('Checking for invalid cookies') 59 | for cookie_string in cookies: 60 | cookie_dict = cookie_from_string(cookie_string, 61 | strict_cookies=strict_cookies) 62 | # if cookie is invalid cookie_dict will be None 63 | if cookie_dict: 64 | response.set_cookie(**cookie_dict) 65 | 66 | logger.debug('Response cookies: %s', response.cookies) 67 | 68 | return response 69 | 70 | 71 | # Default number of bytes that are going to be read in a file lecture 72 | DEFAULT_AMT = 2**16 73 | # The amount of chunk being used when no buffering is needed: return every byte 74 | # eagerly, which might be bad in performance perspective, but is essential for 75 | # some special content types, e.g. "text/event-stream". Without disabling 76 | # buffering, all events will pending instead of return in realtime. 77 | NO_BUFFERING_AMT = 1 78 | 79 | NO_BUFFERING_CONTENT_TYPES = set(['text/event-stream', ]) 80 | 81 | 82 | def get_streaming_amt(proxy_response): 83 | """Get the value of streaming amount(in bytes) when streaming response 84 | 85 | :param proxy_response: urllib3.response.HTTPResponse object 86 | """ 87 | content_type = proxy_response.headers.get('Content-Type', '') 88 | # Disable buffering for "text/event-stream"(or other special types) 89 | if content_type.lower() in NO_BUFFERING_CONTENT_TYPES: 90 | return NO_BUFFERING_AMT 91 | return DEFAULT_AMT 92 | -------------------------------------------------------------------------------- /revproxy/settings.py: -------------------------------------------------------------------------------- 1 | REVPROXY_DEFAULT_SETTINGS = { 2 | 'QUOTE_SPACES_AS_PLUS': True, 3 | } 4 | -------------------------------------------------------------------------------- /revproxy/transformer.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | from io import StringIO 5 | 6 | import logging 7 | 8 | try: 9 | from django.utils.six import string_types 10 | except ImportError: 11 | # Django 3 has no six 12 | string_types = str 13 | from django.template import loader 14 | 15 | try: 16 | from diazo.compiler import compile_theme 17 | except ImportError: 18 | #: Variable used to identify if diazo is available 19 | HAS_DIAZO = False 20 | else: 21 | HAS_DIAZO = True 22 | from lxml import etree 23 | 24 | from .utils import get_charset, is_html_content_type 25 | 26 | #: Regex used to find the doctype-header in a html content 27 | doctype_re = re.compile(br"^]+>\s*", re.MULTILINE) 28 | #: String used to verify if request has a 'HTTP_X_DIAZO_OFF' header 29 | DIAZO_OFF_REQUEST_HEADER = 'HTTP_X_DIAZO_OFF' 30 | #: String used to verify if request has a 'X-Diazo-Off' header 31 | DIAZO_OFF_RESPONSE_HEADER = 'X-Diazo-Off' 32 | 33 | 34 | def asbool(value): 35 | """Function used to convert certain string values into an appropriated 36 | boolean value.If value is not a string the built-in python 37 | bool function will be used to convert the passed parameter 38 | 39 | :param value: an object to be converted to a boolean value 40 | :returns: A boolean value 41 | """ 42 | 43 | is_string = isinstance(value, string_types) 44 | 45 | if is_string: 46 | value = value.strip().lower() 47 | if value in ('true', 'yes', 'on', 'y', 't', '1',): 48 | return True 49 | elif value in ('false', 'no', 'off', 'n', 'f', '0'): 50 | return False 51 | else: 52 | raise ValueError("String is not true/false: %r" % value) 53 | else: 54 | return bool(value) 55 | 56 | 57 | class DiazoTransformer(object): 58 | """Class used to make a diazo transformation on a request or a response""" 59 | 60 | def __init__(self, request, response): 61 | self.request = request 62 | self.response = response 63 | self.log = logging.getLogger('revproxy.transformer') 64 | self.log.info("DiazoTransformer created") 65 | 66 | def is_ajax(self): 67 | """ 68 | request.is_ajax() is marked as deprecated in django 3.1. removed in 4.0 69 | See: https://docs.djangoproject.com/en/3.1/releases/3.1/#id2 70 | """ 71 | if hasattr(self.request, 'is_ajax'): 72 | return self.request.is_ajax() 73 | else: 74 | return self.request.headers.get('x-requested-with') \ 75 | == 'XMLHttpRequest' 76 | 77 | def should_transform(self): 78 | """Determine if we should transform the response 79 | 80 | :returns: A boolean value 81 | """ 82 | 83 | if not HAS_DIAZO: 84 | self.log.info("HAS_DIAZO: false") 85 | return False 86 | 87 | if asbool(self.request.META.get(DIAZO_OFF_REQUEST_HEADER)): 88 | self.log.info("DIAZO_OFF_REQUEST_HEADER in request.META: off") 89 | return False 90 | 91 | if asbool(self.response.get(DIAZO_OFF_RESPONSE_HEADER)): 92 | self.log.info("DIAZO_OFF_RESPONSE_HEADER in response.get: off") 93 | return False 94 | 95 | if self.is_ajax(): 96 | self.log.info("Request is AJAX") 97 | return False 98 | 99 | if self.response.streaming: 100 | self.log.info("Response has streaming") 101 | return False 102 | 103 | content_type = self.response.get('Content-Type') 104 | if not is_html_content_type(content_type): 105 | self.log.info("Content-type: false") 106 | return False 107 | 108 | content_encoding = self.response.get('Content-Encoding') 109 | if content_encoding in ('zip', 'compress'): 110 | self.log.info("Content encode is %s", content_encoding) 111 | return False 112 | 113 | status_code = str(self.response.status_code) 114 | if status_code.startswith('3') or \ 115 | status_code == '204' or \ 116 | status_code == '401': 117 | self.log.info("Status code: %s", status_code) 118 | return False 119 | 120 | if len(self.response.content) == 0: 121 | self.log.info("Response Content is EMPTY") 122 | return False 123 | 124 | self.log.info("Transform") 125 | return True 126 | 127 | def transform(self, rules, theme_template, is_html5, context_data=None): 128 | """Method used to make a transformation on the content of 129 | the http response based on the rules and theme_templates 130 | passed as paremters 131 | 132 | :param rules: A file with a set of diazo rules to make a 133 | transformation over the original response content 134 | :param theme_template: A file containing the template used to format 135 | the the original response content 136 | :param is_html5: A boolean parameter to identify a html5 doctype 137 | :returns: A response with a content transformed based on the rules and 138 | theme_template 139 | """ 140 | 141 | if not self.should_transform(): 142 | self.log.info("Don't need to be transformed") 143 | return self.response 144 | 145 | theme = loader.render_to_string(theme_template, context=context_data, 146 | request=self.request) 147 | output_xslt = compile_theme( 148 | rules=rules, 149 | theme=StringIO(theme), 150 | ) 151 | 152 | transform = etree.XSLT(output_xslt) 153 | self.log.debug("Transform: %s", transform) 154 | 155 | charset = get_charset(self.response.get('Content-Type')) 156 | 157 | try: 158 | decoded_response = self.response.content.decode(charset) 159 | except UnicodeDecodeError: 160 | decoded_response = self.response.content.decode(charset, 'ignore') 161 | self.log.warning("Charset is {} and type of encode used in file is\ 162 | different. Some unknown characteres might be\ 163 | ignored.".format(charset)) 164 | 165 | content_doc = etree.fromstring(decoded_response, 166 | parser=etree.HTMLParser()) 167 | 168 | self.response.content = transform(content_doc) 169 | 170 | if is_html5: 171 | self.set_html5_doctype() 172 | 173 | self.reset_headers() 174 | 175 | self.log.debug("Response transformer: %s", self.response) 176 | return self.response 177 | 178 | def reset_headers(self): 179 | """This method remove the header Content-Length entry 180 | from the response 181 | """ 182 | self.log.info("Reset header") 183 | del self.response['Content-Length'] 184 | 185 | def set_html5_doctype(self): 186 | """Method used to transform a doctype in to a properly html5 doctype 187 | """ 188 | doctype = b'\n' 189 | content = doctype_re.subn(doctype, self.response.content, 1)[0] 190 | self.response.content = content 191 | -------------------------------------------------------------------------------- /revproxy/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | import logging 5 | 6 | from wsgiref.util import is_hop_by_hop 7 | 8 | try: 9 | from http.cookies import SimpleCookie 10 | COOKIE_PREFIX = '' 11 | except ImportError: 12 | from Cookie import SimpleCookie 13 | COOKIE_PREFIX = 'Set-Cookie: ' 14 | 15 | 16 | #: List containing string constant that are used to represent headers that can 17 | #: be ignored in the required_header function 18 | IGNORE_HEADERS = ( 19 | 'HTTP_ACCEPT_ENCODING', # We want content to be uncompressed so 20 | # we remove the Accept-Encoding from 21 | # original request 22 | 'HTTP_HOST', 23 | 'HTTP_REMOTE_USER', 24 | ) 25 | 26 | 27 | # Default from HTTP RFC 2616 28 | # See: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1 29 | #: Variable that represent the default charset used 30 | DEFAULT_CHARSET = 'latin-1' 31 | 32 | #: List containing string constants that represents possible html content type 33 | HTML_CONTENT_TYPES = ( 34 | 'text/html', 35 | 'application/xhtml+xml' 36 | ) 37 | 38 | #: Variable used to represent a minimal content size required for response 39 | #: to be turned into stream 40 | MIN_STREAMING_LENGTH = 4 * 1024 # 4KB 41 | 42 | #: Regex used to find charset in a html content type 43 | _get_charset_re = re.compile(r';\s*charset=(?P[^\s;]+)', re.I) 44 | #: Regex used to clean extra HTTP prefixes in headers 45 | _get_header_name_re = re.compile( 46 | r'((http[-|_])*)(?P(http[-|_]).*)', 47 | re.I, 48 | ) 49 | 50 | 51 | def is_html_content_type(content_type): 52 | """Function used to verify if the parameter is a proper html content type 53 | 54 | :param content_type: String variable that represent a content-type 55 | :returns: A boolean value stating if the content_type is a valid html 56 | content type 57 | """ 58 | for html_content_type in HTML_CONTENT_TYPES: 59 | if content_type.startswith(html_content_type): 60 | return True 61 | 62 | return False 63 | 64 | 65 | def should_stream(proxy_response): 66 | """Function to verify if the proxy_response must be converted into 67 | a stream.This will be done by checking the proxy_response content-length 68 | and verify if its length is bigger than one stipulated 69 | by MIN_STREAMING_LENGTH. 70 | 71 | :param proxy_response: An Instance of urllib3.response.HTTPResponse 72 | :returns: A boolean stating if the proxy_response should 73 | be treated as a stream 74 | """ 75 | content_type = proxy_response.headers.get('Content-Type') 76 | 77 | if is_html_content_type(content_type): 78 | return False 79 | 80 | try: 81 | content_length = int(proxy_response.headers.get('Content-Length', 0)) 82 | except ValueError: 83 | content_length = 0 84 | 85 | if not content_length or content_length > MIN_STREAMING_LENGTH: 86 | return True 87 | 88 | return False 89 | 90 | 91 | def get_charset(content_type): 92 | """Function used to retrieve the charset from a content-type.If there is no 93 | charset in the content type then the charset defined on DEFAULT_CHARSET 94 | will be returned 95 | 96 | :param content_type: A string containing a Content-Type header 97 | :returns: A string containing the charset 98 | """ 99 | if not content_type: 100 | return DEFAULT_CHARSET 101 | 102 | matched = _get_charset_re.search(content_type) 103 | if matched: 104 | # Extract the charset and strip its double quotes 105 | return matched.group('charset').replace('"', '') 106 | return DEFAULT_CHARSET 107 | 108 | 109 | def required_header(header): 110 | """Function that verify if the header parameter is an essential header 111 | 112 | :param header: A string represented a header 113 | :returns: A boolean value that represent if the header is required 114 | """ 115 | matched = _get_header_name_re.search(header) 116 | 117 | # Ensure there is only one HTTP prefix in the header 118 | header_name = matched.group('header_name') if matched else header 119 | 120 | header_name_upper = header_name.upper().replace('-', '_') 121 | 122 | if header_name_upper in IGNORE_HEADERS: 123 | return False 124 | 125 | if header_name_upper.startswith('HTTP_') or header == 'CONTENT_TYPE': 126 | return True 127 | 128 | return False 129 | 130 | 131 | def set_response_headers(response, response_headers): 132 | # check for Django 3.2 headers interface 133 | # https://code.djangoproject.com/ticket/31789 134 | # check and set pointer before loop to improve efficiency 135 | if hasattr(response, 'headers'): 136 | headers = response.headers 137 | else: 138 | headers = response 139 | 140 | for header, value in response_headers.items(): 141 | if is_hop_by_hop(header) or header.lower() == 'set-cookie': 142 | continue 143 | 144 | headers[header] = value 145 | 146 | if hasattr(response, 'headers'): 147 | logger.debug('Response headers: %s', response.headers) 148 | else: 149 | logger.debug('Response headers: %s', 150 | getattr(response, '_headers', None)) 151 | 152 | 153 | def normalize_request_headers(request): 154 | r"""Function used to transform header, replacing 'HTTP\_' to '' 155 | and replace '_' to '-' 156 | 157 | :param request: A HttpRequest that will be transformed 158 | :returns: A dictionary with the normalized headers 159 | """ 160 | norm_headers = {} 161 | for header, value in request.META.items(): 162 | if required_header(header): 163 | norm_header = header.replace('HTTP_', '').title().replace('_', '-') 164 | norm_headers[norm_header] = value 165 | 166 | return norm_headers 167 | 168 | 169 | def encode_items(items): 170 | """Function that encode all elements in the list of items passed as 171 | a parameter 172 | 173 | :param items: A list of tuple 174 | :returns: A list of tuple with all items encoded in 'utf-8' 175 | """ 176 | encoded = [] 177 | for key, values in items: 178 | for value in values: 179 | encoded.append((key.encode('utf-8'), value.encode('utf-8'))) 180 | return encoded 181 | 182 | 183 | logger = logging.getLogger('revproxy.cookies') 184 | 185 | 186 | def cookie_from_string(cookie_string, strict_cookies=False): 187 | """Parser for HTTP header set-cookie 188 | The return from this function will be used as parameters for 189 | django's response.set_cookie method. Because set_cookie doesn't 190 | have parameter comment, this cookie attribute will be ignored. 191 | 192 | :param cookie_string: A string representing a valid cookie 193 | :param strict_cookies: Whether to only accept RFC-compliant cookies 194 | :returns: A dictionary containing the cookie_string attributes 195 | """ 196 | 197 | if strict_cookies: 198 | 199 | cookies = SimpleCookie(COOKIE_PREFIX + cookie_string) 200 | if not cookies.keys(): 201 | return None 202 | cookie_name, = cookies.keys() 203 | cookie_dict = {k: v for k, v in cookies[cookie_name].items() 204 | if v and k != 'comment'} 205 | cookie_dict['key'] = cookie_name 206 | cookie_dict['value'] = cookies[cookie_name].value 207 | return cookie_dict 208 | 209 | else: 210 | valid_attrs = ('path', 'domain', 'comment', 'expires', 211 | 'max-age', 'httponly', 'secure', 'samesite') 212 | 213 | cookie_dict = {} 214 | 215 | cookie_parts = cookie_string.split(';') 216 | try: 217 | key, value = cookie_parts[0].split('=', 1) 218 | cookie_dict['key'], cookie_dict['value'] = key, unquote(value) 219 | except ValueError: 220 | logger.warning('Invalid cookie: `%s`', cookie_string) 221 | return None 222 | 223 | if cookie_dict['value'].startswith('='): 224 | logger.warning('Invalid cookie: `%s`', cookie_string) 225 | return None 226 | 227 | for part in cookie_parts[1:]: 228 | if '=' in part: 229 | attr, value = part.split('=', 1) 230 | value = value.strip() 231 | else: 232 | attr = part 233 | value = '' 234 | 235 | attr = attr.strip().lower() 236 | if not attr: 237 | continue 238 | 239 | if attr in valid_attrs: 240 | if attr in ('httponly', 'secure'): 241 | cookie_dict[attr] = True 242 | elif attr in 'comment': 243 | # ignoring comment attr as explained in the 244 | # function docstring 245 | continue 246 | elif attr == 'max-age': 247 | # The cookie uses 'max-age' but django's 248 | # set_cookie uses 'max_age' 249 | try: 250 | # Cast to Integer as Django's set_cookie() 251 | # expects max_age as int 252 | cookie_dict['max_age'] = int(unquote(value)) 253 | except ValueError: 254 | logger.warning( 255 | 'Invalid max_age attribute value in cookie: `%s`', 256 | cookie_string, 257 | ) 258 | cookie_dict['max_age'] = None 259 | else: 260 | cookie_dict[attr] = unquote(value) 261 | else: 262 | logger.warning('Unknown cookie attribute %s', attr) 263 | 264 | return cookie_dict 265 | 266 | 267 | def unquote(value): 268 | """Remove wrapping quotes from a string. 269 | 270 | :param value: A string that might be wrapped in double quotes, such 271 | as a HTTP cookie value. 272 | :returns: Beginning and ending quotes removed and escaped quotes (``\"``) 273 | unescaped 274 | """ 275 | if len(value) > 1 and value[0] == '"' and value[-1] == '"': 276 | value = value[1:-1].replace(r'\"', '"') 277 | return value 278 | -------------------------------------------------------------------------------- /revproxy/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import sys 6 | import mimetypes 7 | import logging 8 | 9 | import urllib3 10 | 11 | try: 12 | from django.utils.six.moves.urllib.parse import ( 13 | urlparse, urlencode, quote_plus, quote 14 | ) 15 | except ImportError: 16 | # Django 3 has no six 17 | from urllib.parse import ( 18 | urlparse, urlencode, quote_plus, quote 19 | ) 20 | 21 | from django.conf import settings 22 | from django.shortcuts import redirect 23 | from django.views.generic import View 24 | from django.utils.decorators import classonlymethod 25 | from django.views.generic.base import ContextMixin 26 | 27 | from .exceptions import InvalidUpstream 28 | from .response import get_django_response 29 | from .transformer import DiazoTransformer 30 | from .utils import normalize_request_headers, encode_items 31 | 32 | # Chars that don't need to be quoted. We use same than nginx: 33 | # https://github.com/nginx/nginx/blob/nginx-1.9/src/core/ngx_string.c 34 | # (Lines 1433-1449) 35 | QUOTE_SAFE = r'<.;>\(}*+|~=-$/_:^@)[{]&\'!,"`' 36 | 37 | 38 | ERRORS_MESSAGES = { 39 | 'upstream-no-scheme': ("Upstream URL scheme must be either " 40 | "'http' or 'https' (%s).") 41 | } 42 | 43 | HTTP_POOLS = urllib3.PoolManager() 44 | 45 | 46 | class ProxyView(View): 47 | """View responsable by excute proxy requests, process and return 48 | their responses. 49 | 50 | """ 51 | _upstream = None 52 | 53 | add_x_forwarded = False 54 | add_remote_user = False 55 | default_content_type = 'application/octet-stream' 56 | retries = None 57 | rewrite = tuple() # It will be overrided by a tuple inside tuple. 58 | strict_cookies = False 59 | #: Do not send any body if it is empty (put ``None`` into the ``urlopen()`` 60 | #: call). This is required when proxying to Shiny apps, for example. 61 | suppress_empty_body = False 62 | 63 | # The buffering amount for streaming HTTP response(in bytes), response will 64 | # be buffered until it's length exceeds this value. `None` means using 65 | # default value, override this variable to change. 66 | streaming_amount = None 67 | 68 | def __init__(self, *args, **kwargs): 69 | super(ProxyView, self).__init__(*args, **kwargs) 70 | 71 | self._rewrite = [] 72 | # Take all elements inside tuple, and insert into _rewrite 73 | for from_pattern, to_pattern in self.rewrite: 74 | from_re = re.compile(from_pattern) 75 | self._rewrite.append((from_re, to_pattern)) 76 | self.http = HTTP_POOLS 77 | self.log = logging.getLogger('revproxy.view') 78 | self.log.info("ProxyView created") 79 | 80 | @property 81 | def upstream(self): 82 | if not self._upstream: 83 | raise NotImplementedError('Upstream server must be set') 84 | return self._upstream 85 | 86 | @upstream.setter 87 | def upstream(self, value): 88 | self._upstream = value 89 | 90 | def get_upstream(self, path): 91 | upstream = self.upstream 92 | 93 | if not getattr(self, '_parsed_url', None): 94 | self._parsed_url = urlparse(upstream) 95 | 96 | if self._parsed_url.scheme not in ('http', 'https'): 97 | raise InvalidUpstream(ERRORS_MESSAGES['upstream-no-scheme'] % 98 | upstream) 99 | 100 | if path and upstream[-1] != '/': 101 | upstream += '/' 102 | 103 | return upstream 104 | 105 | @classonlymethod 106 | def as_view(cls, **initkwargs): 107 | view = super(ProxyView, cls).as_view(**initkwargs) 108 | view.csrf_exempt = True 109 | return view 110 | 111 | def _format_path_to_redirect(self, request): 112 | full_path = request.get_full_path() 113 | self.log.debug("Dispatch full path: %s", full_path) 114 | for from_re, to_pattern in self._rewrite: 115 | if from_re.match(full_path): 116 | redirect_to = from_re.sub(to_pattern, full_path) 117 | self.log.debug("Redirect to: %s", redirect_to) 118 | return redirect_to 119 | 120 | def get_proxy_request_headers(self, request): 121 | """Get normalized headers for the upstream 122 | 123 | Gets all headers from the original request and normalizes them. 124 | Normalization occurs by removing the prefix ``HTTP_`` and 125 | replacing and ``_`` by ``-``. Example: ``HTTP_ACCEPT_ENCODING`` 126 | becames ``Accept-Encoding``. 127 | 128 | .. versionadded:: 0.9.1 129 | 130 | :param request: The original HTTPRequest instance 131 | :returns: Normalized headers for the upstream 132 | """ 133 | return normalize_request_headers(request) 134 | 135 | def get_request_headers(self): 136 | """Return request headers that will be sent to upstream. 137 | 138 | The header REMOTE_USER is set to the current user 139 | if AuthenticationMiddleware is enabled and 140 | the view's add_remote_user property is True. 141 | 142 | .. versionadded:: 0.9.8 143 | 144 | If the view's add_x_forwarded property is True, the 145 | headers X-Forwarded-For and X-Forwarded-Proto are set to the 146 | IP address of the requestor and the request's protocol (http or https), 147 | respectively. 148 | 149 | .. versionadded:: TODO 150 | 151 | """ 152 | request_headers = self.get_proxy_request_headers(self.request) 153 | 154 | if (self.add_remote_user and hasattr(self.request, 'user') 155 | and self.request.user.is_active): 156 | request_headers['REMOTE_USER'] = self.request.user.get_username() 157 | self.log.info("REMOTE_USER set") 158 | 159 | if self.add_x_forwarded: 160 | request_ip = self.request.META.get('REMOTE_ADDR') 161 | self.log.debug("Proxy request IP: %s", request_ip) 162 | request_headers['X-Forwarded-For'] = request_ip 163 | 164 | request_proto = "https" if self.request.is_secure() else "http" 165 | self.log.debug("Proxy request using %s", request_proto) 166 | request_headers['X-Forwarded-Proto'] = request_proto 167 | 168 | return request_headers 169 | 170 | def get_quoted_path(self, path): 171 | """Return quoted path to be used in proxied request""" 172 | if settings.REVPROXY["QUOTE_SPACES_AS_PLUS"]: 173 | return quote_plus(path.encode('utf8'), QUOTE_SAFE) 174 | else: 175 | return quote(path.encode('utf8'), QUOTE_SAFE) 176 | 177 | def get_encoded_query_params(self): 178 | """Return encoded query params to be used in proxied request""" 179 | get_data = encode_items(self.request.GET.lists()) 180 | return urlencode(get_data) 181 | 182 | def _created_proxy_response(self, request, path): 183 | request_payload = request.body 184 | if self.suppress_empty_body and not request_payload: 185 | request_payload = None 186 | 187 | self.log.debug("Request headers: %s", self.request_headers) 188 | 189 | path = self.get_quoted_path(path) 190 | 191 | request_url = self.get_upstream(path) + path 192 | self.log.debug("Request URL: %s", request_url) 193 | 194 | if request.GET: 195 | request_url += '?' + self.get_encoded_query_params() 196 | self.log.debug("Request URL: %s", request_url) 197 | 198 | try: 199 | proxy_response = self.http.urlopen(request.method, 200 | request_url, 201 | redirect=False, 202 | retries=self.retries, 203 | headers=self.request_headers, 204 | body=request_payload, 205 | decode_content=False, 206 | preload_content=False) 207 | self.log.debug("Proxy response header: %s", 208 | proxy_response.headers) 209 | except urllib3.exceptions.HTTPError as error: 210 | self.log.exception(error) 211 | raise 212 | 213 | return proxy_response 214 | 215 | def _replace_host_on_redirect_location(self, request, proxy_response): 216 | location = proxy_response.headers.get('Location') 217 | if location: 218 | if request.is_secure(): 219 | scheme = 'https://' 220 | else: 221 | scheme = 'http://' 222 | request_host = scheme + request.get_host() 223 | 224 | upstream_host_http = 'http://' + self._parsed_url.netloc 225 | upstream_host_https = 'https://' + self._parsed_url.netloc 226 | 227 | location = location.replace(upstream_host_http, request_host) 228 | location = location.replace(upstream_host_https, request_host) 229 | proxy_response.headers['Location'] = location 230 | self.log.debug("Proxy response LOCATION: %s", 231 | proxy_response.headers['Location']) 232 | 233 | def _set_content_type(self, request, proxy_response): 234 | content_type = proxy_response.headers.get('Content-Type') 235 | if not content_type: 236 | content_type = (mimetypes.guess_type(request.path)[0] or 237 | self.default_content_type) 238 | proxy_response.headers['Content-Type'] = content_type 239 | self.log.debug("Proxy response CONTENT-TYPE: %s", 240 | proxy_response.headers['Content-Type']) 241 | 242 | def dispatch(self, request, path): 243 | self.request_headers = self.get_request_headers() 244 | 245 | redirect_to = self._format_path_to_redirect(request) 246 | if redirect_to: 247 | return redirect(redirect_to) 248 | 249 | proxy_response = self._created_proxy_response(request, path) 250 | 251 | self._replace_host_on_redirect_location(request, proxy_response) 252 | self._set_content_type(request, proxy_response) 253 | 254 | response = get_django_response(proxy_response, 255 | strict_cookies=self.strict_cookies, 256 | streaming_amount=self.streaming_amount) 257 | 258 | self.log.debug("RESPONSE RETURNED: %s", response) 259 | return response 260 | 261 | 262 | class DiazoProxyView(ProxyView, ContextMixin): 263 | _diazo_rules = None 264 | diazo_theme_template = 'diazo.html' 265 | html5 = False 266 | 267 | @property 268 | def diazo_rules(self): 269 | if not self._diazo_rules: 270 | child_class_file = sys.modules[self.__module__].__file__ 271 | app_path = os.path.abspath(os.path.dirname(child_class_file)) 272 | diazo_path = os.path.join(app_path, 'diazo.xml') 273 | 274 | self.log.debug("diazo_rules: %s", diazo_path) 275 | self._diazo_rules = diazo_path 276 | return self._diazo_rules 277 | 278 | @diazo_rules.setter 279 | def diazo_rules(self, value): 280 | self._diazo_rules = value 281 | 282 | def dispatch(self, request, path): 283 | response = super(DiazoProxyView, self).dispatch(request, path) 284 | 285 | context_data = self.get_context_data() 286 | diazo = DiazoTransformer(request, response) 287 | response = diazo.transform(self.diazo_rules, self.diazo_theme_template, 288 | self.html5, context_data) 289 | 290 | return response 291 | -------------------------------------------------------------------------------- /sample_project/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/sample_project/db.sqlite3 -------------------------------------------------------------------------------- /sample_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sample_project.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /sample_project/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .. 2 | -------------------------------------------------------------------------------- /sample_project/sample_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/sample_project/sample_app/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /sample_project/sample_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SampleAppConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'sample_app' 7 | -------------------------------------------------------------------------------- /sample_project/sample_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/sample_project/sample_app/migrations/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /sample_project/sample_app/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /sample_project/sample_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from .views import SampleProxyView 4 | 5 | urlpatterns = [ 6 | re_path(r'(?P.*)', SampleProxyView.as_view()), 7 | ] 8 | -------------------------------------------------------------------------------- /sample_project/sample_app/views.py: -------------------------------------------------------------------------------- 1 | from revproxy.views import ProxyView 2 | 3 | 4 | class SampleProxyView(ProxyView): 5 | upstream = 'https://docs.djangoproject.com' 6 | -------------------------------------------------------------------------------- /sample_project/sample_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/sample_project/sample_project/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for sample_project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sample_project.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /sample_project/sample_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for sample_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = 'django-insecure-bpl4kc5@pt11bi&d6!60k5f861if0ry0-w_2@e!vab+e1)7fi5' 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | 39 | 'revproxy', 40 | 'sample_app', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'sample_project.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'sample_project.wsgi.application' 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | 'default': { 78 | 'ENGINE': 'django.db.backends.sqlite3', 79 | 'NAME': BASE_DIR / 'db.sqlite3', 80 | } 81 | } 82 | 83 | # Password validation 84 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 85 | 86 | AUTH_PASSWORD_VALIDATORS = [ 87 | { 88 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 89 | }, 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 98 | }, 99 | ] 100 | 101 | LOGGING = { 102 | "version": 1, 103 | "disable_existing_loggers": False, 104 | "handlers": { 105 | "console": { 106 | "class": "logging.StreamHandler", 107 | }, 108 | }, 109 | "root": { 110 | "handlers": ["console"], 111 | "level": "INFO", 112 | }, 113 | "loggers": { 114 | "django": { 115 | "handlers": ["console"], 116 | "level": "INFO", 117 | "propagate": False, 118 | }, 119 | }, 120 | } 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 124 | 125 | LANGUAGE_CODE = 'en-us' 126 | 127 | TIME_ZONE = 'UTC' 128 | 129 | USE_I18N = True 130 | 131 | USE_TZ = True 132 | 133 | # Static files (CSS, JavaScript, Images) 134 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 135 | 136 | STATIC_URL = 'static/' 137 | 138 | # Default primary key field type 139 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 140 | 141 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 142 | -------------------------------------------------------------------------------- /sample_project/sample_project/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for sample_project project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | 21 | urlpatterns = [ 22 | path('admin/', admin.site.urls), 23 | path('', include('sample_app.urls')), 24 | ] 25 | -------------------------------------------------------------------------------- /sample_project/sample_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for sample_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sample_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-revproxy/72e1d4d7c9be7e6f0e15a08ad9c4afbe83db76b9/tests/__init__.py -------------------------------------------------------------------------------- /tests/custom_diazo.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/diazo.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 7 | 8 | import django 9 | 10 | from django.test.utils import get_runner 11 | from django.conf import settings 12 | 13 | 14 | def runtests(): 15 | if django.VERSION >= (1, 7, 0): 16 | django.setup() 17 | 18 | test_runner = get_runner(settings) 19 | failures = test_runner(interactive=False, failfast=False).run_tests([]) 20 | sys.exit(failures) 21 | 22 | 23 | if __name__ == '__main__': 24 | runtests() 25 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | SECRET_KEY = 'asdf' 5 | 6 | BASE_DIR = Path(__file__).resolve().parent 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'NAME': 'test.db', 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | } 13 | } 14 | 15 | INSTALLED_APPS = ( 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.staticfiles', 19 | 20 | 'revproxy', 21 | ) 22 | 23 | MIDDLEWARE_CLASSES = ( 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.middleware.common.CommonMiddleware', 26 | 'django.middleware.csrf.CsrfViewMiddleware', 27 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 28 | 'django.contrib.messages.middleware.MessageMiddleware', 29 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 30 | ) 31 | 32 | ROOT_URLCONF = 'tests.urls' 33 | 34 | TEMPLATE_DIRS = ( 35 | os.path.join(BASE_DIR, 'templates'), 36 | ) 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'APP_DIRS': True, 42 | 'DIRS': TEMPLATE_DIRS, 43 | }, 44 | ] 45 | 46 | LOGGING = { 47 | 'version': 1, 48 | 49 | 'handlers': { 50 | 'null': { 51 | 'level': 'DEBUG', 52 | 'class': 'logging.NullHandler', 53 | }, 54 | }, 55 | 56 | 'loggers': { 57 | 'revproxy': { 58 | 'handlers': ['null'], 59 | 'propagate': False, 60 | }, 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /tests/templates/diazo.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/templates/diazo_with_context_data.html: -------------------------------------------------------------------------------- 1 | {{ view.context_data }} 2 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from revproxy import connection 4 | 5 | 6 | class TestOutput(TestCase): 7 | 8 | def setUp(self): 9 | self.connection = connection.HTTPConnectionPool.ConnectionCls('example.com') 10 | 11 | def test_byte_url(self): 12 | """Output strings are always byte strings, even using Python 3""" 13 | mock_output = b'mock output' 14 | connection._output(self.connection, mock_output) 15 | self.assertEqual(self.connection._buffer, [mock_output]) 16 | 17 | def test_host_is_first(self): 18 | """Make sure the host line is second in the request""" 19 | mock_host_output = b'host: example.com' 20 | for output in [b'GET / HTTP/1.1', b'before', mock_host_output, b'after']: 21 | connection._output(self.connection, output) 22 | self.assertEqual(self.connection._buffer[1], mock_host_output) 23 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | 4 | if sys.version_info >= (3, 0, 0): 5 | from urllib.parse import parse_qs 6 | else: 7 | from urlparse import parse_qs 8 | 9 | from django.contrib.auth.models import AnonymousUser, User 10 | from django.test import TestCase, RequestFactory 11 | 12 | from unittest.mock import patch 13 | 14 | from urllib3 import Retry 15 | 16 | from revproxy.views import ProxyView 17 | 18 | from .utils import get_urlopen_mock 19 | 20 | 21 | class RequestTest(TestCase): 22 | 23 | def setUp(self): 24 | # Every test needs access to the request factory. 25 | self.factory = RequestFactory() 26 | self.user = User.objects.create_user( 27 | username='jacob', email='jacob@example.com', password='top_secret') 28 | 29 | urlopen_mock = get_urlopen_mock() 30 | self.urlopen_patcher = patch('urllib3.PoolManager.urlopen', 31 | urlopen_mock) 32 | 33 | self.urlopen = self.urlopen_patcher.start() 34 | 35 | def tearDown(self): 36 | self.urlopen_patcher.stop() 37 | 38 | def test_default_attributes(self): 39 | proxy_view = ProxyView() 40 | self.assertFalse(proxy_view.add_remote_user) 41 | self.assertFalse(proxy_view.add_x_forwarded) 42 | 43 | def factory_custom_proxy_view(self, **kwargs): 44 | class CustomProxyView(ProxyView): 45 | add_remote_user = kwargs.get('add_remote_user', False) 46 | add_x_forwarded = kwargs.get('add_x_forwarded', False) 47 | 48 | url_prefix = 'https' if kwargs.get('https', False) else 'http' 49 | upstream = kwargs.get('upstream', '{}://www.example.com'.format(url_prefix)) 50 | retries = kwargs.get('retries', None) 51 | rewrite = kwargs.get('rewrite', tuple()) 52 | 53 | is_secure = kwargs.get('https', False) 54 | if kwargs.get('method') == 'POST': 55 | request = self.factory.post(kwargs.get('path', ''), 56 | kwargs.get('post_data', {}), secure=is_secure) 57 | elif kwargs.get('method') == 'PUT': 58 | request = self.factory.put('path', kwargs.get('request_data', {}), secure=is_secure) 59 | else: 60 | request = self.factory.get(kwargs.get('path', ''), 61 | kwargs.get('get_data', {}), secure=is_secure) 62 | 63 | if kwargs.get('anonymous'): 64 | request.user = AnonymousUser() 65 | else: 66 | request.user = self.user 67 | 68 | if kwargs.get('headers'): 69 | for key, value in kwargs.get('headers').items(): 70 | request.META[key] = value 71 | 72 | response = CustomProxyView.as_view()(request, kwargs.get('path', '')) 73 | return {'request': request, 'response': response} 74 | 75 | def test_remote_user_authenticated(self): 76 | options = {'add_remote_user': True, 'anonymous': False, 77 | 'path': 'test'} 78 | 79 | self.factory_custom_proxy_view(**options) 80 | url = 'http://www.example.com/test' 81 | headers = {'REMOTE_USER': 'jacob', 'Cookie': ''} 82 | self.urlopen.assert_called_with('GET', url, 83 | redirect=False, 84 | retries=None, 85 | preload_content=False, 86 | decode_content=False, 87 | headers=headers, 88 | body=b'') 89 | 90 | def test_remote_user_anonymous(self): 91 | options = {'add_remote_user': True, 'anonymous': True, 92 | 'path': 'test/anonymous/'} 93 | 94 | self.factory_custom_proxy_view(**options) 95 | url = 'http://www.example.com/test/anonymous/' 96 | headers = {'Cookie': ''} 97 | self.urlopen.assert_called_with('GET', url, redirect=False, 98 | retries=None, 99 | preload_content=False, 100 | decode_content=False, 101 | headers=headers, body=b'') 102 | 103 | def test_add_x_forwarded_true_http(self): 104 | options = {'add_x_forwarded': True, 'path': 'test'} 105 | 106 | self.factory_custom_proxy_view(**options) 107 | url = 'http://www.example.com/test' 108 | headers = {'Cookie': '', 'X-Forwarded-For': '127.0.0.1', 'X-Forwarded-Proto': 'http'} 109 | self.urlopen.assert_called_with('GET', url, redirect=False, 110 | retries=None, 111 | preload_content=False, 112 | decode_content=False, 113 | headers=headers, body=b'') 114 | 115 | def test_add_x_forwarded_true_https(self): 116 | options = {'add_x_forwarded': True, 'path': 'test', 'https': True} 117 | 118 | self.factory_custom_proxy_view(**options) 119 | url = 'https://www.example.com/test' 120 | headers = {'Cookie': '', 'X-Forwarded-For': '127.0.0.1', 'X-Forwarded-Proto': 'https'} 121 | self.urlopen.assert_called_with('GET', url, redirect=False, 122 | retries=None, 123 | preload_content=False, 124 | decode_content=False, 125 | headers=headers, body=b'') 126 | 127 | def test_add_x_forwarded_false(self): 128 | options = {'add_x_forwarded': False, 'path': 'test'} 129 | 130 | self.factory_custom_proxy_view(**options) 131 | url = 'http://www.example.com/test' 132 | headers = {'Cookie': ''} 133 | self.urlopen.assert_called_with('GET', url, redirect=False, 134 | retries=None, 135 | preload_content=False, 136 | decode_content=False, 137 | headers=headers, body=b'') 138 | 139 | def test_custom_retries(self): 140 | RETRIES = Retry(20, backoff_factor=0.1) 141 | options = {'path': 'test/', 'retries': RETRIES} 142 | 143 | self.factory_custom_proxy_view(**options) 144 | url = 'http://www.example.com/test/' 145 | headers = {'Cookie': ''} 146 | self.urlopen.assert_called_with('GET', url, redirect=False, 147 | retries=RETRIES, 148 | preload_content=False, 149 | decode_content=False, 150 | headers=headers, body=b'') 151 | 152 | def test_simple_get(self): 153 | get_data = {'a': ['b'], 'c': ['d'], 'e': ['f']} 154 | options = {'path': 'test/', 'get_data': get_data} 155 | 156 | self.factory_custom_proxy_view(**options) 157 | 158 | assert self.urlopen.called 159 | called_qs = self.urlopen.call_args[0][1].split('?')[-1] 160 | called_get_data = parse_qs(called_qs) 161 | self.assertEqual(called_get_data, get_data) 162 | 163 | def test_get_with_attr_list(self): 164 | get_data = { 165 | u'a': [u'a', u'b', u'c', u'd'], 166 | u'foo': [u'bar'], 167 | } 168 | options = {'path': '/', 'get_data': get_data} 169 | self.factory_custom_proxy_view(**options) 170 | 171 | assert self.urlopen.called 172 | called_qs = self.urlopen.call_args[0][1].split('?')[-1] 173 | 174 | called_get_data = parse_qs(called_qs) 175 | self.assertEqual(called_get_data, get_data) 176 | 177 | def test_post_and_get(self): 178 | get_data = {'x': ['y', 'z']} 179 | post_data = {'a': ['b'], 'c': ['d'], 'e': ['f']} 180 | 181 | options = {'path': '/?x=y&x=z', 'post_data': post_data} 182 | result = self.factory_custom_proxy_view(**options) 183 | 184 | assert self.urlopen.called 185 | called_qs = self.urlopen.call_args[0][1].split('?')[-1] 186 | 187 | # Check for GET data 188 | called_get_data = parse_qs(called_qs) 189 | self.assertEqual(called_get_data, get_data) 190 | 191 | # Check for POST data 192 | self.assertEqual(self.urlopen.call_args[1]['body'], 193 | result.get('request').body) 194 | 195 | def test_put(self): 196 | request_data = {'a': ['b'], 'c': ['d'], 'e': ['f']} 197 | 198 | options = {'path': '/', 'method': 'PUT', 'request_data': request_data} 199 | 200 | result = self.factory_custom_proxy_view(**options) 201 | 202 | assert self.urlopen.called 203 | 204 | # Check for request data 205 | self.assertEqual(self.urlopen.call_args[1]['body'], 206 | result.get('request').body) 207 | 208 | self.assertEqual(self.urlopen.call_args[0][0], 'PUT') 209 | 210 | def test_simple_rewrite(self): 211 | rewrite = ( 212 | (r'^/yellow/star/?$', r'/black/hole/'), 213 | (r'^/foo/$', r'/bar'), 214 | ) 215 | options = {'path': '/yellow/star', 'add_remote_user': False, 216 | 'rewrite': rewrite} 217 | 218 | result = self.factory_custom_proxy_view(**options) 219 | self.assertEqual(result.get('response').url, '/black/hole/') 220 | self.assertEqual(result.get('response').status_code, 302) 221 | 222 | def test_rewrite_with_get(self): 223 | rewrite = ( 224 | (r'^/foo/(.*)$', r'/bar\1'), 225 | ) 226 | get_data = {'a': ['1'], 'b': ['c']} 227 | options = {'path': '/foo/', 'add_remote_user': False, 228 | 'rewrite': rewrite, 'get_data': get_data} 229 | 230 | result = self.factory_custom_proxy_view(**options) 231 | path, querystring = result.get('response').url.split('?') 232 | self.assertEqual(path, '/bar') 233 | self.assertEqual(result.get('response').status_code, 302) 234 | 235 | response_data = parse_qs(querystring) 236 | self.assertEqual(response_data, get_data) 237 | 238 | def test_rewrite_to_external_location(self): 239 | rewrite = ( 240 | (r'^/yellow/star/?$', r'http://www.mozilla.org/'), 241 | ) 242 | options = {'path': '/yellow/star', 'add_remote_user': False, 243 | 'rewrite': rewrite} 244 | 245 | result = self.factory_custom_proxy_view(**options) 246 | self.assertEqual(result.get('response').url, 'http://www.mozilla.org/') 247 | self.assertEqual(result.get('response').status_code, 302) 248 | 249 | def test_rewrite_to_view_name(self): 250 | rewrite = ( 251 | (r'^/yellow/star/$', r'login'), 252 | ) 253 | options = {'path': '/yellow/star/', 'add_remote_user': False, 254 | 'rewrite': rewrite} 255 | 256 | result = self.factory_custom_proxy_view(**options) 257 | self.assertEqual(result.get('response').url, '/accounts/login/') 258 | self.assertEqual(result.get('response').status_code, 302) 259 | 260 | def test_no_rewrite(self): 261 | rewrite = ( 262 | (r'^/yellow/star/$', r'login'), 263 | (r'^/foo/(.*)$', r'/bar\1'), 264 | ) 265 | 266 | options = {'path': 'test/', 'rewrite': rewrite} 267 | 268 | result = self.factory_custom_proxy_view(**options) 269 | url = 'http://www.example.com/test/' 270 | headers = {'Cookie': ''} 271 | self.urlopen.assert_called_with('GET', url, redirect=False, 272 | retries=None, 273 | preload_content=False, 274 | decode_content=False, 275 | headers=headers, body=b'') 276 | 277 | def test_remote_user_injection_anonymous(self): 278 | request_headers = {'HTTP_REMOTE_USER': 'foo'} 279 | options = {'path': 'test', 'anonymous': True, 280 | 'headers': request_headers} 281 | result = self.factory_custom_proxy_view(**options) 282 | 283 | url = 'http://www.example.com/test' 284 | headers = {'Cookie': ''} 285 | self.urlopen.assert_called_with('GET', url, 286 | redirect=False, 287 | retries=None, 288 | preload_content=False, 289 | decode_content=False, 290 | headers=headers, 291 | body=b'') 292 | 293 | def test_remote_user_injection_authenticated(self): 294 | request_headers = {'HTTP_REMOTE_USER': 'foo'} 295 | options = {'path': 'test', 'headers': request_headers} 296 | result = self.factory_custom_proxy_view(**options) 297 | 298 | url = 'http://www.example.com/test' 299 | headers = {'Cookie': ''} 300 | self.urlopen.assert_called_with('GET', url, 301 | redirect=False, 302 | retries=None, 303 | preload_content=False, 304 | decode_content=False, 305 | headers=headers, 306 | body=b'') 307 | 308 | def test_remote_user_injection_authenticated_add_remote_user(self): 309 | request_headers = {'HTTP_REMOTE_USER': 'foo'} 310 | options = {'path': 'test', 'headers': request_headers, 311 | 'add_remote_user': True} 312 | result = self.factory_custom_proxy_view(**options) 313 | 314 | url = 'http://www.example.com/test' 315 | headers = {'Cookie': '', 'REMOTE_USER': 'jacob'} 316 | self.urlopen.assert_called_with('GET', url, 317 | redirect=False, 318 | retries=None, 319 | preload_content=False, 320 | decode_content=False, 321 | headers=headers, 322 | body=b'') 323 | 324 | def test_remote_user_injection_anonymous_add_remote_user(self): 325 | request_headers = {'HTTP_REMOTE_USER': 'foo'} 326 | options = {'path': 'test', 'headers': request_headers, 327 | 'add_remote_user': True, 'anonymous': True} 328 | result = self.factory_custom_proxy_view(**options) 329 | 330 | url = 'http://www.example.com/test' 331 | headers = {'Cookie': ''} 332 | self.urlopen.assert_called_with('GET', url, 333 | redirect=False, 334 | retries=None, 335 | preload_content=False, 336 | decode_content=False, 337 | headers=headers, 338 | body=b'') 339 | 340 | def test_set_custom_headers(self): 341 | request_headers = {'HTTP_DNT': 1} 342 | url = 'http://www.example.com' 343 | 344 | request = self.factory.get('') 345 | for key, value in request_headers.items(): 346 | request.META[key] = value 347 | 348 | class CustomProxyView(ProxyView): 349 | upstream = url 350 | 351 | def get_request_headers(self): 352 | request_headers = super(CustomProxyView, 353 | self).get_request_headers() 354 | request_headers['Host'] = 'foo.bar' 355 | return request_headers 356 | 357 | CustomProxyView.as_view()(request, '') 358 | 359 | headers = {'Dnt': 1, 'Cookie': '', 'Host': 'foo.bar'} 360 | 361 | self.urlopen.assert_called_with('GET', url, 362 | redirect=False, 363 | retries=None, 364 | preload_content=False, 365 | decode_content=False, 366 | headers=headers, 367 | body=b'') 368 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | #! *-* coding: utf8 *-* 2 | 3 | import logging 4 | 5 | import urllib3 6 | 7 | from wsgiref.util import is_hop_by_hop 8 | 9 | from django.test import RequestFactory, TestCase 10 | from unittest.mock import MagicMock, patch 11 | from urllib3.exceptions import HTTPError 12 | 13 | from revproxy.response import get_streaming_amt, DEFAULT_AMT, NO_BUFFERING_AMT, get_django_response 14 | from .utils import (get_urlopen_mock, DEFAULT_BODY_CONTENT, 15 | CustomProxyView, URLOPEN) 16 | 17 | 18 | class ResponseTest(TestCase): 19 | def setUp(self): 20 | self.factory = RequestFactory() 21 | self.log = logging.getLogger('revproxy') 22 | self.log.disabled = True 23 | 24 | def tearDown(self): 25 | CustomProxyView.upstream = "http://www.example.com" 26 | CustomProxyView.diazo_rules = None 27 | self.log.disabled = False 28 | 29 | def test_broken_response(self): 30 | request = self.factory.get('/') 31 | 32 | urlopen_mock = MagicMock(side_effect=HTTPError()) 33 | with patch(URLOPEN, urlopen_mock), self.assertRaises(HTTPError): 34 | CustomProxyView.as_view()(request, '/') 35 | 36 | def test_location_replaces_request_host(self): 37 | headers = {'Location': 'http://www.example.com'} 38 | path = "/path" 39 | request = self.factory.get(path) 40 | 41 | urlopen_mock = get_urlopen_mock(headers=headers) 42 | with patch(URLOPEN, urlopen_mock): 43 | response = CustomProxyView.as_view()(request, path) 44 | 45 | location = "http://" + request.get_host() 46 | self.assertEqual(location, response['Location']) 47 | 48 | def test_location_replaces_secure_request_host(self): 49 | CustomProxyView.upstream = "https://www.example.com" 50 | 51 | headers = {'Location': 'https://www.example.com'} 52 | path = "/path" 53 | request = self.factory.get( 54 | path, 55 | # using kwargs instead of the secure parameter because it 56 | # works only after Django 1.7 57 | **{ 58 | 'wsgi.url_scheme': 'https' # tell factory to use 59 | } # https over http 60 | ) 61 | 62 | urlopen_mock = get_urlopen_mock(headers=headers) 63 | with patch(URLOPEN, urlopen_mock): 64 | response = CustomProxyView.as_view()(request, path) 65 | 66 | location = "https://" + request.get_host() 67 | self.assertEqual(location, response['Location']) 68 | 69 | def test_response_headers_are_not_in_hop_by_hop_headers(self): 70 | path = "/" 71 | request = self.factory.get(path) 72 | headers = { 73 | 'connection': '0', 74 | 'proxy-authorization': 'allow', 75 | 'content-type': 'text/html', 76 | } 77 | 78 | urlopen_mock = get_urlopen_mock(headers=headers) 79 | with patch(URLOPEN, urlopen_mock): 80 | response = CustomProxyView.as_view()(request, path) 81 | 82 | # Django 3.2+ 83 | if hasattr(response, 'headers'): 84 | response_headers = response.headers 85 | else: 86 | response_headers = response._headers 87 | 88 | for header in response_headers: 89 | self.assertFalse(is_hop_by_hop(header)) 90 | 91 | def test_response_code_remains_the_same(self): 92 | path = "/" 93 | request = self.factory.get(path) 94 | status = 300 95 | 96 | urlopen_mock = get_urlopen_mock(status=status) 97 | with patch(URLOPEN, urlopen_mock): 98 | response = CustomProxyView.as_view()(request, path) 99 | 100 | self.assertEqual(response.status_code, status) 101 | 102 | def test_response_content_remains_the_same(self): 103 | path = "/" 104 | request = self.factory.get(path) 105 | status = 300 106 | 107 | headers = {'Content-Type': 'text/html'} 108 | urlopen_mock = get_urlopen_mock(DEFAULT_BODY_CONTENT, headers, status) 109 | with patch(URLOPEN, urlopen_mock): 110 | response = CustomProxyView.as_view()(request, path) 111 | 112 | # had to prefix it with 'b' because Python 3 treats str and byte 113 | # differently 114 | self.assertEqual(DEFAULT_BODY_CONTENT, response.content) 115 | 116 | def test_cookie_is_not_in_response_headers(self): 117 | path = "/" 118 | request = self.factory.get(path) 119 | headers = { 120 | 'connection': '0', 121 | 'proxy-authorization': 'allow', 122 | 'content-type': 'text/html', 123 | 'set-cookie': '_cookie=9as8sd32fg48gh2j4k7o3;path=/' 124 | } 125 | 126 | urlopen_mock = get_urlopen_mock(headers=headers) 127 | with patch(URLOPEN, urlopen_mock): 128 | response = CustomProxyView.as_view()(request, path) 129 | 130 | # Django 3.2+ 131 | if hasattr(response, 'headers'): 132 | response_headers = response.headers 133 | else: 134 | response_headers = response._headers 135 | self.assertNotIn('set-cookie', response_headers) 136 | 137 | def test_set_cookie_is_used_by_httpproxy_response(self): 138 | path = "/" 139 | request = self.factory.get(path) 140 | headers = urllib3.response.HTTPHeaderDict({ 141 | 'connection': '0', 142 | 'proxy-authorization': 'allow', 143 | 'content-type': 'text/html' 144 | }) 145 | headers.add('set-cookie', '_cookie1=l4hs3kdf2jsh2324') 146 | headers.add('set-cookie', '_cookie2=l2lk5sj3df22sk3j4') 147 | 148 | urlopen_mock = get_urlopen_mock(headers=headers) 149 | with patch(URLOPEN, urlopen_mock): 150 | response = CustomProxyView.as_view()(request, path) 151 | 152 | self.assertIn("_cookie1", response.cookies.keys()) 153 | self.assertIn("_cookie2", response.cookies.keys()) 154 | 155 | def test_invalid_cookie(self): 156 | path = "/" 157 | request = self.factory.get(path) 158 | headers = { 159 | 'connection': '0', 160 | 'proxy-authorization': 'allow', 161 | 'content-type': 'text/html', 162 | 'set-cookie': 'invalid-cookie', 163 | } 164 | 165 | urlopen_mock = get_urlopen_mock(headers=headers) 166 | with patch(URLOPEN, urlopen_mock): 167 | response = CustomProxyView.as_view()(request, path) 168 | 169 | # Django 3.2+ 170 | if hasattr(response, 'headers'): 171 | response_headers = response.headers 172 | else: 173 | response_headers = response._headers 174 | self.assertFalse(response.cookies) 175 | 176 | 177 | class TestGetDjangoResponseStreamed(TestCase): 178 | 179 | def test_multiple_conditions(self): 180 | optional_amt = 42 181 | cases = [ 182 | ('text/event-stream', None, NO_BUFFERING_AMT), 183 | ('image/jpeg', None, DEFAULT_AMT), 184 | ('image/jpeg', optional_amt, optional_amt), 185 | ] 186 | for content_type, optional_amt, expected_amt in cases: 187 | # Provide no "Content-Length" to trigger response streaming 188 | resp = urllib3.response.HTTPResponse(body=b'', headers={'Content-Type': content_type}, status=200) 189 | with patch.object(resp, 'stream') as stream_mocker: 190 | get_django_response(resp, streaming_amount=optional_amt) 191 | self.assertTrue(stream_mocker.called) 192 | self.assertEqual(stream_mocker.call_args[0][0], expected_amt) 193 | 194 | 195 | class TestGetStreamingAmt(TestCase): 196 | 197 | def test_normal(self): 198 | resp = urllib3.response.HTTPResponse() 199 | self.assertEqual(get_streaming_amt(resp), DEFAULT_AMT) 200 | 201 | def test_event_stream(self): 202 | resp = urllib3.response.HTTPResponse(headers={'Content-Type': 'text/event-stream'}) 203 | self.assertEqual(get_streaming_amt(resp), NO_BUFFERING_AMT) 204 | -------------------------------------------------------------------------------- /tests/test_transformer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from sys import version_info 4 | 5 | from unittest.mock import patch, MagicMock, PropertyMock 6 | 7 | from django.test import RequestFactory, TestCase 8 | 9 | from revproxy.views import DiazoProxyView 10 | 11 | from revproxy.transformer import asbool 12 | 13 | from .utils import get_urlopen_mock, DEFAULT_BODY_CONTENT, MockFile, URLOPEN 14 | 15 | 16 | CONTENT = """`1234567890-=qwertyuiop[]\\asdfghjkl;'zxcvbnm,./ 17 | ˜!@#$%ˆ&*()_+QWTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>? 18 | `¡™£¢∞§¶•ªº–≠œ∑´®†\“‘«åß∂ƒ©˙∆˚¬…æΩ≈ç√∫˜µ≤≥÷ 19 | áéíóúÁÉÍÓÚàèìòùÀÈÌÒÙäëïöüÄËÏÖÜãõÃÕçÇ""" 20 | 21 | if version_info >= (3, 0, 0): 22 | FILE_CONTENT = bytes(CONTENT, 'utf-8') 23 | else: 24 | FILE_CONTENT = CONTENT 25 | 26 | 27 | class CustomProxyView(DiazoProxyView): 28 | upstream = "http://www.example.com" 29 | 30 | 31 | class TransformerTest(TestCase): 32 | def setUp(self): 33 | self.factory = RequestFactory() 34 | 35 | def tearDown(self): 36 | CustomProxyView.upstream = "http://www.example.com" 37 | 38 | def test_no_diazo(self): 39 | request = self.factory.get('/') 40 | headers = {'Content-Type': 'text/html'} 41 | urlopen_mock = get_urlopen_mock(headers=headers) 42 | 43 | with patch(URLOPEN, urlopen_mock): 44 | with patch('revproxy.transformer.HAS_DIAZO', False): 45 | response = CustomProxyView.as_view()(request, '/') 46 | 47 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 48 | 49 | def test_disable_request_header(self): 50 | request = self.factory.get('/', HTTP_X_DIAZO_OFF='true') 51 | headers = {'Content-Type': 'text/html'} 52 | urlopen_mock = get_urlopen_mock(headers=headers) 53 | 54 | with patch(URLOPEN, urlopen_mock): 55 | response = CustomProxyView.as_view()(request, '/') 56 | 57 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 58 | 59 | def test_disable_response_header(self): 60 | request = self.factory.get('/') 61 | headers = {'Content-Type': 'text/html', 'X-Diazo-Off': 'true'} 62 | urlopen_mock = get_urlopen_mock(headers=headers) 63 | 64 | with patch(URLOPEN, urlopen_mock): 65 | response = CustomProxyView.as_view()(request, '/') 66 | 67 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 68 | 69 | def test_x_diazo_off_false_on_response(self): 70 | request = self.factory.get('/') 71 | headers = {'Content-Type': 'text/html', 'X-Diazo-Off': 'false'} 72 | urlopen_mock = get_urlopen_mock(headers=headers) 73 | 74 | with patch(URLOPEN, urlopen_mock): 75 | response = CustomProxyView.as_view()(request, '/') 76 | 77 | self.assertNotIn(response.content, DEFAULT_BODY_CONTENT) 78 | 79 | def test_x_diazo_off_invalid(self): 80 | request = self.factory.get('/') 81 | headers = {'Content-Type': 'text/html', 'X-Diazo-Off': 'nopz'} 82 | urlopen_mock = get_urlopen_mock(headers=headers) 83 | 84 | with patch(URLOPEN, urlopen_mock), self.assertRaises(ValueError): 85 | CustomProxyView.as_view()(request, '/') 86 | 87 | def test_ajax_request(self): 88 | request = self.factory.get('/', HTTP_X_REQUESTED_WITH='XMLHttpRequest') 89 | headers = {'Content-Type': 'text/html'} 90 | 91 | urlopen_mock = get_urlopen_mock(headers=headers) 92 | with patch(URLOPEN, urlopen_mock): 93 | response = CustomProxyView.as_view()(request, '/') 94 | 95 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 96 | 97 | def test_response_streaming(self): 98 | request = self.factory.get('/') 99 | urlopen_mock = get_urlopen_mock() 100 | 101 | with patch(URLOPEN, urlopen_mock): 102 | response = CustomProxyView.as_view()(request, '/') 103 | 104 | content = b''.join(response.streaming_content) 105 | self.assertEqual(content, DEFAULT_BODY_CONTENT) 106 | 107 | def test_response_reading_of_file_stream(self): 108 | request = self.factory.get('/') 109 | 110 | test_file = MockFile(FILE_CONTENT, 4) 111 | mock_file = MagicMock() 112 | type(mock_file).encoding = PropertyMock(return_value='utf-8') 113 | type(mock_file).isclosed = MagicMock(side_effect=test_file.closed) 114 | type(mock_file).closed = PropertyMock(side_effect=test_file.closed) 115 | mock_file.read.side_effect = test_file.read 116 | mock_file.close.side_effect = test_file.close 117 | mock_file.seek.side_effect = test_file.seek 118 | 119 | urlopen_mock = get_urlopen_mock(mock_file) 120 | 121 | with patch(URLOPEN, urlopen_mock): 122 | response = CustomProxyView.as_view()(request, '/') 123 | 124 | content = b''.join(response.streaming_content) 125 | 126 | self.assertEqual(FILE_CONTENT, content) 127 | 128 | def test_num_reads_by_stream_on_a_file(self): 129 | request = self.factory.get('/') 130 | 131 | # number of reads done by the stream. 132 | # it must be said that the stream reads the file one last time before 133 | # closing the file.For further information look at the file named 134 | # response.py on library urlib3, method read. 135 | NUM_READS = 69 136 | 137 | test_file = MockFile(FILE_CONTENT) 138 | 139 | mock_file = MagicMock() 140 | type(mock_file).encoding = PropertyMock(return_value='utf-8') 141 | type(mock_file).isclosed = MagicMock(side_effect=test_file.closed) 142 | type(mock_file).closed = PropertyMock(side_effect=test_file.closed) 143 | mock_file.read.side_effect = test_file.read 144 | mock_file.close.side_effect = test_file.close 145 | mock_file.seek.side_effect = test_file.seek 146 | 147 | urlopen_mock = get_urlopen_mock(mock_file) 148 | 149 | with patch(URLOPEN, urlopen_mock): 150 | response = CustomProxyView.as_view()(request, '/') 151 | 152 | content = b''.join(response.streaming_content) 153 | self.assertEqual(mock_file.read.call_count, NUM_READS) 154 | self.assertEqual(FILE_CONTENT, content) 155 | 156 | def test_no_content_type(self): 157 | request = self.factory.get('/') 158 | headers = {'Content-Length': '1'} 159 | 160 | urlopen_mock = get_urlopen_mock(headers=headers) 161 | with patch(URLOPEN, urlopen_mock): 162 | response = CustomProxyView.as_view()(request, '/') 163 | 164 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 165 | 166 | def test_unsupported_content_type(self): 167 | request = self.factory.get('/') 168 | headers = {'Content-Type': 'application/pdf', 169 | 'Content-Length': '1'} 170 | 171 | urlopen_mock = get_urlopen_mock(headers=headers) 172 | with patch(URLOPEN, urlopen_mock): 173 | response = CustomProxyView.as_view()(request, '/') 174 | 175 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 176 | 177 | def test_unsupported_content_encoding_zip(self): 178 | request = self.factory.get('/') 179 | headers = { 180 | 'Content-Encoding': 'zip', 181 | 'Content-Type': 'text/html', 182 | } 183 | 184 | urlopen_mock = get_urlopen_mock(headers=headers) 185 | with patch(URLOPEN, urlopen_mock): 186 | response = CustomProxyView.as_view()(request, '/') 187 | 188 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 189 | 190 | def test_unsupported_content_encoding_compress(self): 191 | request = self.factory.get('/') 192 | headers = { 193 | 'Content-Encoding': 'compress', 194 | 'Content-Type': 'text/html', 195 | } 196 | 197 | urlopen_mock = get_urlopen_mock(headers=headers) 198 | with patch(URLOPEN, urlopen_mock): 199 | response = CustomProxyView.as_view()(request, '/') 200 | 201 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 202 | 203 | def test_server_redirection_status(self): 204 | request = self.factory.get('/') 205 | headers = {'Content-Type': 'text/html'} 206 | 207 | urlopen_mock = get_urlopen_mock(headers=headers, status=301) 208 | with patch(URLOPEN, urlopen_mock): 209 | response = CustomProxyView.as_view()(request, '/') 210 | 211 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 212 | 213 | def test_no_content_status(self): 214 | request = self.factory.get('/') 215 | headers = {'Content-Type': 'text/html'} 216 | 217 | urlopen_mock = get_urlopen_mock(headers=headers, status=204) 218 | with patch(URLOPEN, urlopen_mock): 219 | response = CustomProxyView.as_view()(request, '/') 220 | 221 | self.assertEqual(response.content, DEFAULT_BODY_CONTENT) 222 | 223 | def test_response_length_zero(self): 224 | request = self.factory.get('/') 225 | headers = {'Content-Type': 'text/html'} 226 | 227 | urlopen_mock = get_urlopen_mock(u''.encode('utf-8'), headers, 200) 228 | with patch(URLOPEN, urlopen_mock): 229 | response = CustomProxyView.as_view()(request, '/') 230 | 231 | self.assertEqual(response.content, b'') 232 | 233 | def test_transform(self): 234 | request = self.factory.get('/') 235 | content = u'
testing
'.encode('utf-8') 236 | headers = {'Content-Type': 'text/html'} 237 | 238 | urlopen_mock = get_urlopen_mock(content, headers) 239 | with patch(URLOPEN, urlopen_mock): 240 | response = CustomProxyView.as_view( 241 | diazo_theme_template='diazo.html' 242 | )(request, '/') 243 | 244 | self.assertNotIn(content, response.content) 245 | 246 | def test_html5_transform(self): 247 | request = self.factory.get('/') 248 | content = u'test'.encode('utf-8') 249 | headers = {'Content-Type': 'text/html'} 250 | 251 | urlopen_mock = get_urlopen_mock(content, headers) 252 | with patch(URLOPEN, urlopen_mock): 253 | response = CustomProxyView.as_view(html5=True)(request, '/') 254 | 255 | self.assertIn(b'', response.content) 256 | 257 | def test_transform_with_context_data(self): 258 | class ContextDataView(CustomProxyView): 259 | context_data = 'random data' 260 | diazo_theme_template = 'diazo_with_context_data.html' 261 | 262 | request = self.factory.get('/') 263 | content = u'test'.encode('utf-8') 264 | headers = {'Content-Type': 'text/html'} 265 | 266 | urlopen_mock = get_urlopen_mock(content, headers) 267 | with patch(URLOPEN, urlopen_mock): 268 | response = ContextDataView.as_view(html5=True)(request, '/') 269 | 270 | self.assertIn(b'random data', response.content) 271 | 272 | def test_transform_with_wrong_charset(self): 273 | request = self.factory.get('/') 274 | content = u'átest'.encode('utf-8') 275 | headers = {'Content-Type': 'text/html; charset=ascii'} 276 | 277 | urlopen_mock = get_urlopen_mock(content, headers) 278 | with patch(URLOPEN, urlopen_mock): 279 | view = CustomProxyView.as_view(diazo_rules='tests/custom_diazo.xml') 280 | response = view(request, '/') 281 | 282 | self.assertIn(b'test', response.content) 283 | self.assertNotIn(b'\xc3\xa1', response.content) 284 | 285 | def test_asbool(self): 286 | test_true = ['true', 'yes', 'on', 'y', 't', '1'] 287 | for element in test_true: 288 | self.assertEqual(True, asbool(element)) 289 | 290 | test_false = ['false', 'no', 'off', 'n', 'f', '0'] 291 | for element in test_false: 292 | self.assertEqual(False, asbool(element)) 293 | 294 | self.assertEqual(True, asbool(1)) 295 | self.assertEqual(False, asbool(0)) 296 | with self.assertRaises(ValueError): 297 | asbool('test') 298 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | 2 | from django.test import TestCase 3 | 4 | from unittest.mock import PropertyMock, MagicMock 5 | 6 | from revproxy import utils 7 | 8 | from .utils import get_urlopen_mock, CustomProxyView 9 | 10 | from django.test import RequestFactory 11 | 12 | 13 | class UtilsTest(TestCase): 14 | def test_get_charset(self): 15 | content_type = 'text/html; charset=utf-8' 16 | charset = utils.get_charset(content_type) 17 | self.assertEqual(charset, 'utf-8') 18 | 19 | def test_get_default_charset(self): 20 | content_type = '' 21 | charset = utils.get_charset(content_type) 22 | self.assertEqual('latin-1', charset) 23 | 24 | def test_required_header(self): 25 | self.assertTrue(utils.required_header('HTTP_USER_AGENT')) 26 | self.assertTrue(utils.required_header('HTTP_ANY_THING_AFTER_HTTP')) 27 | 28 | def test_ignore_host_header(self): 29 | self.assertFalse(utils.required_header('HTTP_HOST')) 30 | self.assertFalse(utils.required_header('HTTP_REMOTE_USER')) 31 | self.assertFalse(utils.required_header('HTTP_Remote-User')) 32 | self.assertFalse(utils.required_header('HTTP-HTTP-Remote-User')) 33 | self.assertFalse(utils.required_header('HTTP-Remote_User')) 34 | self.assertFalse(utils.required_header('Http-Remote-User')) 35 | self.assertFalse(utils.required_header('HTTP_ACCEPT_ENCODING')) 36 | self.assertFalse(utils.required_header('WRONG_HEADER')) 37 | 38 | def test_normalize_request_headers(self): 39 | request = MagicMock( 40 | META={ 41 | 'HTTP_ANY_THING_AFTER_HTTP': 'value', 42 | 'HTTP-HTTP-Remote-User': 'username', 43 | 'HTTP_REMOTE_USER': 'username', 44 | 'Http-Remote-User': 'username', 45 | 'HTTP_USER_AGENT': 'useragent', 46 | }, 47 | ) 48 | normalized_headers = utils.normalize_request_headers(request) 49 | self.assertEqual( 50 | normalized_headers, 51 | {'Any-Thing-After-Http': 'value', 'User-Agent': 'useragent'}, 52 | ) 53 | 54 | def test_ignore_accept_encoding_header(self): 55 | self.assertFalse(utils.required_header('HTTP_ACCEPT_ENCODING')) 56 | 57 | def test_is_html_content_type(self): 58 | self.assertEqual(True, utils.is_html_content_type("text/html")) 59 | self.assertEqual(True, 60 | utils.is_html_content_type('application/xhtml+xml')) 61 | 62 | def test_is_not_html_content_type(self): 63 | self.assertEqual(False, utils.is_html_content_type("html/text")) 64 | self.assertEqual(False, 65 | utils.is_html_content_type('application/pdf')) 66 | 67 | def test_not_should_stream(self): 68 | SMALLER_THAN_MIN_STREAMING_LENGTH = '5' 69 | headers = {'Content-Type': 'text/html', 70 | 'Content-Length': SMALLER_THAN_MIN_STREAMING_LENGTH} 71 | 72 | urlopen_mock = get_urlopen_mock(headers=headers) 73 | 74 | type(urlopen_mock).headers = PropertyMock(return_value=headers) 75 | self.assertEqual(False, utils.should_stream(urlopen_mock)) 76 | 77 | headers['Content-Type'] = 'application/pdf' 78 | type(urlopen_mock).headers = PropertyMock(return_value=headers) 79 | self.assertEqual(False, utils.should_stream(urlopen_mock)) 80 | 81 | def test_should_be_stream(self): 82 | BIGGER_THAN_MIN_STREAMING_LENGTH = '5120' 83 | headers = {'Content-Type': 'application/pdf', 84 | 'Content-Length': 'asad'} 85 | 86 | urlopen_mock = get_urlopen_mock(headers=headers) 87 | 88 | type(urlopen_mock).headers = PropertyMock(return_value=headers) 89 | self.assertEqual(True, utils.should_stream(urlopen_mock)) 90 | 91 | headers['Content-Length'] = BIGGER_THAN_MIN_STREAMING_LENGTH 92 | type(urlopen_mock).headers = PropertyMock(return_value=headers) 93 | self.assertEqual(True, utils.should_stream(urlopen_mock)) 94 | 95 | def test_strict_cookies(self): 96 | valid_cookie = '_cookie_session="1234c12d4p=312341243";' \ 97 | 'expires=Thu, 29 Jan 2015 13:51:41 GMT; httponly;' \ 98 | 'secure;Path=/gitlab' 99 | self.assertDictContainsSubset( 100 | { 101 | 'expires': 'Thu, 29 Jan 2015 13:51:41 GMT', 102 | 'value': '1234c12d4p=312341243', 103 | }, 104 | utils.cookie_from_string(valid_cookie, strict_cookies=True), 105 | ) 106 | 107 | invalid_cookie = "_cookie_session:xyz" 108 | self.assertIsNone(utils.cookie_from_string(invalid_cookie, 109 | strict_cookies=True)) 110 | 111 | def test_quoted_value_cookies(self): 112 | valid_cookie = '_cookie_session="1234c12d4p=312341243";' \ 113 | 'expires=Thu, 29 Jan 2015 13:51:41 GMT; httponly;' \ 114 | 'secure;Path="/gitlab"' 115 | self.assertDictContainsSubset( 116 | { 117 | 'expires': 'Thu, 29 Jan 2015 13:51:41 GMT', 118 | 'value': '1234c12d4p=312341243', 119 | 'path': '/gitlab', 120 | }, 121 | utils.cookie_from_string(valid_cookie), 122 | ) 123 | 124 | def test_get_dict_in_cookie_from_string(self): 125 | cookie = "_cookie_session = 1266bb13c139cfba3ed1c9c68110bae9;" \ 126 | "expires=Thu, 29 Jan 2015 13:51:41 -0000; httponly;" \ 127 | "Path=/gitlab" 128 | 129 | my_dict = utils.cookie_from_string(cookie) 130 | self.assertIs(type(my_dict), dict) 131 | 132 | def test_valid_attr_in_cookie_from_string(self): 133 | cookie = "_cookie_session=1266bb13c139cfba3ed1c9c68110bae9;" \ 134 | "expires=Thu, 29 Jan 2015 13:51:41 -0000; httponly;" \ 135 | "secure;Path=/gitlab;max-age=60;samesite=lax" 136 | 137 | self.assertIn('path', utils.cookie_from_string(cookie)) 138 | self.assertIn('/', utils.cookie_from_string(cookie)['path']) 139 | 140 | self.assertIn('expires', utils.cookie_from_string(cookie)) 141 | self.assertIn('Thu, 29 Jan 2015 13:51:41 -0000', 142 | utils.cookie_from_string(cookie)['expires']) 143 | 144 | self.assertIn('httponly', utils.cookie_from_string(cookie)) 145 | self.assertTrue(utils.cookie_from_string(cookie)['httponly']) 146 | 147 | self.assertIn('secure', utils.cookie_from_string(cookie)) 148 | self.assertTrue(utils.cookie_from_string(cookie)['secure']) 149 | 150 | self.assertIn('samesite', utils.cookie_from_string(cookie)) 151 | self.assertIn('lax', utils.cookie_from_string(cookie)['samesite']) 152 | 153 | self.assertIn('max_age', utils.cookie_from_string(cookie)) 154 | self.assertEqual(60, utils.cookie_from_string(cookie)['max_age']) 155 | 156 | self.assertIn('value', utils.cookie_from_string(cookie)) 157 | self.assertIn('1266bb13c139cfba3ed1c9c68110bae9', 158 | utils.cookie_from_string(cookie)['value']) 159 | 160 | self.assertIn('key', utils.cookie_from_string(cookie)) 161 | self.assertIn('_cookie_session', 162 | utils.cookie_from_string(cookie)['key']) 163 | 164 | def test_valid_attr_in_cookie_from_string_none_max_age(self): 165 | cookie = "_cookie_session=1266bb13c139cfba3ed1c9c68110bae9;"\ 166 | "expires=Thu, 29 Jan 2015 13:51:41 -0000; httponly;"\ 167 | "secure;Path=/gitlab;max-age=null;samesite=lax" 168 | 169 | self.assertIn('path', utils.cookie_from_string(cookie)) 170 | self.assertIn('/', utils.cookie_from_string(cookie)['path']) 171 | 172 | self.assertIn('expires', utils.cookie_from_string(cookie)) 173 | self.assertIn('Thu, 29 Jan 2015 13:51:41 -0000', 174 | utils.cookie_from_string(cookie)['expires']) 175 | 176 | self.assertIn('httponly', utils.cookie_from_string(cookie)) 177 | self.assertTrue(utils.cookie_from_string(cookie)['httponly']) 178 | 179 | self.assertIn('secure', utils.cookie_from_string(cookie)) 180 | self.assertTrue(utils.cookie_from_string(cookie)['secure']) 181 | 182 | self.assertIn('samesite', utils.cookie_from_string(cookie)) 183 | self.assertIn('lax', utils.cookie_from_string(cookie)['samesite']) 184 | 185 | self.assertIn('max_age', utils.cookie_from_string(cookie)) 186 | self.assertEqual(None, utils.cookie_from_string(cookie)['max_age']) 187 | 188 | self.assertIn('value', utils.cookie_from_string(cookie)) 189 | self.assertIn('1266bb13c139cfba3ed1c9c68110bae9', 190 | utils.cookie_from_string(cookie)['value']) 191 | 192 | self.assertIn('key', utils.cookie_from_string(cookie)) 193 | self.assertIn('_cookie_session', 194 | utils.cookie_from_string(cookie)['key']) 195 | 196 | def test_None_value_cookie_from_string(self): 197 | cookie = "_cookie_session=" 198 | self.assertIn('_cookie_session', 199 | utils.cookie_from_string(cookie)['key']) 200 | self.assertIn('', 201 | utils.cookie_from_string(cookie)['value']) 202 | 203 | def test_invalid_cookie_from_string(self): 204 | cookie = "_cookie_session1234c12d4p312341243" 205 | self.assertIsNone(utils.cookie_from_string(cookie)) 206 | 207 | cookie = "_cookie_session==1234c12d4p312341243" 208 | self.assertIsNone(utils.cookie_from_string(cookie)) 209 | 210 | cookie = "_cookie_session:123s234c1234d12" 211 | self.assertIsNone(utils.cookie_from_string(cookie)) 212 | 213 | def test_invalid_attr_cookie_from_string(self): 214 | cookie = "_cookie=2j3d4k35f466l7fj9;path=/;None;" 215 | 216 | self.assertNotIn('None', utils.cookie_from_string(cookie)) 217 | 218 | self.assertIn('value', utils.cookie_from_string(cookie)) 219 | self.assertIn('2j3d4k35f466l7fj9', 220 | utils.cookie_from_string(cookie)['value']) 221 | 222 | self.assertIn('key', utils.cookie_from_string(cookie)) 223 | self.assertIn('_cookie', 224 | utils.cookie_from_string(cookie)['key']) 225 | 226 | self.assertIn('path', utils.cookie_from_string(cookie)) 227 | self.assertIn('/', utils.cookie_from_string(cookie)['path']) 228 | 229 | def test_ignore_comment_cookie_from_string(self): 230 | cookie = "_cookie=k2j3l;path=/;comment=this is a new comment;secure" 231 | self.assertNotIn('comment', utils.cookie_from_string(cookie)) 232 | 233 | def test_value_exeption_cookie_from_string(self): 234 | cookie = "_cookie=k2j3l;path=/,comment=teste;httponly" 235 | self.assertIsNotNone(utils.cookie_from_string(cookie)) 236 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | from django.conf import settings 5 | from django.test import TestCase, RequestFactory, override_settings 6 | try: 7 | from django.utils.six.moves.urllib.parse import ParseResult 8 | except ImportError: 9 | # Django 3 has no six 10 | from urllib.parse import ParseResult 11 | 12 | from revproxy.exceptions import InvalidUpstream 13 | from revproxy.views import ProxyView, DiazoProxyView 14 | 15 | from .utils import get_urlopen_mock 16 | 17 | 18 | class ViewTest(TestCase): 19 | 20 | def setUp(self): 21 | self.factory = RequestFactory() 22 | urlopen_mock = get_urlopen_mock() 23 | self.urlopen_patcher = patch('urllib3.PoolManager.urlopen', 24 | urlopen_mock) 25 | self.urlopen = self.urlopen_patcher.start() 26 | 27 | def test_connection_pool_singleton(self): 28 | view1 = ProxyView() 29 | view2 = ProxyView() 30 | self.assertIs(view1.http, view2.http) 31 | 32 | def test_url_injection(self): 33 | path = 'http://example.org' 34 | request = self.factory.get(path) 35 | 36 | view = ProxyView.as_view(upstream='http://example.com/', 37 | strict_cookies=True) 38 | view(request, path) 39 | 40 | headers = {u'Cookie': u''} 41 | url = 'http://example.com/http://example.org' 42 | 43 | self.urlopen.assert_called_with('GET', url, 44 | body=b'', 45 | redirect=False, 46 | retries=None, 47 | preload_content=False, 48 | decode_content=False, 49 | headers=headers) 50 | 51 | @patch('revproxy.transformer.DiazoTransformer.transform') 52 | def test_set_diazo_as_argument(self, transform): 53 | url = 'http://example.com/' 54 | rules = '/tmp/diazo.xml' 55 | path = '/' 56 | 57 | class CustomProxyView(DiazoProxyView): 58 | upstream = url 59 | 60 | view = CustomProxyView.as_view(diazo_rules='/tmp/diazo.xml') 61 | request = self.factory.get(path) 62 | view(request, path) 63 | 64 | self.assertEqual(transform.call_args[0][0], rules) 65 | 66 | def test_set_upstream_as_argument(self): 67 | url = 'http://example.com/' 68 | view = ProxyView.as_view(upstream=url) 69 | 70 | request = self.factory.get('') 71 | response = view(request, '') 72 | 73 | headers = {u'Cookie': u''} 74 | self.urlopen.assert_called_with('GET', url, 75 | body=b'', 76 | redirect=False, 77 | retries=None, 78 | preload_content=False, 79 | decode_content=False, 80 | headers=headers) 81 | 82 | def test_upstream_not_implemented(self): 83 | proxy_view = ProxyView() 84 | with self.assertRaises(NotImplementedError): 85 | upstream = proxy_view.upstream 86 | 87 | def test_upstream_parsed_url_cache(self): 88 | class CustomProxyView(ProxyView): 89 | upstream = 'http://www.example.com' 90 | 91 | proxy_view = CustomProxyView() 92 | with self.assertRaises(AttributeError): 93 | proxy_view._parsed_url 94 | 95 | # Test for parsed URL 96 | proxy_view.get_upstream('') 97 | self.assertIsInstance(proxy_view._parsed_url, ParseResult) 98 | # Get parsed URL from cache 99 | proxy_view.get_upstream('') 100 | self.assertIsInstance(proxy_view._parsed_url, ParseResult) 101 | 102 | def test_upstream_without_scheme(self): 103 | class BrokenProxyView(ProxyView): 104 | upstream = 'www.example.com' 105 | 106 | proxy_view = BrokenProxyView() 107 | with self.assertRaises(InvalidUpstream): 108 | proxy_view.get_upstream('') 109 | 110 | def test_upstream_overriden(self): 111 | class CustomProxyView(ProxyView): 112 | upstream = 'http://www.google.com/' 113 | 114 | proxy_view = CustomProxyView() 115 | self.assertEqual(proxy_view.upstream, 'http://www.google.com/') 116 | 117 | def test_upstream_without_trailing_slash(self): 118 | class CustomProxyView(ProxyView): 119 | upstream = 'http://example.com/area' 120 | 121 | request = self.factory.get('login') 122 | CustomProxyView.as_view()(request, 'login') 123 | 124 | headers = {u'Cookie': u''} 125 | self.urlopen.assert_called_with('GET', 'http://example.com/area/login', 126 | body=b'', 127 | redirect=False, 128 | retries=None, 129 | preload_content=False, 130 | decode_content=False, 131 | headers=headers) 132 | 133 | def test_default_diazo_rules(self): 134 | class CustomProxyView(DiazoProxyView): 135 | pass 136 | 137 | proxy_view = CustomProxyView() 138 | 139 | correct_path = os.path.join(settings.BASE_DIR, 'diazo.xml') 140 | self.assertEqual(proxy_view.diazo_rules, correct_path) 141 | 142 | def test_diazo_rules_overriden(self): 143 | class CustomProxyView(DiazoProxyView): 144 | diazo_rules = '/tmp/diazo.xml' 145 | 146 | proxy_view = CustomProxyView() 147 | self.assertEqual(proxy_view.diazo_rules, '/tmp/diazo.xml') 148 | 149 | def test_default_diazo_theme_template(self): 150 | proxy_view = DiazoProxyView() 151 | self.assertEqual(proxy_view.diazo_theme_template, 'diazo.html') 152 | 153 | def test_default_html_attr(self): 154 | proxy_view = DiazoProxyView() 155 | self.assertFalse(proxy_view.html5) 156 | 157 | def test_default_attributes(self): 158 | proxy_view = DiazoProxyView() 159 | self.assertFalse(proxy_view.add_remote_user) 160 | self.assertFalse(proxy_view.add_x_forwarded) 161 | 162 | def test_inheritance_context_mixin(self): 163 | mixin_view = DiazoProxyView() 164 | self.assertTrue(hasattr(mixin_view, 'get_context_data')) 165 | 166 | def test_added_view_context(self): 167 | class CustomProxyView(DiazoProxyView): 168 | def get_context_data(self, **kwargs): 169 | context_data = {'key': 'value'} 170 | context_data.update(kwargs) 171 | return super(CustomProxyView, self).get_context_data(**context_data) 172 | 173 | class TextGetContextData(CustomProxyView): 174 | def get_context_data(self, **kwargs): 175 | context_data = super(CustomProxyView, self).get_context_data(**context_data) 176 | self.assertEqual(context_data['key'], 'value') 177 | return {} 178 | 179 | 180 | def test_tilde_is_not_escaped(self): 181 | class CustomProxyView(ProxyView): 182 | upstream = 'http://example.com' 183 | 184 | request = self.factory.get('~') 185 | CustomProxyView.as_view()(request, '~') 186 | 187 | url = 'http://example.com/~' 188 | headers = {u'Cookie': u''} 189 | self.urlopen.assert_called_with('GET', url, 190 | body=b'', 191 | redirect=False, 192 | retries=None, 193 | preload_content=False, 194 | decode_content=False, 195 | headers=headers) 196 | 197 | def test_space_is_escaped_enabled(self): 198 | class CustomProxyView(ProxyView): 199 | upstream = 'http://example.com' 200 | 201 | path = ' test test' 202 | request = self.factory.get(path) 203 | CustomProxyView.as_view()(request, path) 204 | 205 | url = 'http://example.com/+test+test' 206 | headers = {u'Cookie': u''} 207 | self.urlopen.assert_called_with('GET', url, 208 | body=b'', 209 | redirect=False, 210 | retries=None, 211 | preload_content=False, 212 | decode_content=False, 213 | headers=headers) 214 | 215 | @override_settings(REVPROXY={'QUOTE_SPACES_AS_PLUS': False}) 216 | def test_space_is_escaped_disabled(self): 217 | class CustomProxyView(ProxyView): 218 | upstream = 'http://example.com' 219 | 220 | path = ' test test' 221 | request = self.factory.get(path) 222 | CustomProxyView.as_view()(request, path) 223 | 224 | url = 'http://example.com/%20test%20test' 225 | headers = {u'Cookie': u''} 226 | self.urlopen.assert_called_with('GET', url, 227 | body=b'', 228 | redirect=False, 229 | retries=None, 230 | preload_content=False, 231 | decode_content=False, 232 | headers=headers) 233 | 234 | def test_extending_headers(self): 235 | class CustomProxyView(ProxyView): 236 | upstream = 'http://example.com' 237 | 238 | def get_proxy_request_headers(self, request): 239 | headers = super(CustomProxyView, self).\ 240 | get_proxy_request_headers(request) 241 | headers['DNT'] = 1 242 | return headers 243 | 244 | path = '' 245 | request = self.factory.get(path) 246 | CustomProxyView.as_view()(request, path) 247 | 248 | url = 'http://example.com' 249 | headers = {u'Cookie': u''} 250 | custom_headers = {'DNT': 1} 251 | custom_headers.update(headers) 252 | self.urlopen.assert_called_with('GET', url, 253 | body=b'', 254 | redirect=False, 255 | retries=None, 256 | preload_content=False, 257 | decode_content=False, 258 | headers=custom_headers) 259 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django.contrib.auth import login 4 | 5 | urlpatterns = [ 6 | path('accounts/login/', login, name='login'), 7 | ] 8 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import urllib3 4 | 5 | from io import BytesIO 6 | 7 | from unittest.mock import MagicMock, Mock 8 | 9 | from revproxy.views import ProxyView 10 | 11 | DEFAULT_BODY_CONTENT = u'áéíóú'.encode('utf-8') 12 | URLOPEN = 'urllib3.PoolManager.urlopen' 13 | 14 | 15 | class CustomProxyView(ProxyView): 16 | upstream = "http://www.example.com" 17 | diazo_rules = None 18 | 19 | 20 | def get_urlopen_mock(body=DEFAULT_BODY_CONTENT, headers=None, status=200): 21 | mockHttpResponse = Mock(name='httplib.HTTPResponse') 22 | 23 | headers = urllib3.response.HTTPHeaderDict(headers) 24 | 25 | if not hasattr(body, 'read'): 26 | body = BytesIO(body) 27 | 28 | else: 29 | body.seek(0) 30 | 31 | urllib3_response = urllib3.HTTPResponse(body, 32 | headers, 33 | status, 34 | preload_content=False, 35 | original_response=mockHttpResponse) 36 | 37 | return MagicMock(return_value=urllib3_response) 38 | 39 | 40 | class MockFile(): 41 | 42 | def __init__(self, content, read_size=4): 43 | self.content = content 44 | self.mock_file = BytesIO(content) 45 | self.mock_read_size = read_size 46 | 47 | def closed(self): 48 | return self.mock_file.closed 49 | 50 | def close(self): 51 | self.mock_file.close() 52 | 53 | def read(self, size=-1): 54 | return self.mock_file.read(self.mock_read_size) 55 | 56 | def seek(self, size): 57 | return self.mock_file.seek(size) 58 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.7: py37 4 | 3.8: py38 5 | 3.9: py39 6 | 3.10: py310 7 | 3.11: py311 8 | 9 | [gh-actions:env] 10 | DJANGO = 11 | 3.0: dj30 12 | 3.1: dj31 13 | 3.2: dj32 14 | 4.0: dj40 15 | 4.1: dj41 16 | 4.2: dj42 17 | 18 | [tox] 19 | usedevelop = True 20 | 21 | envlist = 22 | py{37,38,39}-dj{30} 23 | py{37,38,39}-dj{31} 24 | py{37,38,39,310}-dj{32} 25 | py{38,39,310,311}-dj{40} 26 | py{38,39,310,311}-dj{41} 27 | py{38,39,310,311}-dj{42} 28 | 29 | [testenv] 30 | basepython = 31 | py37: python3.7 32 | py38: python3.8 33 | py39: python3.9 34 | py310: python3.10 35 | py311: python3.11 36 | 37 | deps = 38 | dj30: Django>=3.0,<3.1 39 | dj31: Django>=3.1,<3.2 40 | dj32: Django>=3.2,<4.0 41 | dj40: Django>=4.0,<4.1 42 | dj41: Django>=4.1,<4.2 43 | dj42: Django>=4.2,<5.0 44 | 45 | extras = 46 | tests 47 | 48 | commands = 49 | flake8 revproxy 50 | coverage run --branch --source=revproxy {envbindir}/django-admin test --pythonpath=./ --settings=tests.settings 51 | coverage report --fail-under=90 --show-missing 52 | --------------------------------------------------------------------------------