diff --git a/README.md b/README.md index a7051af..e44ba20 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,7 @@ If you are behind two proxies, use the second to last address, and so on. * `BLACKLIST_ADDRESS_SOURCE` - the source of client addresses; can be a key in `request.META`, a callable that receives the request object, or the dotted string path to such a callable; default: `'REMOTE_ADDR'` +* `BLACKLIST_EXCLUDED_ADDRESSES` - a list of addresses that should not be blacklisted; + default: `[]` +* `BLACKLIST_EXCLUDED_USERS` - a list of users that should not be blacklisted; + default: `[]` diff --git a/blacklist/__init__.py b/blacklist/__init__.py index a71c5c7..49e0fc1 100644 --- a/blacklist/__init__.py +++ b/blacklist/__init__.py @@ -1 +1 @@ -__version__ = '0.7.0' +__version__ = "0.7.0" diff --git a/blacklist/admin.py b/blacklist/admin.py index 280bf33..7eb7dae 100644 --- a/blacklist/admin.py +++ b/blacklist/admin.py @@ -4,9 +4,17 @@ from .models import Rule class RuleAdmin(admin.ModelAdmin): - list_display = ('id', 'user', 'address', 'prefixlen', 'created', 'duration', 'is_active') - list_filter = ('created', 'duration') - search_fields = ('user__username', 'address', 'comments') + list_display = ( + "id", + "user", + "address", + "prefixlen", + "created", + "duration", + "is_active", + ) + list_filter = ("created", "duration") + search_fields = ("user__username", "address", "comments") admin.site.register(Rule, RuleAdmin) diff --git a/blacklist/management/commands/trim_blacklist.py b/blacklist/management/commands/trim_blacklist.py index 057d617..50557d6 100644 --- a/blacklist/management/commands/trim_blacklist.py +++ b/blacklist/management/commands/trim_blacklist.py @@ -8,27 +8,31 @@ from ...models import Rule class Command(BaseCommand): - help = 'Trims the blacklist' + help = "Trims the blacklist" def add_arguments(self, parser): - parser.add_argument('-c', '--created', type=int, default=0, - help='created days ago; default: 0') - parser.add_argument('-e', '--expired', type=int, default=0, - help='expired days ago; default: 0') + parser.add_argument( + "-c", "--created", type=int, default=0, help="created days ago; default: 0" + ) + parser.add_argument( + "-e", "--expired", type=int, default=0, help="expired days ago; default: 0" + ) def handle(self, *args, **options): - self.stdout.write('Deleting expired rules') + self.stdout.write("Deleting expired rules") - created_age = timedelta(days=options['created']) - expired_age = timedelta(days=options['expired']) + created_age = timedelta(days=options["created"]) + expired_age = timedelta(days=options["expired"]) current_time = now() - rules = Rule.objects.filter(created__lte=(current_time - F('duration'))) + rules = Rule.objects.filter(created__lte=(current_time - F("duration"))) rules = rules.filter(created__lte=(current_time - created_age)) - rules = rules.filter(created__lte=(current_time - expired_age - F('duration'))) + rules = rules.filter(created__lte=(current_time - expired_age - F("duration"))) deleted = rules.delete() - num_deleted = deleted[1].get('%s.%s' % (Rule._meta.app_label, Rule._meta.object_name), 0) + num_deleted = deleted[1].get( + "%s.%s" % (Rule._meta.app_label, Rule._meta.object_name), 0 + ) - self.stdout.write(self.style.SUCCESS('Deleted %d rule(s).' % num_deleted)) + self.stdout.write(self.style.SUCCESS("Deleted %d rule(s)." % num_deleted)) diff --git a/blacklist/middleware.py b/blacklist/middleware.py index f98e2e6..34a7abf 100644 --- a/blacklist/middleware.py +++ b/blacklist/middleware.py @@ -18,11 +18,13 @@ from .models import Rule logger = logging.getLogger(__name__) -_RELOAD_PERIOD = timedelta(seconds=getattr(settings, 'BLACKLIST_RELOAD_PERIOD', 60)) +_RELOAD_PERIOD = timedelta(seconds=getattr(settings, "BLACKLIST_RELOAD_PERIOD", 60)) _user_blacklist: Dict[int, datetime] = {} -_addr_blacklist: Dict[Optional[int], Dict[Union[ipaddress.IPv4Network, ipaddress.IPv6Network], datetime]] = {} +_addr_blacklist: Dict[ + Optional[int], Dict[Union[ipaddress.IPv4Network, ipaddress.IPv6Network], datetime] +] = {} _loaded: Optional[datetime] = None @@ -35,7 +37,7 @@ class Blacklisted(SuspiciousOperation): class BlacklistMiddleware(MiddlewareMixin): def process_request(self, request): - if getattr(settings, 'BLACKLIST_ENABLE', True): + if getattr(settings, "BLACKLIST_ENABLE", True): current_time = now() if _needs_reload(current_time): @@ -45,13 +47,13 @@ class BlacklistMiddleware(MiddlewareMixin): _filter_client(request, current_time) except Blacklisted as exception: - template_name = getattr(settings, 'BLACKLIST_TEMPLATE', None) + template_name = getattr(settings, "BLACKLIST_TEMPLATE", None) if template_name: - if getattr(settings, 'BLACKLIST_LOGGING_ENABLE', True): + if getattr(settings, "BLACKLIST_LOGGING_ENABLE", True): logger.warning(exception) - context = {'request': request, 'exception': exception} + context = {"request": request, "exception": exception} return render(request, template_name, context, status=400) raise @@ -65,20 +67,30 @@ def _filter_client(request, current_time): # no logging here, because the event will be logged either by the caller, or by django.request + # check if the user in the excluded list + execluded_users = getattr(settings, "BLACKLIST_EXCLUDED_USERS", []) + if user in execluded_users: + return + + # check if the address in the excluded list + execluded_addresses = getattr(settings, "BLACKLIST_EXCLUDED_ADDRESSES", []) + if addr in execluded_addresses: + return + until = _user_blacklist.get(user_id) if until is not None and until > current_time: - raise Blacklisted('Blacklisted user: %s' % user.username) + raise Blacklisted("Blacklisted user: %s" % user.username) for prefixlen, blacklist in _addr_blacklist.items(): network = Rule(address=addr, prefixlen=prefixlen).get_network() until = blacklist.get(network) if until is not None and until > current_time: - raise Blacklisted('Blacklisted address: %s' % addr) + raise Blacklisted("Blacklisted address: %s" % addr) def _get_client_address(request): - source = getattr(settings, 'BLACKLIST_ADDRESS_SOURCE', 'REMOTE_ADDR') + source = getattr(settings, "BLACKLIST_ADDRESS_SOURCE", "REMOTE_ADDR") if source in request.META: addr = request.META[source] @@ -86,20 +98,20 @@ def _get_client_address(request): elif callable(source): addr = source(request) - elif isinstance(source, str) and '.' in source: + elif isinstance(source, str) and "." in source: func = import_string(source) addr = func(request) else: raise ImproperlyConfigured( - 'Unable to obtain the client address. ' - 'Please see the documentation of the BLACKLIST_ADDRESS_SOURCE setting.' + "Unable to obtain the client address. " + "Please see the documentation of the BLACKLIST_ADDRESS_SOURCE setting." ) if not addr: raise ImproperlyConfigured( - 'The client address is empty. ' - 'Please check the BLACKLIST_ADDRESS_SOURCE setting.' + "The client address is empty. " + "Please check the BLACKLIST_ADDRESS_SOURCE setting." ) return addr @@ -116,20 +128,26 @@ def _load_blacklist(): current_time = now() if _needs_reload(current_time): - until = Max(F('created') + F('duration'), output_field=DateTimeField()) - rules = Rule.objects.values('user_id', 'address', 'prefixlen').annotate(until=until) + until = Max(F("created") + F("duration"), output_field=DateTimeField()) + rules = Rule.objects.values("user_id", "address", "prefixlen").annotate( + until=until + ) rules = rules.filter(until__gt=current_time) user_blacklist = {} addr_blacklist = {} for rule in rules: - user_id = rule['user_id'] - prefixlen = rule['prefixlen'] - network = Rule(address=rule['address'], prefixlen=prefixlen).get_network() - until = rule['until'] - - _add_client(user_blacklist, addr_blacklist, user_id, prefixlen, network, until) + user_id = rule["user_id"] + prefixlen = rule["prefixlen"] + network = Rule( + address=rule["address"], prefixlen=prefixlen + ).get_network() + until = rule["until"] + + _add_client( + user_blacklist, addr_blacklist, user_id, prefixlen, network, until + ) _user_blacklist = user_blacklist _addr_blacklist = addr_blacklist diff --git a/blacklist/migrations/0001_initial.py b/blacklist/migrations/0001_initial.py index 8d833b7..7379275 100644 --- a/blacklist/migrations/0001_initial.py +++ b/blacklist/migrations/0001_initial.py @@ -17,16 +17,51 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Rule', + name="Rule", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('address', models.GenericIPAddressField(blank=True, null=True)), - ('prefixlen', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), - ('duration', models.DurationField(validators=[django.core.validators.MinValueValidator(datetime.timedelta(0))])), - ('comments', models.TextField(blank=True, max_length=2048)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("address", models.GenericIPAddressField(blank=True, null=True)), + ( + "prefixlen", + models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(128), + ], + ), + ), + ( + "duration", + models.DurationField( + validators=[ + django.core.validators.MinValueValidator( + datetime.timedelta(0) + ) + ] + ), + ), + ("comments", models.TextField(blank=True, max_length=2048)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), ] diff --git a/blacklist/models.py b/blacklist/models.py index 2ebbe24..044b70f 100644 --- a/blacklist/models.py +++ b/blacklist/models.py @@ -12,13 +12,20 @@ class Rule(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE + ) address = models.GenericIPAddressField(null=True, blank=True) - prefixlen = models.PositiveIntegerField(null=True, blank=True, - validators=[validators.MinValueValidator(0), validators.MaxValueValidator(128)]) + prefixlen = models.PositiveIntegerField( + null=True, + blank=True, + validators=[validators.MinValueValidator(0), validators.MaxValueValidator(128)], + ) - duration = models.DurationField(validators=[validators.MinValueValidator(timedelta(0))]) + duration = models.DurationField( + validators=[validators.MinValueValidator(timedelta(0))] + ) comments = models.TextField(max_length=2048, blank=True) @@ -30,7 +37,7 @@ class Rule(models.Model): if addr: if self.prefixlen is not None: - addr += f'/{self.prefixlen}' + addr += f"/{self.prefixlen}" return ipaddress.ip_network(addr, strict=False) else: @@ -48,7 +55,7 @@ class Rule(models.Model): self.address = None if self.address is None and self.prefixlen is not None: - raise ValidationError('Prefixlen without address.') + raise ValidationError("Prefixlen without address.") try: self.get_network() @@ -57,7 +64,7 @@ class Rule(models.Model): raise ValidationError(str(ve)) if self.user is None and self.address is None: - raise ValidationError('Neither user nor address provided.') + raise ValidationError("Neither user nor address provided.") if self.user is not None and self.address is not None: - raise ValidationError('Both user and address provided.') + raise ValidationError("Both user and address provided.") diff --git a/blacklist/ratelimit.py b/blacklist/ratelimit.py index ac0491c..cf7834a 100644 --- a/blacklist/ratelimit.py +++ b/blacklist/ratelimit.py @@ -18,8 +18,9 @@ def blacklist_ratelimited(duration, block=True): @wraps(fn) def wrapper(request, *args, **kwargs): if request.limited: - if getattr(settings, 'BLACKLIST_ENABLE', True) \ - and getattr(settings, 'BLACKLIST_RATELIMITED_ENABLE', True): + if getattr(settings, "BLACKLIST_ENABLE", True) and getattr( + settings, "BLACKLIST_RATELIMITED_ENABLE", True + ): if user_duration and request.user.is_authenticated: _create_user_rule(request, user_duration) @@ -27,7 +28,7 @@ def blacklist_ratelimited(duration, block=True): _create_ip_rule(request, ip_duration) else: - logger.warning('Unable to blacklist ratelimited client.') + logger.warning("Unable to blacklist ratelimited client.") if block: raise Ratelimited() @@ -62,10 +63,12 @@ def _create_ip_rule(request, duration): def _create_comments(request): - request_id = getattr(request, 'id', '') - comments = 'Automatically blacklisted ratelimited client.\n' \ - f'Request ID: {request_id}\n' \ - f'Request line: {request.method} {request.get_full_path()}\n' + request_id = getattr(request, "id", "") + comments = ( + "Automatically blacklisted ratelimited client.\n" + f"Request ID: {request_id}\n" + f"Request line: {request.method} {request.get_full_path()}\n" + ) return comments diff --git a/setup.py b/setup.py index 9225e02..43dca8a 100644 --- a/setup.py +++ b/setup.py @@ -5,46 +5,35 @@ from setuptools import setup, find_packages import blacklist -with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: +with open(os.path.join(os.path.dirname(__file__), "README.md")) as readme: README = readme.read() packages = find_packages() -packages.extend(['blacklist.management', 'blacklist.management.commands']) +packages.extend(["blacklist.management", "blacklist.management.commands"]) setup( - name='django-blacklist', - + name="django-blacklist", version=blacklist.__version__, - - description='Blacklist users and hosts in Django. Automatically blacklist rate-limited clients.', + description="Blacklist users and hosts in Django. Automatically blacklist rate-limited clients.", long_description=README, - long_description_content_type='text/markdown', - - url='https://github.com/vsemionov/django-blacklist', - - author='Victor Semionov', - author_email='vsemionov@gmail.com', - - license='MIT', - + long_description_content_type="text/markdown", + url="https://github.com/vsemionov/django-blacklist", + author="Victor Semionov", + author_email="vsemionov@gmail.com", + license="MIT", classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Security' + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Security", ], - - keywords='django blacklist ratelimit firewall', - + keywords="django blacklist ratelimit firewall", packages=packages, - - install_requires=[ - 'Django' - ] + install_requires=["Django"], )