Commit 5962f70a authored by MariaV's avatar MariaV
Browse files

Merge branch 'rate_limiting' into 'master'

Rate-Limit Update

See merge request !81
parents f3ca2165 39df128d
{% extends 'shared/layout.html' %}
{% load static %}
{% block h3 %}
Anon Ticket
{% endblock %}
{% block subheader %}
Anonymous Ticket Reporting for GitLab
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12 pr-5">
<p class="pr-5 alert alert-primary">Access Denied: 403 Forbidden. (Too Many Attempts)</p>
<p class="pr-5">
If you're seeing this page, it's because we've received too many attempts to create
issues, notes, or other GitLab objects in a short period of time. This sometimes
happens if the system is under attack or being spammed.
</p>
<p class="pr-5">
If you are trying to make a legitimate issue, note, or Gitlab account request,
<mark>please use your browser's back button</mark> to return to your form.
Your form should still be filled out, and you can attempt to resubmit
after waiting a few minutes.
</p>
<p class="pr-5">
Sorry for any inconvenience caused!
</p>
</div>
</div>
{% endblock %}
\ No newline at end of file
......@@ -13,8 +13,7 @@ from anonticket.forms import (
CreateIssueForm)
import pprint
pp = pprint.PrettyPrinter(indent=4)
# Create your tests here.
from django.core.cache import cache
# Note: If you run tests with --tag prefix, you can test a small suite
# of tests with one of the tags below (registered with '@tag'.)
......@@ -22,6 +21,30 @@ pp = pprint.PrettyPrinter(indent=4)
# $ python manage.py test --tag url
# (or with coverage) $ coverage run manage.py --tag url.)
# ---------------------CUSTOM TEST FUNCTIONS----------------------------
# URL Tests using Django SimpleTestCase (no need for database.)
# ----------------------------------------------------------------------
def get_testing_limit_rate():
"""Returns the number of requests (numerator) from
settings.LIMIT_RATE and adds 1 so that rate limiting will fail.)"""
limit_rate = settings.LIMIT_RATE
limit_list = limit_rate.split('/')
limit_numerator = limit_list[0]
limit_numerator = int(limit_numerator)
limit_numerator += 1
return limit_numerator
def run_rate_limit_test(self, client, url, form, form_data, follow=False):
"""Run a rate limit test for a post view."""
rate_limit_numerator = get_testing_limit_rate()
tries = 0
while tries < rate_limit_numerator:
response = self.client.post(
path=url, form=form, data=form_data, follow=False)
tries += 1
return response
# ---------------------------URL TESTS----------------------------------
# URL Tests using Django SimpleTestCase (no need for database.)
# ----------------------------------------------------------------------
......@@ -673,7 +696,7 @@ class TestIssuesViews(TestCase):
self.assertTemplateUsed(response, 'anonticket/issue_detail.html')
def test_issue_search_view_GET_valid_data(self):
"""Test the reponse for the issue_search_view"""
"""Test the response for the issue_search_view"""
url = reverse('issue-search', args=[self.new_user])
form_data = {
'choose_project': self.project.pk,
......@@ -694,7 +717,7 @@ class TestIssuesViews(TestCase):
self.assertTemplateUsed(response, 'anonticket/issue_search.html')
def test_issue_search_view_GET_no_matches(self):
"""Test the reponse for the issue_search_view"""
"""Test the response for the issue_search_view"""
url = reverse('issue-search', args=[self.new_user])
form_data = {
'choose_project': self.project.pk,
......@@ -702,7 +725,48 @@ class TestIssuesViews(TestCase):
}
response = self.client.get(url, form_data)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'anonticket/issue_search.html')
self.assertTemplateUsed(response, 'anonticket/issue_search.html')
def tearDown(self):
"""Clear Cache"""
cache.clear()
@tag('rate-limit-issue')
class TestIssueRateLimit(TestCase):
"""Test the rate-limit function for create_new_issue_view."""
def setUp(self):
"""Set up a project, user identifier, and issue in the test database."""
# Setup project
new_project = Project(gitlab_id=747)
# Should fetch gitlab details on save.
new_project.save()
# Create a user
new_user = UserIdentifier.objects.create(
user_identifier = 'duo-atlas-hypnotism-curry-creatable-rubble'
)
self.client=Client()
self.create_issue_url = reverse('create-issue', args=[new_user])
self.new_user = new_user
self.project = new_project
def test_create_issue_POST_new_user_RATE_LIMIT(self):
"""Test rate limit decorators and make exceeding leads to 403."""
form_data = {
'linked_project': self.project.pk,
'title':'A new descriptive issue title',
'description': 'Yet another description of the issue.'
}
form=CreateIssueForm(form_data)
response = run_rate_limit_test(
self, self.client, self.create_issue_url, form, form_data
)
self.assertEqual(response.status_code, 403)
self.assertTemplateUsed('anonticket/rate_limit.html')
def tearDown(self):
"""Clear Cache"""
cache.clear()
@tag('notes')
class TestNotesViews(TestCase):
......@@ -757,13 +821,63 @@ class TestNotesViews(TestCase):
user that doesn't get exist."""
new_user = 'autopilot-stunt-unfasten-dirtiness-wipe-blissful'
url = reverse('create-note', args=[
new_user, self.project.slug, self.issue.gitlab_iid])
new_user, self.project.slug, self.issue.gitlab_iid]
)
form_data = {
'body': """A new note body."""
}
expected_url = reverse('issue-created', args=[new_user])
response = self.client.post(url, form_data)
self.assertRedirects(response, expected_url)
def tearDown(self):
"""Clear Cache"""
cache.clear()
@tag('rate-limit-note')
class TestNoteViewRateLimit(TestCase):
"""Test the ratelimiting for NoteCreateView."""
def setUp(self):
"""Set up a project, user identifier, and issue in the test database."""
# Setup project
new_project = Project(gitlab_id=747)
# Should fetch gitlab details on save.
new_project.save()
# Create a user
new_user = UserIdentifier.objects.create(
user_identifier = 'duo-atlas-hypnotism-curry-creatable-rubble'
)
# Create a posted issue.
posted_issue = Issue.objects.create (
title = 'A posted issue',
description = 'A posted issue description',
linked_project = new_project,
linked_user = new_user,
gitlab_iid = 1,
reviewer_status = 'A',
posted_to_GitLab = True
)
self.client=Client()
self.new_user = new_user
self.project = new_project
self.issue = posted_issue
def test_note_create_view_POST_RATE_LIMIT(self):
"""Test rate limit decorators for note crate view."""
url = reverse('create-note', args=[
self.new_user, self.project.slug, self.issue.gitlab_iid])
form_data = {
'body': """A new note body."""
}
form=CreateIssueForm(form_data)
response = run_rate_limit_test(self, self.client, url, form, form_data)
self.assertEqual(response.status_code, 403)
self.assertTemplateUsed('anonticket/rate_limit.html')
def tearDown(self):
"""Clear Cache"""
cache.clear()
@tag('gitlab')
class TestGitlabAccountRequestViews(TestCase):
......
from __future__ import absolute_import
import gitlab
import functools
from django.conf import settings
......@@ -16,6 +17,11 @@ from django.contrib.auth.decorators import user_passes_test
from django.views.generic import (
TemplateView, DetailView, ListView, CreateView, FormView, UpdateView)
from django.contrib.admin.views.decorators import staff_member_required
from ratelimit.decorators import ratelimit
from functools import wraps
from ratelimit import UNSAFE
from ratelimit.exceptions import Ratelimited
from ratelimit.core import is_ratelimited
# ---------------SHARED FUNCTIONS, NON GITLAB---------------------------
# Functions that need to be accessed from within multiple views go here,
......@@ -81,7 +87,8 @@ def check_user(user_identifier):
return True
# --------------------DECORATORS AND MIXINS-----------------------------
# Django decorators wrap functions (such as views) in other functions.
# Mixins perform a similar function for class based views.
# Mixins perform a similar function for class based views.
# Decorates for rate-limiting are below in RATE-LIMITING SETTINGS.
# ----------------------------------------------------------------------
def validate_user(view_func):
......@@ -106,6 +113,102 @@ class PassUserIdentifierMixin:
context['results'] = {'user_identifier':self.kwargs['user_identifier']}
return context
# --------------------RATE-LIMITING SETTINGS----------------------------
# Set variables here so that django-ratelimit settings can be
# changed across multiple views.
# ----------------------------------------------------------------------
# All items (groups/issues) currently share from same rate-limiting
# bucket, which is set by RATE_GROUP. You can change this by using a
# different group name in the decorator or by not including a group name
# with requests. The custom decorators below allow rate-limiting by
# IP and by key:post with Tor preferred settings applied.
RATE_GROUP = settings.MAIN_RATE_GROUP
BLOCK_ALL = settings.BLOCK_ALL
def custom_ratelimit_ip(
group=RATE_GROUP,
key='ip',
rate=None,
method=ratelimit.UNSAFE,
block=True,
block_all = BLOCK_ALL,
):
"""Custom version of the @ratelimit decorator based on key='ip' and
callable rate function."""
__all__ = ['ratelimit']
ratelimit.UNSAFE = UNSAFE
def get_rate_limit(group, request, block_all=False):
"""Callable function to get rate limit to make testing easier."""
if block_all == True:
return '0/s'
else:
return settings.LIMIT_RATE
def decorator(fn):
@wraps(fn)
def _wrapped(request, *args, **kw):
old_limited = getattr(request, 'limited', False)
ratelimited = is_ratelimited(
request=request,
group=group,
fn=fn,
key=key,
rate=get_rate_limit(
group=group, request=request, block_all=block_all),
method=method,
increment=True)
request.limited = ratelimited or old_limited
if ratelimited and block:
raise Ratelimited()
return fn(request, *args, **kw)
return _wrapped
return decorator
def custom_ratelimit_post(
group=RATE_GROUP,
key='post:',
rate=None,
method=ratelimit.UNSAFE,
block=True,
block_all = BLOCK_ALL,
):
"""Custom version of the @ratelimit decorator with key='post' and
callable rate function."""
__all__ = ['ratelimit']
ratelimit.UNSAFE = UNSAFE
def get_rate_limit(group, request, block_all=False):
"""Callable function to get rate limit to make testing easier."""
if block_all == True:
return '0/s'
else:
return settings.LIMIT_RATE
def decorator(fn):
@wraps(fn)
def _wrapped(request, *args, **kw):
old_limited = getattr(request, 'limited', False)
ratelimited = is_ratelimited(
request=request,
group=group,
fn=fn,
key=key,
rate=get_rate_limit(
group=group, request=request, block_all=block_all),
method=method,
increment=True)
request.limited = ratelimited or old_limited
if ratelimited and block:
raise Ratelimited()
return fn(request, *args, **kw)
return _wrapped
return decorator
# ------------------SHARED FUNCTIONS, GITLAB---------------------------
# Easy to parse version of GitLab-Python functions.
# ----------------------------------------------------------------------
......@@ -571,6 +674,8 @@ class ProjectDetailView(DetailView):
# ----------------------------------------------------------------------
@validate_user
@custom_ratelimit_post()
@custom_ratelimit_ip()
def create_issue_view(request, user_identifier, *args):
"""View that allows a user to create an issue. Pulls the user_identifier
from the URL path and tries to pull that UserIdentifier from database,
......@@ -671,6 +776,8 @@ def issue_search_view(request, user_identifier):
# ----------------------------------------------------------------------
@method_decorator(validate_user, name='dispatch')
@method_decorator(custom_ratelimit_ip(), name='post')
@method_decorator(custom_ratelimit_post(), name='post')
class NoteCreateView(PassUserIdentifierMixin, CreateView):
"""View to create a note given a user_identifier."""
model=Note
......
......@@ -8,7 +8,16 @@ the GitLab API (Python-Gitlab package.)<br>
## 1.0 Quickstart:
Clone the repo. Rename the env_sample.txt file to just “.env”. Open it
and delete first line that says "delete me", then set the SECRET_KEY to a string of
your choosing. Run the following commands:
your choosing.
This project uses Django-Ratelimit.
Set the LIMIT_RATE value to determine how many form post requests
to allow per time unit, or choose '0/s' to block all requests or leave as 'None'
to allow all requests. Change the value of MAIN_RATE_GROUP to a group name
of your choosing, or you can leave blank to have views limit individually.
Run the following commands:
```
1. Make a virtual environment:
......
......@@ -4,4 +4,5 @@ python-gitlab~=2.5.0
django-test-plus~=1.3
django-markdownify~=0.8.1
coverage~=5.3.1
django-ratelimit~=3.0.1
gunicorn~=20.0.4
......@@ -4,5 +4,8 @@ GITLAB_SECRET_TOKEN=TokenHere
GITLAB_ACCOUNTS_SECRET_TOKEN=TokenHere
AUTO_ACCEPT_LIST=""
GITLAB_URL=https://gitlab.torproject.org/
MAIN_RATE_GROUP =
LIMIT_RATE = None
BLOCK_ALL = False
DEBUG=True
ALLOWED_HOSTS=.localhost, 127.0.0.1, .anonticket.onionize.space,
......@@ -87,6 +87,13 @@ DATABASES = {
}
}
# Caching - necessary for Django-Ratelimit
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
......@@ -120,7 +127,6 @@ USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
......@@ -133,8 +139,11 @@ GITLAB_URL = config('GITLAB_URL', default='')
GITLAB_SECRET_TOKEN = config('GITLAB_SECRET_TOKEN', default='')
GITLAB_ACCOUNTS_SECRET_TOKEN = config('GITLAB_ACCOUNTS_SECRET_TOKEN', default='')
# Configuration settings for Django Ratelimit
MAIN_RATE_GROUP = config('MAIN_RATE_GROUP', default='')
LIMIT_RATE = config('LIMIT_RATE', default='100/m')
BLOCK_ALL = config('BLOCK_ALL', default=False, cast=bool)
# Wordlist Settings for generating wordlist
WORD_LIST_PATH = os.path.join(BASE_DIR, 'shared/wordlist.txt')
DICE_ROLLS = 6
\ No newline at end of file
......@@ -15,8 +15,21 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path, include
from ratelimit.exceptions import Ratelimited
from django.http import HttpResponse
from django.shortcuts import redirect, render
def handler403(request, exception=None):
"""Custom 403 handler for ratelimit exceptions."""
if isinstance(exception, Ratelimited):
return render(
request,
template_name='anonticket/rate_limited.html',
status=403)
return HttpResponseForbidden('Forbidden')
urlpatterns = [
path('tor_admin/', admin.site.urls),
path('', include('anonticket.urls'))
]
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment