├── .coveragerc ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .python-version ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── admin_ip_restrictor ├── __init__.py └── middleware.py ├── makefile ├── pull_request_template.md ├── requirements_flake8.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_middleware.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = admin_ip_restrictor 3 | omit = .tox/*,.circleci/* 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 12 * * 2' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v1 38 | with: 39 | languages: ${{ matrix.language }} 40 | # If you wish to specify custom queries, you can do so here or in a config file. 41 | # By default, queries listed here will override any specified in a config file. 42 | # Prefix the list here with "+" to use these queries and those in the config file. 43 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | 8 | # Distribution / packaging 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | 27 | # Unit test / coverage reports 28 | htmlcov/ 29 | .tox/ 30 | .coverage 31 | .coverage.* 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | *.cover 36 | .hypothesis/ 37 | 38 | # Environments 39 | .env 40 | .venv 41 | env/ 42 | venv/ 43 | ENV/ 44 | 45 | # Coverage directory used by tools like istanbul 46 | coverage 47 | 48 | .idea 49 | 50 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.5.10 2 | 3.6.12 3 | 3.7.9 4 | 3.8.6 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | dist: bionic 4 | jobs: 5 | fast_finish: true 6 | include: 7 | - python: '3.5' 8 | env: DJANGO=1.11 9 | - python: '3.5' 10 | env: DJANGO=2.0 11 | - python: '3.5' 12 | env: DJANGO=2.1 13 | - python: '3.5' 14 | env: DJANGO=2.2 15 | - python: '3.6' 16 | env: DJANGO=1.11 17 | - python: '3.6' 18 | env: DJANGO=2.0 19 | - python: '3.6' 20 | env: DJANGO=2.1 21 | - python: '3.6' 22 | env: DJANGO=2.2 23 | - python: '3.6' 24 | env: DJANGO=3.0 25 | - python: '3.6' 26 | env: DJANGO=3.1 27 | - python: '3.7' 28 | env: DJANGO=1.11 29 | - python: '3.7' 30 | env: DJANGO=2.0 31 | - python: '3.7' 32 | env: DJANGO=2.1 33 | - python: '3.7' 34 | env: DJANGO=2.2 35 | - python: '3.7' 36 | env: DJANGO=3.0 37 | - python: '3.7' 38 | env: DJANGO=3.1 39 | - python: '3.8' 40 | env: DJANGO=1.11 41 | - python: '3.8' 42 | env: DJANGO=2.0 43 | - python: '3.8' 44 | env: DJANGO=2.1 45 | - python: '3.8' 46 | env: DJANGO=2.2 47 | - python: '3.8' 48 | env: DJANGO=3.0 49 | - python: '3.8' 50 | env: DJANGO=3.1 51 | - python: '3.7' 52 | if: branch = master 53 | deploy: 54 | provider: pypi 55 | user: __token__ 56 | password: 57 | secure: eFSLFNELkrNapEZC99gg/Bi0iBlPuxi+Rz9cGeaFXn3rQi5liDx8/0nqPkvu33XM87rGkymg1CSaWkN+fA2K8wW8xWW0kBUopEMH0wJLaIJg4PAV+CH1w+iKnNYCWHaWzWT+v90k/fIsAZdVliANo40XguvZQzWTULfh3gFtqj+XUaUyD/9WsqJ4uWxoGGrB+HD3AXS1H7wfqsh3lVBIXPHyJtZrbqryS6hXtfuJrgCKXBVyPpk+CHfGtvJbYmieguXf0CRIk6jpyF7mZyHM+wOrDGv+Ow7pc1T0Vb6U51woMKsExvpDPpLOw5n8eHpqAccmY+w2vVWrRhOenICC8QY69zzDh/5tJuA+Nvw18EK5bxhEzUQMhZOkME1k5cHeFK1+eOMjON6fNeQ4tgBTQe6zq0w3h+GVdsbrumas/t1ecwm5fovuET/GTifb7pfJJd9eplFqG83RIk2XFUjxivAN7q4Eh6F7C+Ua8iQuC7PhFoyp/X5vzGZ2LWu4i7XPAHYfpU5gFgwZMiwt8WbknkYYJf1r/TtsfS4ZsZtIfYylKfkYmeQWj7UkZfAmvSomInEDimen3sRDa8kGrfzx85vprCyRiSO13gBbYa6jSsFMwWoYu9iXvqPt6n+zqKVyyb/cT759sARDkb3bU8fZPpO5uaKcrEwz/Wi7N8MmhQo= 58 | 59 | install: 60 | - pip install tox tox-venv tox-travis codecov 61 | 62 | script: 63 | - tox 64 | 65 | after_success: 66 | - tox -e coverage-report 67 | 68 | notifications: 69 | email: false 70 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | `Release history`_ 2 | ################## 3 | 4 | 2.2.0 - 06-10-2020 5 | ================== 6 | 7 | * Add Django 3.0 and 3.1 support 8 | * Add django-ipware 3 support 9 | 10 | 2.1.1 - 10-01-2020 11 | ================== 12 | * Fix Travis CI deployment to PyPi 13 | 14 | 15 | 2.1.0 - 10-01-2020 16 | ================== 17 | * Add TRUST_PRIVATE_IP settings (default: `False`) 18 | 19 | 20 | 2.0.0 - 10-01-2019 21 | ================== 22 | * Drop Django 1.10 support 23 | * Drop Python 3.4 support 24 | * Add Django 2.1 and 2.2 support 25 | * Switch to Travis CI 26 | 27 | .. _Release history: https://pypi.org/project/django-admin-ip-restrictor/#history -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 UK Trade & Investment (UKTI) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | prune tests -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Admin IP Restrictor 2 | ========================== 3 | 4 | .. image:: https://travis-ci.org/sdonk/django-admin-ip-restrictor.svg?branch=master 5 | :target: https://travis-ci.org/sdonk/django-admin-ip-restrictor 6 | 7 | .. image:: https://codecov.io/gh/uktrade/django-admin-ip-restrictor/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/uktrade/django-admin-ip-restrictor 9 | 10 | .. image:: https://img.shields.io/pypi/v/django-admin-ip-restrictor.svg 11 | :target: https://pypi.python.org/pypi/django-admin-ip-restrictor 12 | 13 | .. image:: https://img.shields.io/pypi/pyversions/django-admin-ip-restrictor.svg 14 | :target: https://pypi.python.org/pypi/django-admin-ip-restrictor 15 | 16 | .. image:: https://img.shields.io/pypi/l/django-admin-ip-restrictor.svg 17 | :target: https://pypi.python.org/pypi/django-admin-ip-restrictor 18 | 19 | A Django middleware to restrict the access to the Django admin based on incoming IPs 20 | 21 | Requirements 22 | ------------ 23 | 24 | * Python >= 3.5 25 | * Django >= 1.11,<4 26 | * django-ipware=>2,<4 27 | 28 | Note that Django 3 has dropped support for Python 3.5 29 | 30 | Usage 31 | ----- 32 | 33 | First install the package:: 34 | 35 | $ pip install django-admin-ip-restrictor 36 | 37 | Then add the middleware to your settings:: 38 | 39 | MIDDLEWARE = [ 40 | ... 41 | 'admin_ip_restrictor.middleware.AdminIPRestrictorMiddleware' 42 | ] 43 | 44 | Set these variables in your `settings.py` to control who has access to the admin (IPV4 and IPV6 can be mixed):: 45 | 46 | RESTRICT_ADMIN=True 47 | ALLOWED_ADMIN_IPS=['127.0.0.1', '::1'] 48 | ALLOWED_ADMIN_IP_RANGES=['127.0.0.0/24', '::/1'] 49 | RESTRICTED_APP_NAMES=['admin'] 50 | TRUST_PRIVATE_IP=True 51 | 52 | Use `RESTRICTED_APP_NAMES` to restrict the access to more apps. Admin app is always included. 53 | 54 | If using environment variables make sure that the variables receive the right type of value. 55 | `django-admin-ip-restrictor` automatically converts the following formats:: 56 | 57 | $ export RESTRICT_ADMIN='true' 58 | $ export ALLOWED_ADMIN_IPS='127.0.0.1,::1' 59 | $ export ALLOWED_ADMIN_IP_RANGES='127.0.0.0/24,::/1' 60 | $ export RESTRICTED_APP_NAMES='wagtail_admin,foo' 61 | 62 | 63 | For `RESTRICT_ADMIN` also these values can be used: `True`, `1`, `false`, `False`, `0` 64 | 65 | Use `TRUST_PRIVATE_IP` to skip checking IP addresses from a trusted private network. 66 | 67 | Changelog 68 | --------- 69 | Full changelog at https://github.com/sdonk/django-admin-ip-restrictor/blob/master/CHANGELOG.rst 70 | 71 | Run tests 72 | --------- 73 | 74 | Install `tox`:: 75 | 76 | $ pip install tox 77 | 78 | 79 | Install `pyenv`, use https://github.com/pyenv/pyenv#installation as reference. 80 | 81 | Install Python versions in `pyenv`:: 82 | 83 | $ pyenv install 3.5.10 84 | $ pyenv install 3.6.12 85 | $ pyenv install 3.7.9 86 | $ pyenv install 3.8.6 87 | 88 | Specify the Python versions you want to test with:: 89 | 90 | $ pyenv local 3.5.10 3.6.12 3.7.9 3.8.6 91 | 92 | Run tests:: 93 | 94 | $ tox 95 | 96 | Contribute 97 | ---------- 98 | 99 | Fork the project and submit a PR! 100 | -------------------------------------------------------------------------------- /admin_ip_restrictor/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (2, 2, 0) 2 | 3 | 4 | __version__ = '{major}.{minor}.{patch}'.format( 5 | major=VERSION[0], 6 | minor=VERSION[1], 7 | patch=VERSION[2] 8 | ) 9 | -------------------------------------------------------------------------------- /admin_ip_restrictor/middleware.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | try: 4 | from ipware.ip2 import get_client_ip 5 | except ImportError: 6 | from ipware.ip import get_client_ip 7 | 8 | from django.conf import settings 9 | from django.http import Http404 10 | from django.utils.deprecation import MiddlewareMixin 11 | 12 | 13 | class AdminIPRestrictorMiddleware(MiddlewareMixin): 14 | 15 | 16 | def __init__(self, get_response=None): 17 | self.get_response = get_response 18 | restrict_admin = getattr( 19 | settings, 20 | 'RESTRICT_ADMIN', 21 | False 22 | ) 23 | trust_private_ip = getattr( 24 | settings, 25 | 'TRUST_PRIVATE_IP', 26 | False 27 | ) 28 | self.trust_private_ip = self.parse_bool_envars( 29 | trust_private_ip 30 | ) 31 | self.restrict_admin = self.parse_bool_envars( 32 | restrict_admin 33 | ) 34 | allowed_admin_ips = getattr( 35 | settings, 36 | 'ALLOWED_ADMIN_IPS', 37 | [] 38 | ) 39 | self.allowed_admin_ips = self.parse_list_envars( 40 | allowed_admin_ips 41 | ) 42 | allowed_admin_ip_ranges = getattr( 43 | settings, 44 | 'ALLOWED_ADMIN_IP_RANGES', 45 | [] 46 | ) 47 | self.allowed_admin_ip_ranges = self.parse_list_envars( 48 | allowed_admin_ip_ranges 49 | ) 50 | restricted_app_names = getattr( 51 | settings, 52 | 'RESTRICTED_APP_NAMES', 53 | [] 54 | ) 55 | self.restricted_app_names = self.parse_list_envars( 56 | restricted_app_names 57 | ) 58 | self.restricted_app_names.append('admin') 59 | 60 | @staticmethod 61 | def parse_bool_envars(value): 62 | if value in ('true', 'True', '1', 1): 63 | return True 64 | return False 65 | 66 | @staticmethod 67 | def parse_list_envars(value): 68 | if type(value) == list: 69 | return value 70 | else: 71 | return value.split(',') 72 | 73 | def is_blocked(self, ip): 74 | """Determine if an IP address should be considered blocked.""" 75 | blocked = True 76 | 77 | if self.trust_private_ip: 78 | if ipaddress.ip_address(ip).is_private: 79 | blocked = False 80 | 81 | if ip in self.allowed_admin_ips: 82 | blocked = False 83 | 84 | for allowed_range in self.allowed_admin_ip_ranges: 85 | if ipaddress.ip_address(ip) in ipaddress.ip_network(allowed_range): 86 | blocked = False 87 | 88 | return blocked 89 | 90 | def get_ip(self, request): 91 | client_ip, is_routable = get_client_ip(request) 92 | assert client_ip, 'IP not found' 93 | if not self.trust_private_ip: 94 | assert is_routable, 'IP is private' 95 | return client_ip 96 | 97 | def process_view(self, request, view_func, view_args, view_kwargs): 98 | app_name = request.resolver_match.app_name 99 | is_restricted_app = app_name in self.restricted_app_names 100 | 101 | if self.restrict_admin and is_restricted_app: 102 | ip = self.get_ip(request) 103 | if self.is_blocked(ip): 104 | raise Http404() 105 | 106 | return None 107 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | test: 2 | tox 3 | 4 | clean: 5 | find . -type f -name "*.pyc" -delete 6 | rm -rf coverage.xml .coverage.* *.egg-info dist .cache .tox .pytest.cache 7 | 8 | publish: clean 9 | rm -rf build dist; \ 10 | pip install twine wheel;\ 11 | python setup.py bdist_wheel; \ 12 | twine upload --username $$PYPI_USERNAME --password $$PYPI_PASSWORD dist/* 13 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] Changelog entry added 2 | - [ ] Version number updated 3 | -------------------------------------------------------------------------------- /requirements_flake8.txt: -------------------------------------------------------------------------------- 1 | # Code static analysis 2 | pep8==1.7.1 3 | flake8==3.7.9 4 | flake8-blind-except==0.1.1 5 | flake8-debugger==3.2.1 6 | flake8-import-order==0.18.1 7 | flake8-docstrings==1.5.0 8 | flake8-print==3.1.4 9 | flake8-quotes==2.1.1 10 | flake8-string-format==0.2.3 11 | pep8-naming==0.9.1 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # D203: 1 blank line required before class docstring 2 | # D100: Missing docstring in public module 3 | # D101: Missing docstring in public class 4 | # D102: Missing docstring in public method 5 | # D103: Missing docstring in public function 6 | # D104: Missing docstring in public package 7 | # D105: Missing docstring in magic method 8 | # D106: Missing docstring in public nested class 9 | # D107: Missing docstring in __init__ 10 | # D401: First line should be imperative 11 | 12 | [flake8] 13 | max-line-length = 120 14 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,node_modules,*settings*,manage.py,wsgi.py 15 | ignore = D203, D100, D101, D102, D103, D104, D105, D106, D107, D401 16 | max-complexity = 7 17 | application-import-names = money_tracker 18 | import_order_style = smarkets 19 | 20 | [pycodestyle] 21 | max-line-length = 120 22 | exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,node_modules,*settings*,manage.py,wsgi.py 23 | ignore = D203, D100, D101, D102, D103, D104, D105, D106, D107, D401 24 | 25 | [tool:pytest] 26 | testpaths = tests/ 27 | norecursedirs = .tox 28 | 29 | [bdist_wheel] 30 | python-tag = py3 31 | 32 | [metadata] 33 | license_file = LICENSE 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | from admin_ip_restrictor import __version__ 5 | 6 | PROJECT_DIR = os.path.dirname(__file__) 7 | 8 | with open(os.path.join(PROJECT_DIR, 'README.rst')) as readme: 9 | long_description = readme.read() 10 | 11 | install_requires = [ 12 | 'django>=1.11,<4; python_version >= "3.6.0"', 13 | 'django-ipware>=2,<4; python_version >= "3.6.0"', 14 | 'django>=1.11,<3; python_version < "3.6.0"', 15 | 'django-ipware>=2,<3; python_version < "3.6.0"' 16 | ] 17 | 18 | test_requires = [ 19 | 'coverage==5.0.2', 20 | 'pytest==5.3.2', 21 | 'pytest-cov==2.8.1', 22 | 'pytest-django==3.7.0', 23 | 'pytest-sugar==0.9.2', 24 | 'tox==3.14.3', 25 | ] 26 | 27 | setup( 28 | name='django-admin-ip-restrictor', 29 | version=__version__, 30 | packages=find_packages(exclude=["tests.*", "tests", ".tox"]), 31 | include_package_data=True, 32 | license='MIT License', 33 | description='A Django middleware to restrict incoming IPs to admin panel', 34 | long_description=long_description, 35 | url='https://github.com/sdonk/django-admin-ip-restrictor/', 36 | author='Alessandro De Noia', 37 | author_email='alessandro.denoia@gmail.com', 38 | install_requires=install_requires, 39 | extras_require={ 40 | 'tests': test_requires, 41 | }, 42 | classifiers=[ 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Environment :: Web Environment', 45 | 'Framework :: Django', 46 | 'Framework :: Django :: 1.11', 47 | 'Framework :: Django :: 2.0', 48 | 'Framework :: Django :: 2.1', 49 | 'Framework :: Django :: 2.2', 50 | 'Framework :: Django :: 3.0', 51 | 'Framework :: Django :: 3.1', 52 | 'Intended Audience :: Developers', 53 | 'License :: OSI Approved :: MIT License', 54 | 'Operating System :: OS Independent', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.5', 58 | 'Programming Language :: Python :: 3.6', 59 | 'Programming Language :: Python :: 3.7', 60 | 'Programming Language :: Python :: 3.8', 61 | 'Programming Language :: Python :: 3 :: Only', 62 | 'Topic :: Software Development :: Libraries :: Python Modules', 63 | ], 64 | ) 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdonk/django-admin-ip-restrictor/09ff6c1f5f17a4ef934d94d8a8d6dc9d0cda8c66/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | 6 | def pytest_configure(config): 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 8 | django.setup() 9 | 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | 3 | SECRET_KEY = 'fake-key' 4 | 5 | INSTALLED_APPS = [ 6 | 'django.contrib.auth', 7 | 'django.contrib.contenttypes', 8 | 'django.contrib.sessions', 9 | 'django.contrib.messages', 10 | 'django.contrib.admin', 11 | 'tests' 12 | ] 13 | 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | 'NAME': ':memory:', 18 | } 19 | } 20 | 21 | ROOT_URLCONF = 'tests.urls' 22 | 23 | TEMPLATES = [ 24 | { 25 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 26 | 'APP_DIRS': True, 27 | 'OPTIONS': { 28 | 'context_processors': [ 29 | 'django.contrib.auth.context_processors.auth', 30 | 'django.contrib.messages.context_processors.messages', 31 | ], 32 | }, 33 | }, 34 | ] 35 | 36 | 37 | if DJANGO_VERSION < (1, 10): 38 | MIDDLEWARE_CLASSES = [ 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | 'django.contrib.messages.middleware.MessageMiddleware', 42 | 'admin_ip_restrictor.middleware.AdminIPRestrictorMiddleware' 43 | ] 44 | else: 45 | MIDDLEWARE = [ 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.messages.middleware.MessageMiddleware', 49 | 'admin_ip_restrictor.middleware.AdminIPRestrictorMiddleware' 50 | ] 51 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import pytest 3 | try: 4 | from django.core.urlresolvers import reverse_lazy 5 | except ImportError: 6 | from django.urls import reverse_lazy 7 | 8 | 9 | @pytest.mark.parametrize( 10 | 'header, incoming_ip, expected', 11 | ( 12 | ('HTTP_X_FORWARDED_FOR', '74.125.224.72', 200), 13 | ('X_FORWARDED_FOR', '74.125.224.72', 200), 14 | ('HTTP_CLIENT_IP', '74.125.224.72', 200), 15 | ('HTTP_X_REAL_IP', '74.125.224.72', 200), 16 | ('HTTP_X_FORWARDED', '74.125.224.72', 200), 17 | ('HTTP_X_CLUSTER_CLIENT_IP', '74.125.224.72', 200), 18 | ('HTTP_FORWARDED_FOR', '74.125.224.72', 200), 19 | ('HTTP_FORWARDED', '74.125.224.72', 200), 20 | ('HTTP_VIA', '74.125.224.72', 200), 21 | ('REMOTE_ADDR', '74.125.224.72', 200) 22 | ), 23 | ids=[ 24 | 'HTTP_X_FORWARDED_FOR allow', 25 | 'X_FORWARDED_FOR allow', 26 | 'HTTP_CLIENT_IP allow', 27 | 'HTTP_X_REAL_IP allow', 28 | 'HTTP_X_FORWARDED allow', 29 | 'HTTP_X_CLUSTER_CLIENT_IP allow', 30 | 'HTTP_FORWARDED_FOR allow', 31 | 'HTTP_FORWARDED allow', 32 | 'HTTP_VIA allow', 33 | 'REMOTE_ADDR allow' 34 | ] 35 | ) 36 | def test_admin_no_restriction(header, incoming_ip, expected, settings, client): 37 | settings.RESTRICT_ADMIN = False 38 | admin_url = reverse_lazy('admin:login') 39 | response = client.get(admin_url, **{header: incoming_ip}) 40 | assert response.status_code == expected 41 | 42 | 43 | @pytest.mark.parametrize( 44 | 'header, incoming_ip, expected', 45 | ( 46 | ('HTTP_X_FORWARDED_FOR', '74.125.224.72', 200), 47 | ('X_FORWARDED_FOR', '74.125.224.72', 200), 48 | ('HTTP_CLIENT_IP', '74.125.224.72', 200), 49 | ('HTTP_X_REAL_IP', '74.125.224.72', 200), 50 | ('HTTP_X_FORWARDED', '74.125.224.72', 200), 51 | ('HTTP_X_CLUSTER_CLIENT_IP', '74.125.224.72', 200), 52 | ('HTTP_FORWARDED_FOR', '74.125.224.72', 200), 53 | ('HTTP_FORWARDED', '74.125.224.72', 200), 54 | ('HTTP_VIA', '74.125.224.72', 200), 55 | ('REMOTE_ADDR', '74.125.224.72', 200), 56 | ('HTTP_X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 57 | ('X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 58 | ('HTTP_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 59 | ('HTTP_X_REAL_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 60 | ('HTTP_X_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 200), 61 | ('HTTP_X_CLUSTER_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 62 | ('HTTP_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 63 | ('HTTP_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 200), 64 | ('HTTP_VIA', '0:0:0:0:0:ffff:4a7d:e048', 200), 65 | ('REMOTE_ADDR', '0:0:0:0:0:ffff:4a7d:e048', 200) 66 | ), 67 | ids=[ 68 | 'HTTP_X_FORWARDED_FOR allow', 69 | 'X_FORWARDED_FOR allow', 70 | 'HTTP_CLIENT_IP allow', 71 | 'HTTP_X_REAL_IP allow', 72 | 'HTTP_X_FORWARDED allow', 73 | 'HTTP_X_CLUSTER_CLIENT_IP allow', 74 | 'HTTP_FORWARDED_FOR allow', 75 | 'HTTP_FORWARDED allow', 76 | 'HTTP_VIA allow', 77 | 'REMOTE_ADDR allow', 78 | 'HTTP_X_FORWARDED_FOR ipv6 allow', 79 | 'X_FORWARDED_FOR ipv6 allow', 80 | 'HTTP_CLIENT_IP ipv6 allow', 81 | 'HTTP_X_REAL_IP ipv6 allow', 82 | 'HTTP_X_FORWARDED ipv6 allow', 83 | 'HTTP_X_CLUSTER_CLIENT_IP ipv6 allow', 84 | 'HTTP_FORWARDED_FOR ipv6 allow', 85 | 'HTTP_FORWARDED ipv6 allow', 86 | 'HTTP_VIA ipv6 allow', 87 | 'REMOTE_ADDR ipv6 allow', 88 | ] 89 | ) 90 | def test_admin_restricted_allowed_ips(header, incoming_ip, expected, settings, client): 91 | settings.RESTRICT_ADMIN = True 92 | settings.ALLOWED_ADMIN_IPS = ['74.125.224.72', '0:0:0:0:0:ffff:4a7d:e048'] 93 | admin_url = reverse_lazy('admin:login') 94 | response = client.get(admin_url, **{header: incoming_ip}) 95 | assert response.status_code == expected 96 | 97 | 98 | @pytest.mark.parametrize( 99 | 'header, incoming_ip, expected', 100 | ( 101 | ('HTTP_X_FORWARDED_FOR', '74.125.224.72', 404), 102 | ('X_FORWARDED_FOR', '74.125.224.72', 404), 103 | ('HTTP_CLIENT_IP', '74.125.224.72', 404), 104 | ('HTTP_X_REAL_IP', '74.125.224.72', 404), 105 | ('HTTP_X_FORWARDED', '74.125.224.72', 404), 106 | ('HTTP_X_CLUSTER_CLIENT_IP', '74.125.224.72', 404), 107 | ('HTTP_FORWARDED_FOR', '74.125.224.72', 404), 108 | ('HTTP_FORWARDED', '74.125.224.72', 404), 109 | ('HTTP_VIA', '74.125.224.72', 404), 110 | ('REMOTE_ADDR', '74.125.224.72', 404), 111 | ('HTTP_X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 112 | ('X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 113 | ('HTTP_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 114 | ('HTTP_X_REAL_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 115 | ('HTTP_X_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 404), 116 | ('HTTP_X_CLUSTER_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 117 | ('HTTP_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 118 | ('HTTP_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 404), 119 | ('HTTP_VIA', '0:0:0:0:0:ffff:4a7d:e048', 404), 120 | ('REMOTE_ADDR', '0:0:0:0:0:ffff:4a7d:e048', 404) 121 | ), 122 | ids=[ 123 | 'HTTP_X_FORWARDED_FOR block', 124 | 'X_FORWARDED_FOR block', 125 | 'HTTP_CLIENT_IP block', 126 | 'HTTP_X_REAL_IP block', 127 | 'HTTP_X_FORWARDED block', 128 | 'HTTP_X_CLUSTER_CLIENT_IP block', 129 | 'HTTP_FORWARDED_FOR block', 130 | 'HTTP_FORWARDED block', 131 | 'HTTP_VIA block', 132 | 'REMOTE_ADDR block', 133 | 'HTTP_X_FORWARDED_FOR ipv6 block', 134 | 'X_FORWARDED_FOR ipv6 block', 135 | 'HTTP_CLIENT_IP ipv6 block', 136 | 'HTTP_X_REAL_IP ipv6 block', 137 | 'HTTP_X_FORWARDED ipv6 block', 138 | 'HTTP_X_CLUSTER_CLIENT_IP ipv6 block', 139 | 'HTTP_FORWARDED_FOR ipv6 block', 140 | 'HTTP_FORWARDED ipv6 block', 141 | 'HTTP_VIA ipv6 block', 142 | 'REMOTE_ADDR ipv6 block' 143 | ] 144 | ) 145 | def test_admin_restricted_blocked_ips(header, incoming_ip, expected, settings, client): 146 | settings.RESTRICT_ADMIN = True 147 | settings.ALLOWED_ADMIN_IPS = ['8.8.8.9', '0:0:0:0:0:ffff:4a7d:e04b'] 148 | admin_url = reverse_lazy('admin:login') 149 | response = client.get(admin_url, **{header: incoming_ip}) 150 | assert response.status_code == expected 151 | 152 | 153 | @pytest.mark.parametrize( 154 | 'header, incoming_ip, expected', 155 | ( 156 | ('HTTP_X_FORWARDED_FOR', '74.125.224.72', 200), 157 | ('X_FORWARDED_FOR', '74.125.224.72', 200), 158 | ('HTTP_CLIENT_IP', '74.125.224.72', 200), 159 | ('HTTP_X_REAL_IP', '74.125.224.72', 200), 160 | ('HTTP_X_FORWARDED', '74.125.224.72', 200), 161 | ('HTTP_X_CLUSTER_CLIENT_IP', '74.125.224.72', 200), 162 | ('HTTP_FORWARDED_FOR', '74.125.224.72', 200), 163 | ('HTTP_FORWARDED', '74.125.224.72', 200), 164 | ('HTTP_VIA', '74.125.224.72', 200), 165 | ('REMOTE_ADDR', '74.125.224.72', 200), 166 | ('HTTP_X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 167 | ('X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 168 | ('HTTP_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 169 | ('HTTP_X_REAL_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 170 | ('HTTP_X_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 200), 171 | ('HTTP_X_CLUSTER_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 200), 172 | ('HTTP_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 200), 173 | ('HTTP_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 200), 174 | ('HTTP_VIA', '0:0:0:0:0:ffff:4a7d:e048', 200), 175 | ('REMOTE_ADDR', '0:0:0:0:0:ffff:4a7d:e048', 200) 176 | ), 177 | ids=[ 178 | 'HTTP_X_FORWARDED_FOR allow', 179 | 'X_FORWARDED_FOR allow', 180 | 'HTTP_CLIENT_IP allow', 181 | 'HTTP_X_REAL_IP allow', 182 | 'HTTP_X_FORWARDED allow', 183 | 'HTTP_X_CLUSTER_CLIENT_IP allow', 184 | 'HTTP_FORWARDED_FOR allow', 185 | 'HTTP_FORWARDED allow', 186 | 'HTTP_VIA allow', 187 | 'REMOTE_ADDR allow', 188 | 'HTTP_X_FORWARDED_FOR ipv6 allow', 189 | 'X_FORWARDED_FOR ipv6 allow', 190 | 'HTTP_CLIENT_IP ipv6 allow', 191 | 'HTTP_X_REAL_IP ipv6 allow', 192 | 'HTTP_X_FORWARDED ipv6 allow', 193 | 'HTTP_X_CLUSTER_CLIENT_IP ipv6 allow', 194 | 'HTTP_FORWARDED_FOR ipv6 allow', 195 | 'HTTP_FORWARDED ipv6 allow', 196 | 'HTTP_VIA ipv6 allow', 197 | 'REMOTE_ADDR ipv6 allow', 198 | ] 199 | ) 200 | def test_admin_restricted_allowed_ip_ranges(header, incoming_ip, expected, 201 | settings, client): 202 | settings.RESTRICT_ADMIN = True 203 | settings.ALLOWED_ADMIN_IP_RANGES = [ 204 | '74.125.224.72/32', 205 | '0:0:0:0:0:ffff:4a7d:e048/128' 206 | ] 207 | admin_url = reverse_lazy('admin:login') 208 | response = client.get(admin_url, **{header: incoming_ip}) 209 | assert response.status_code == expected 210 | 211 | 212 | @pytest.mark.parametrize( 213 | 'header, incoming_ip, expected', 214 | ( 215 | ('HTTP_X_FORWARDED_FOR', '74.125.224.72', 404), 216 | ('X_FORWARDED_FOR', '74.125.224.72', 404), 217 | ('HTTP_CLIENT_IP', '74.125.224.72', 404), 218 | ('HTTP_X_REAL_IP', '74.125.224.72', 404), 219 | ('HTTP_X_FORWARDED', '74.125.224.72', 404), 220 | ('HTTP_X_CLUSTER_CLIENT_IP', '74.125.224.72', 404), 221 | ('HTTP_FORWARDED_FOR', '74.125.224.72', 404), 222 | ('HTTP_FORWARDED', '74.125.224.72', 404), 223 | ('HTTP_VIA', '74.125.224.72', 404), 224 | ('REMOTE_ADDR', '74.125.224.72', 404), 225 | ('HTTP_X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 226 | ('X_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 227 | ('HTTP_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 228 | ('HTTP_X_REAL_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 229 | ('HTTP_X_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 404), 230 | ('HTTP_X_CLUSTER_CLIENT_IP', '0:0:0:0:0:ffff:4a7d:e048', 404), 231 | ('HTTP_FORWARDED_FOR', '0:0:0:0:0:ffff:4a7d:e048', 404), 232 | ('HTTP_FORWARDED', '0:0:0:0:0:ffff:4a7d:e048', 404), 233 | ('HTTP_VIA', '0:0:0:0:0:ffff:4a7d:e048', 404), 234 | ('REMOTE_ADDR', '0:0:0:0:0:ffff:4a7d:e048', 404) 235 | ), 236 | ids=[ 237 | 'HTTP_X_FORWARDED_FOR block', 238 | 'X_FORWARDED_FOR block', 239 | 'HTTP_CLIENT_IP block', 240 | 'HTTP_X_REAL_IP block', 241 | 'HTTP_X_FORWARDED block', 242 | 'HTTP_X_CLUSTER_CLIENT_IP block', 243 | 'HTTP_FORWARDED_FOR block', 244 | 'HTTP_FORWARDED block', 245 | 'HTTP_VIA block', 246 | 'REMOTE_ADDR block', 247 | 'HTTP_X_FORWARDED_FOR ipv6 block', 248 | 'X_FORWARDED_FOR ipv6 block', 249 | 'HTTP_CLIENT_IP ipv6 block', 250 | 'HTTP_X_REAL_IP ipv6 block', 251 | 'HTTP_X_FORWARDED ipv6 block', 252 | 'HTTP_X_CLUSTER_CLIENT_IP ipv6 block', 253 | 'HTTP_FORWARDED_FOR ipv6 block', 254 | 'HTTP_FORWARDED ipv6 block', 255 | 'HTTP_VIA ipv6 block', 256 | 'REMOTE_ADDR ipv6 block', 257 | ] 258 | ) 259 | def test_admin_restricted_blocked_ip_ranges(header, incoming_ip, expected, 260 | settings, client): 261 | settings.RESTRICT_ADMIN = True 262 | settings.ALLOWED_ADMIN_IPS = ['192.168.0.0/24', '0:0:0:0:0:ffff:4a7d:e04b'] 263 | admin_url = reverse_lazy('admin:login') 264 | response = client.get(admin_url, **{header: incoming_ip}) 265 | assert response.status_code == expected 266 | 267 | 268 | @mock.patch('admin_ip_restrictor.middleware.get_client_ip') 269 | def test_client_ip_not_found(mocked_get_client_ip, settings, client): 270 | settings.RESTRICT_ADMIN = True 271 | mocked_get_client_ip.return_value = None, None 272 | 273 | admin_url = reverse_lazy('admin:login') 274 | with pytest.raises(Exception) as e: 275 | client.get(admin_url) 276 | assert 'IP not found' in str(e.value) 277 | 278 | 279 | @mock.patch('admin_ip_restrictor.middleware.get_client_ip') 280 | def test_ip_not_foud_unrestricted_app(mocked_get_client_ip, settings, client): 281 | settings.RESTRICT_ADMIN = True 282 | settings.RESTRICTED_APP_NAMES = ['foo'] 283 | mocked_get_client_ip.return_value = None, None 284 | 285 | admin_url = reverse_lazy('not-admin:home') 286 | 287 | response = client.get(admin_url) 288 | 289 | assert response.status_code == 200 290 | 291 | 292 | @pytest.mark.parametrize( 293 | 'header, incoming_ip', 294 | ( 295 | ('HTTP_X_FORWARDED_FOR', '127.0.0.1'), 296 | ('HTTP_X_FORWARDED_FOR', 'fc00:'), 297 | ), 298 | ids=[ 299 | 'Private IPV4', 300 | 'Private IPV6' 301 | ] 302 | ) 303 | def test_client_ip_private(header, incoming_ip, settings, client): 304 | settings.RESTRICT_ADMIN = True 305 | admin_url = reverse_lazy('admin:login') 306 | with pytest.raises(Exception) as e: 307 | client.get(admin_url, **{header: incoming_ip}) 308 | assert 'IP is private' in str(e.value) 309 | 310 | 311 | @pytest.mark.parametrize( 312 | 'header, incoming_ip', 313 | ( 314 | ('HTTP_X_FORWARDED_FOR', '127.0.0.1'), 315 | ('HTTP_X_FORWARDED_FOR', 'fc00:'), 316 | ), 317 | ids=[ 318 | 'Private IPV4', 319 | 'Private IPV6' 320 | ] 321 | ) 322 | def test_client_trust_ip_private(header, incoming_ip, settings, client): 323 | settings.RESTRICT_ADMIN = True 324 | settings.TRUST_PRIVATE_IP = True 325 | admin_url = reverse_lazy('admin:login') 326 | 327 | response = client.get(admin_url, **{header: incoming_ip}) 328 | 329 | assert response.status_code == 200 330 | 331 | 332 | @pytest.mark.parametrize( 333 | 'envar, expected', 334 | ( 335 | ('127.0.0.1', ['127.0.0.1']), 336 | ('127.0.0.1,127.0.0.2', ['127.0.0.1', '127.0.0.2']), 337 | ), 338 | ids=[ 339 | 'single entry string', 340 | 'comma separated multiple entry string', 341 | ] 342 | ) 343 | def test_list_envars_parsing(envar, expected): 344 | from admin_ip_restrictor.middleware import AdminIPRestrictorMiddleware 345 | assert AdminIPRestrictorMiddleware.parse_list_envars(envar) == expected 346 | 347 | 348 | @pytest.mark.parametrize( 349 | 'envar, expected', 350 | ( 351 | ('True', True), 352 | ('true', True), 353 | ('1', True), 354 | (1, True), 355 | ('bla', False), 356 | ('foo', False), 357 | ('0', False), 358 | (0, False) 359 | ), 360 | ids=[ 361 | 'True', 362 | 'true', 363 | '1', 364 | '1 number', 365 | 'false', 366 | 'False', 367 | '0', 368 | '0 number' 369 | ] 370 | ) 371 | def test_bool_envars_parsing(envar, expected): 372 | from admin_ip_restrictor.middleware import AdminIPRestrictorMiddleware 373 | assert AdminIPRestrictorMiddleware.parse_bool_envars(envar) == expected 374 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | from django.http.response import HttpResponse 4 | 5 | def my_view(*args, **kwargs): 6 | return HttpResponse('Hello, world') 7 | 8 | not_admin_urls = [ 9 | url(r'', my_view, name='home'), 10 | ] 11 | 12 | urlpatterns = [ 13 | url(r'^admin/', admin.site.urls), 14 | url(r'^not-admin/', include((not_admin_urls, 'not-admin'))) 15 | ] 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{35,36,37}-django111, 4 | py{35,36,37}-django20 5 | py{35,36,37}-django21 6 | py{35,36,37,38}-django22 7 | py{36,37,38}-django30 8 | py{36,37,38}-django31 9 | 10 | [travis:env] 11 | DJANGO = 12 | 1.11: django111 13 | 2.0: django20 14 | 2.1: django21 15 | 2.2: django22 16 | 3.0: django30 17 | 3.1: django31 18 | 19 | [testenv] 20 | commands = coverage run --parallel -m pytest {posargs} 21 | extras = tests 22 | deps = 23 | -e. 24 | django111: Django>=1.11,<2.0 25 | django20: Django>=2.0,<2.1 26 | django21: Django>=2.1,<2.2 27 | django22: Django>=2.2,<3.0 28 | django30: Django>=3.0,<3.1 29 | django31: Django>=3.1,<3.2 30 | 31 | 32 | [testenv:coverage-report] 33 | basepython = python3.7 34 | deps = coverage 35 | skip_install = true 36 | commands = 37 | coverage combine 38 | coverage report 39 | --------------------------------------------------------------------------------