Commit 30dbda91 authored by Roland Denis's avatar Roland Denis
Browse files

Adding anti-flood system to job offer form.

parent 1da19358
Pipeline #4407 passed with stages
in 53 seconds
......@@ -46,6 +46,7 @@ apache-dev:
- echo "Publishing to $PUBLISH_DIR"
- rsync -av --delete --exclude 'attachments' output/ $PUBLISH_DIR/
- rsync -av --delete content/attachments/ $PUBLISH_DIR/attachments/
- chmod a+w $PUBLISH_DIR/job_offers/flood
apache:
stage: deploy
......@@ -61,6 +62,7 @@ apache:
- echo "Publishing to $PUBLISH_DIR"
- rsync -av --delete --exclude 'attachments' output/ $PUBLISH_DIR/
- rsync -av --delete content/attachments/ $PUBLISH_DIR/attachments/
- chmod a+w $PUBLISH_DIR/job_offers/flood
update-issue:
stage: post-deploy
......
......@@ -79,6 +79,9 @@ import itertools
import pprint
import mimetypes
import magic
import hashlib
import pickle
import uuid
###############################################################################
# General configuration
......@@ -101,6 +104,18 @@ PELICAN_JOB_OFFER_PATH = 'content/job_offers'
ATTACHMENT_MIME_TYPE = ['application/pdf']
JOBOFFER_EXPIRATION_DELAY = datetime.timedelta(weeks=3*4)
# The folder where files associated to each submission are created.
# Should be a folder that depends on the current website to avoid collisions.
FLOOD_PATH = "./flood"
# Global flood limits (for all submissions)
FLOOD_GLOBAL_TIMEOUT = datetime.timedelta(minutes=10)
FLOOD_GLOBAL_LIMIT = 10
# Local flood limits (for one filler)
FLOOD_LOCAL_TIMEOUT = datetime.timedelta(minutes=5)
FLOOD_LOCAL_LIMIT = 3
###############################################################################
class Debug(object):
""" Debugging parameters. """
......@@ -110,11 +125,155 @@ class Debug(object):
self.offline = offline # Do not connect to Gitlab
self.local = local # Print job offer files locally
###############################################################################
class FloodChecker():
""" Anti-flood protection """
class FloodStats():
""" Local or global flood stats """
def __init__(self):
self.first = datetime.datetime.max
self.last = datetime.datetime.min
self.cnt = 0
def add(self, dt):
self.first = min(self.first, dt)
self.last = max(self.last, dt)
self.cnt += 1
def __repr__(self):
return "FloodStats(first={}, last={}, cnt={})".format(
pprint.pformat(self.first),
pprint.pformat(self.last),
self.cnt
)
def __init__(self):
self.dt_now = datetime.datetime.now() # To have a consistent current time
def _read_files(self):
""" Read submission files and clean outdated ones """
self.global_stats = self.FloodStats()
self.local_stats = dict()
# Submission file name pattern: hexdigest followed by an uuid
pattern = re.compile("(?P<datetime>[0-9-]+)_(?P<id>[a-z0-9]+)_[a-z0-9-]+")
for entry in os.scandir(FLOOD_PATH):
if entry.is_file():
file_name_match = pattern.fullmatch(entry.name)
if file_name_match:
try:
file_mtime = datetime.datetime.strptime(file_name_match.group('datetime'), "%Y-%m-%d-%H-%M-%S-%f")
# Clean if outdated file
if file_mtime < self.dt_now - max(FLOOD_GLOBAL_TIMEOUT, FLOOD_LOCAL_TIMEOUT):
try:
os.remove(entry.path)
except OSError:
pass # File may have been deleted by other script instance
continue
# Add to global stats
if file_mtime >= self.dt_now - FLOOD_GLOBAL_TIMEOUT:
self.global_stats.add(file_mtime)
# Add to local stats
if file_mtime >= self.dt_now - FLOOD_LOCAL_TIMEOUT:
self.local_stats.setdefault(file_name_match.group('id'), self.FloodStats()).add(file_mtime)
except OSError:
pass # File may have been deleted by other script instance
def _client_id_hexdigest(self, client_id):
""" Return the hexadecimal digest of the given client identification data """
return hashlib.sha1(pickle.dumps(client_id)).hexdigest()
def _submission_delay(self, stats, timeout, limit):
""" Return delay before a form can be submitted (0 if no delay) """
if stats.cnt <= limit:
return datetime.timedelta()
else:
return max(datetime.timedelta(), timeout - (self.dt_now - stats.first))
def _global_submission_delay(self):
""" Return delay before a form can be submitted (0 if no delay) by anyone """
return self._submission_delay(
self.global_stats,
FLOOD_GLOBAL_TIMEOUT,
FLOOD_GLOBAL_LIMIT
)
def _local_submission_delay(self, client_id):
""" Return delay before a form can be submitted (0 if no delay) by a given client """
return self._submission_delay(
self.local_stats.get(self._client_id_hexdigest(client_id), self.FloodStats()),
FLOOD_LOCAL_TIMEOUT,
FLOOD_LOCAL_LIMIT
)
def _create_submission_file(self, client_id):
""" Add a file to register a valid submission """
# Submission file name is composed of
# - the current datetime
# - hexadecimal digest of the client identification data (to ease dump to string and increase condifendiality)
# - a random suffix to avoid generating same file name in multiple instances.
self.file_name = "{}_{}_{}".format(
datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S-%f"),
self._client_id_hexdigest(client_id),
uuid.uuid4()
)
# Touch the file
with open(os.path.join(FLOOD_PATH, self.file_name), 'w'):
pass
def _remove_submission_file(self):
""" Remove previously created submission file """
os.remove(os.path.join(FLOOD_PATH, self.file_name))
def approve_submission(self, client_id):
""" Check if a new submission can be accepted. Return bool and waiting delay. """
# First, create a submission file even if it will be deleted if submission is rejected
# It avoids flooding at the exact same time.
self._create_submission_file(client_id)
self._read_files()
# Calculate submission delays
global_delay = self._global_submission_delay()
local_delay = self._local_submission_delay(client_id)
delay = max(global_delay, local_delay)
if delay.total_seconds() == 0: # No delay => OK
return True, delay
else: # Otherwise => KO and submission file is removed
flood_details = "[FLOOD] client_id={} client_hash={} global_stats={} global_delay={} local_stats={} local_delay={}".format(
pprint.pformat(client_id),
pprint.pformat(self._client_id_hexdigest(client_id)),
pprint.pformat(self.global_stats),
pprint.pformat(global_delay),
pprint.pformat(self.local_stats),
pprint.pformat(local_delay),
)
print(flood_details, file=sys.stderr)
#TODO: mail
self._remove_submission_file()
return False, delay
###############################################################################
class FormData(object):
""" Job offer data extracted from the form. """
def __init__(self,
client_id=None, # Client identification data
title='',
description='',
author='',
......@@ -128,6 +287,7 @@ class FormData(object):
attachment_content=None,
attachment_name=''):
self.client_id = client_id
self.title = title.strip()
self.description = description.strip()
self.author = author.strip()
......@@ -568,7 +728,7 @@ def local_write_job(job_offer):
###############################################################################
def update_html_form(form_data=FormData(), errors=FormError(), debug=Debug(), internal_error=False, success=False):
def update_html_form(form_data=FormData(), errors=FormError(), debug=Debug(), internal_error=False, success=False, flood_error=False, flood_delay=None):
""" Update the form with error messages. """
from jinja2 import Environment, FileSystemLoader
......@@ -599,6 +759,8 @@ def update_html_form(form_data=FormData(), errors=FormError(), debug=Debug(), in
job_type_list=JOBOFFER_TYPE,
internal_error=internal_error,
success=success,
flood_error=flood_error,
flood_delay=flood_delay,
))
......@@ -624,6 +786,12 @@ def process_form(form_data, debug=Debug()):
update_html_form(form_data, errors, debug)
return
# Checking undergoing flood
is_flood_ok, flood_delay = FloodChecker().approve_submission(form_data.client_id)
if not is_flood_ok:
update_html_form(form_data, errors, debug, flood_error=True, flood_delay=flood_delay)
return
# Creating job offer
job_offer = JobOffer(form_data)
......@@ -728,6 +896,11 @@ def process_cmdline():
default='',
help='File attachment'
)
form_parser.add_argument(
'--client_id',
default=None,
help='Client id'
)
# Parsing arguments
args = parser.parse_args()
......@@ -760,7 +933,8 @@ def process_cmdline():
expiration=args.expiration,
description=args.description,
attachment_name=attachment_name,
attachment_content=attachment_content
attachment_content=attachment_content,
client_id=args.client_id,
)
# Continue submission process
......@@ -818,6 +992,9 @@ def process_cgi():
form_data.attachment_name = file_item.filename
form_data.attachment_content = file_item.file.read(-1)
# Client identification data (IP)
form_data.client_id = os.getenv("REMOTE_ADDR")
# Checking if the form was submitted
if 'submit' in cgi_form:
# Continue submission process
......
<Files "*">
Order Allow,Deny
Deny from all
</Files>
......@@ -38,6 +38,13 @@
</p>
{{ ' {% endif %} ' }}
{{ ' {% if flood_error %} ' }}
<p class="error">
À cause d'un nombre important de soumissions récentes, nous vous prions d'attendre {{ '{{ (flood_delay.total_seconds() / 60) | round(0, "ceil") | int }}' }} minute(s) avant de soumettre cette offre.
Si le problème persiste, contactez-nous à <a href="mailto:calcul-contact@math.cnrs.fr">calcul-contact@math.cnrs.fr</a>.
</p>
{{ ' {% endif %} ' }}
{{ ' {% if errors.general %} <p class="error">{{ errors.general }}</p>{% endif %} ' }}
<p><span class="error">Les informations obligatoires sont indiquées par une *.</span></p>
......
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