├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── flask_ipblock ├── __init__.py └── documents.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests └── __init__.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/python:2.7 7 | - image: mongo:3.0.7 8 | working_directory: ~/code 9 | steps: 10 | - checkout 11 | - run: 12 | name: Install dependencies 13 | command: pipenv install flake8 nose 14 | - run: 15 | name: Lint 16 | command: pipenv run flake8 17 | - run: 18 | name: Test 19 | command: pipenv run nosetests 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Elastic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask-IPBlock [![Build Status](https://circleci.com/gh/closeio/flask-ipblock.png?branch=master&style=shield)](https://circleci.com/gh/closeio/flask-ipblock) 2 | ============= 3 | 4 | Block certain IP addresses from accessing your Flask application. 5 | 6 | Flask-IPBlock is backed by MongoDB and supports application-level caching to boost performance. 7 | 8 | Options 9 | ======= 10 | You can override the default MongoDB read preference via the optional read_preference kwarg. 11 | 12 | You can limit the impact of the IP checks on your MongoDB by maintaining a local in-memory LRU cache. To do so, specify its cache_size (i.e. max number of IP addresses it can store) and cache_ttl (i.e. how many seconds each result should be cached for). 13 | 14 | To run in dry-run mode without blocking requests, set `blocking_enabled` to `False`. Set `logging_enabled` to `True` to log IPs that match blocking rules -- if enabled, will log even if `blocking_enabled` is False. 15 | 16 | Setup 17 | ===== 18 | 19 | ``` python 20 | from flask import Flask 21 | from flask_ipblock import IPBlock 22 | from flask_ipblock.documents import IPNetwork 23 | 24 | # Initialize the Flask app 25 | app = Flask(__name__) 26 | 27 | # Configuration (e.g. setting up MongoEngine) 28 | 29 | # Set up IPBlock 30 | ipblock = IPBlock(app) 31 | 32 | # Create a MongoEngine document corresponding to a range of IP addresses 33 | # owned by Facebook 34 | IPNetwork.objects.create_from_string('204.15.20.0/22', label='Facebook') 35 | 36 | # From now on, any request coming from the above range will be blocked. 37 | ``` 38 | -------------------------------------------------------------------------------- /flask_ipblock/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import request, url_for 2 | from flask_ipblock.documents import IPNetwork 3 | 4 | 5 | class IPBlock(object): 6 | 7 | def __init__(self, app, read_preference=None, cache_size=None, cache_ttl=None, 8 | blocking_enabled=True, logging_enabled=False): 9 | """ 10 | Initialize IPBlock and set up a before_request handler in the 11 | app. 12 | 13 | You can override the default MongoDB read preference via the 14 | optional read_preference kwarg. 15 | 16 | You can limit the impact of the IP checks on your MongoDB by 17 | maintaining a local in-memory LRU cache. To do so, specify its 18 | cache_size (i.e. max number of IP addresses it can store) and 19 | cache_ttl (i.e. how many seconds each result should be cached 20 | for). 21 | 22 | To run in dry-run mode without blocking requests, set 23 | blocking_enabled to False. Set logging_enabled to True 24 | to log IPs that match blocking rules -- if enabled, will 25 | log even if blocking_enabled is False. 26 | """ 27 | self.read_preference = read_preference 28 | self.blocking_enabled = blocking_enabled 29 | self.logger = None 30 | if logging_enabled: 31 | self.logger = app.logger 32 | self.block_msg = "blocking" if blocking_enabled else "blocking disabled" 33 | 34 | if cache_size and cache_ttl: 35 | # inline import because cachetools dependency is optional. 36 | from cachetools import TTLCache 37 | self.cache = TTLCache(cache_size, cache_ttl) 38 | else: 39 | self.cache = None 40 | 41 | app.before_request(self.block_before) 42 | 43 | def block_before(self): 44 | """ 45 | Check the current request and block it if the IP address it's 46 | coming from is blacklisted. 47 | """ 48 | # To avoid unnecessary database queries, ignore the IP check for 49 | # requests for static files 50 | if request.path.startswith(url_for('static', filename='')): 51 | return 52 | 53 | # Some static files might be served from the root path (e.g. 54 | # favicon.ico, robots.txt, etc.). Ignore the IP check for most 55 | # common extensions of those files. 56 | ignored_extensions = ('ico', 'png', 'txt', 'xml') 57 | if request.path.rsplit('.', 1)[-1] in ignored_extensions: 58 | return 59 | 60 | ips = request.headers.getlist('X-Forwarded-For') 61 | if not ips: 62 | return 63 | 64 | # If the X-Forwarded-For header contains multiple comma-separated 65 | # IP addresses, we're only interested in the last one. 66 | ip = ips[0].strip() 67 | if ip[-1] == ',': 68 | ip = ip[:-1] 69 | ip = ip.rsplit(',', 1)[-1].strip() 70 | 71 | if self.matches_ip(ip): 72 | if self.logger is not None: 73 | self.logger.info("IPBlock: matched {}, {}".format(ip, self.block_msg)) 74 | if self.blocking_enabled: 75 | return 'IP Blocked', 200 76 | 77 | def matches_ip(self, ip): 78 | """Return True if the given IP is blacklisted, False otherwise.""" 79 | 80 | # Check the cache if caching is enabled 81 | if self.cache is not None: 82 | matches_ip = self.cache.get(ip) 83 | if matches_ip is not None: 84 | return matches_ip 85 | 86 | # Query MongoDB to see if the IP is blacklisted 87 | matches_ip = IPNetwork.matches_ip( 88 | ip, read_preference=self.read_preference) 89 | 90 | # Cache the result if caching is enabled 91 | if self.cache is not None: 92 | self.cache[ip] = matches_ip 93 | 94 | return matches_ip 95 | -------------------------------------------------------------------------------- /flask_ipblock/documents.py: -------------------------------------------------------------------------------- 1 | import netaddr 2 | from mongoengine import Document, StringField, IntField, BooleanField 3 | 4 | 5 | class IPNetwork(Document): 6 | """ 7 | Represents an IP (v4 or v6) network as 2 integers, the starting address 8 | and the stopping address, inclusive. 9 | """ 10 | label = StringField(required=False) 11 | start = IntField(required=True) 12 | stop = IntField(required=True) 13 | whitelist = BooleanField(default=False) 14 | 15 | meta = { 16 | 'indexes': [('start', 'stop', 'whitelist')] 17 | } 18 | 19 | @classmethod 20 | def create_from_string(cls, cidr, label=None, whitelist=False): 21 | """ 22 | Converts a CIDR like 192.168.0.0/24 into 2 parts: 23 | start: 3232235520 24 | stop: 3232235775 25 | """ 26 | network = netaddr.IPNetwork(cidr) 27 | start = network.first 28 | stop = start + network.size - 1 29 | obj = cls.objects.create(label=label, start=start, stop=stop, 30 | whitelist=whitelist) 31 | return obj 32 | 33 | def __unicode__(self): 34 | return "%s: %s - %s" % ( 35 | self.label, 36 | str(netaddr.IPAddress(self.start)), 37 | str(netaddr.IPAddress(self.stop))) 38 | 39 | @classmethod 40 | def qs_for_ip(cls, ip_str): 41 | """ 42 | Returns a queryset with matching IPNetwork objects for the given IP. 43 | """ 44 | ip = int(netaddr.IPAddress(ip_str)) 45 | 46 | # ignore IPv6 addresses for now (4294967295 is 0xffffffff, aka the 47 | # biggest 32-bit number) 48 | if ip > 4294967295: 49 | return cls.objects.none() 50 | 51 | ip_range_query = { 52 | 'start__lte': ip, 53 | 'stop__gte': ip 54 | } 55 | 56 | return cls.objects.filter(**ip_range_query) 57 | 58 | @classmethod 59 | def matches_ip(cls, ip_str, read_preference=None): 60 | """ 61 | Return True if provided IP exists in the blacklist and doesn't exist 62 | in the whitelist. Otherwise, return False. 63 | """ 64 | qs = cls.qs_for_ip(ip_str).only('whitelist') 65 | if read_preference: 66 | qs = qs.read_preference(read_preference) 67 | 68 | # Return True if any docs match the IP and none of them represent 69 | # a whitelist 70 | return bool(qs) and not any(obj.whitelist for obj in qs) 71 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e git+ssh://git@github.com/closeio/mongoengine.git#egg=mongoengine-dev 2 | Flask==0.9 3 | netaddr==0.7.10 4 | pymongo==2.8 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='flask-ipblock', 5 | version='0.3', 6 | url='http://github.com/closeio/flask-ipblock', 7 | license='MIT', 8 | description='Block certain IP addresses from accessing your Flask app', 9 | platforms='any', 10 | classifiers=[ 11 | 'Intended Audience :: Developers', 12 | 'Operating System :: OS Independent', 13 | 'Topic :: Software Development :: Libraries :: Python Modules', 14 | 'Programming Language :: Python', 15 | 'Programming Language :: Python :: 2', 16 | ], 17 | packages=[ 18 | 'flask_ipblock', 19 | ], 20 | install_requires=[ 21 | 'Flask', 22 | 'mongoengine' 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask_ipblock.documents import IPNetwork 4 | 5 | from mongoengine.connection import connect 6 | 7 | connect('testdb') 8 | 9 | 10 | class IPBlockTestCase(unittest.TestCase): 11 | 12 | def setUp(self): 13 | IPNetwork.drop_collection() 14 | 15 | def test_blacklist_specific_ip(self): 16 | IPNetwork.create_from_string('192.168.1.23/32') 17 | self.assertTrue(IPNetwork.matches_ip('192.168.1.23')) 18 | self.assertFalse(IPNetwork.matches_ip('192.168.1.22')) 19 | self.assertFalse(IPNetwork.matches_ip('192.168.1.24')) 20 | self.assertFalse(IPNetwork.matches_ip('192.168.100.23')) 21 | 22 | def test_blacklist_ip_range(self): 23 | IPNetwork.create_from_string('192.168.1.0/24') 24 | for i in range(256): 25 | self.assertTrue(IPNetwork.matches_ip('192.168.1.%d' % i)) 26 | self.assertFalse(IPNetwork.matches_ip('192.168.2.1')) 27 | 28 | def test_whitelist_ip(self): 29 | # blacklist the whole range 30 | IPNetwork.create_from_string('192.168.1.0/24') 31 | 32 | # whitelist a single address 33 | IPNetwork.create_from_string('192.168.1.100/32', whitelist=True) 34 | 35 | for i in range(256): 36 | match = IPNetwork.matches_ip('192.168.1.%d' % i) 37 | if i == 100: 38 | self.assertFalse(match) 39 | else: 40 | self.assertTrue(match) 41 | 42 | def test_ipv6(self): 43 | """Make sure an IPv6 address is ignored gracefully for now.""" 44 | self.assertFalse(IPNetwork.matches_ip('2604:a880:800:10::ff:1')) 45 | 46 | 47 | if __name__ == '__main__': 48 | unittest.main() 49 | --------------------------------------------------------------------------------