Skip to content
Snippets Groups Projects
Commit 070fc233 authored by Bryan BRANCOTTE's avatar Bryan BRANCOTTE
Browse files

Merge branch 'extract-csp-mail-reports' into 'master'

extract django-csp-mail-reports

See merge request !243
parents d316c5e7 4d69016a
No related branches found
No related tags found
1 merge request!243extract django-csp-mail-reports
Pipeline #150596 passed
from django.apps import AppConfig
import django.core.checks
from cspmailreports.conf import app_settings
class CspmailreportsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'cspmailreports'
@django.core.checks.register()
def check_settings(app_configs, **kwargs):
errors = []
if app_settings.dos_cooldown <= 0:
errors.append(
django.core.checks.Error(
"CSP_MAIL_REPORTS_COOLDOWN_IN_SECONDS must be greater than 0",
id="cspmailreports.E001",
)
)
if app_settings.max_report_before_cooldown <= 0:
errors.append(
django.core.checks.Error(
"CSP_MAIL_REPORTS_MAX_BEFORE_COOLDOWN must be greater than 0",
id="cspmailreports.E002",
)
)
return errors
from django.conf import settings
class Settings:
def __init__(self):
self.__print_in_log = None
self.__max_report_before_cooldown = None
self.__dos_cooldown = None
def _reset_cache(self):
self.__init__()
@property
def print_in_log(self) -> bool:
if self.__print_in_log is None:
self.__print_in_log = getattr(settings, "CSP_MAIL_REPORTS_LOG", True)
return self.__print_in_log
@property
def dos_cooldown(self) -> int:
if self.__dos_cooldown is None:
self.__dos_cooldown = getattr(settings, "CSP_MAIL_REPORTS_COOLDOWN_IN_SECONDS", 30 * 60)
return self.__dos_cooldown
@property
def max_report_before_cooldown(self) -> bool:
if self.__max_report_before_cooldown is None:
self.__max_report_before_cooldown = getattr(settings, "CSP_MAIL_REPORTS_MAX_BEFORE_COOLDOWN", 50)
return self.__max_report_before_cooldown
app_settings = Settings()
import json
import logging
from typing import Dict
from django.core import mail
from django.test import TestCase, override_settings
from django.urls import reverse
from django.urls import reverse_lazy
import cspmailreports.apps
import cspmailreports.conf
logger = logging.getLogger(__name__)
class CSPTooledTestCase(TestCase):
url = reverse_lazy('cspmailreports:csp-report')
def setUp(self):
super().setUp()
cspmailreports.conf.app_settings._reset_cache()
@staticmethod
def fake_report(referrer="http://127.0.0.1:8080") -> Dict:
return {
"csp-report": {
"blocked-uri": "inline",
"disposition": "enforce",
"document-uri": f"{referrer}/about/",
"effective-directive": "script-src-elem",
"line-number": 215,
"original-policy": "default-src 'self' *; script-src 'self' cdn.datatables.net",
"referrer": referrer,
"script-sample": "",
"source-file": f"{referrer}/about/",
"status-code": 200,
"violated-directive": "script-src-elem",
}
}
def report(self, report=None):
if report is None:
report = self.fake_report()
return self.client.post(self.url, data=json.dumps(report), content_type='application/csp-report')
class TestMain(CSPTooledTestCase):
def test_works(self):
url = reverse('cspmailreports:csp-report')
# check get ko
self.assertNotIn(self.client.get(url).status_code, [200])
# check post works
self.assertIn(self.report().status_code, [200])
def test_invalid_mime_type_refused(self):
self.assertNotIn(self.client.post(self.url, data=self.fake_report()).status_code, [200])
def test_invalid_data_accepted(self):
self.assertIn(
self.client.generic(
"POST",
self.url,
'zerzerz!sdf{',
'application/csp-report',
).status_code,
[200],
)
@override_settings(
CSP_MAIL_REPORTS_MAX_BEFORE_COOLDOWN=10,
ADMIN=(('ada', 'ada@pasteur.fr'),),
DEBUG=False,
)
class TestDOS(CSPTooledTestCase):
def test_it(self):
mail_count = len(mail.outbox)
self.client.defaults['REMOTE_ADDR'] = '1.2.3.4'
# trigger dos
for i in range(cspmailreports.conf.app_settings.max_report_before_cooldown):
self.assertIn(self.report().status_code, [200])
mail_count += 1
self.assertEqual(mail_count, len(mail.outbox))
# check blocked
self.assertIn(self.report().status_code, [429])
self.assertEqual(mail_count, len(mail.outbox))
# check other is not blocked
self.client.defaults['REMOTE_ADDR'] = '1.2.3.5'
self.assertIn(self.report().status_code, [200])
mail_count += 1
self.assertEqual(mail_count, len(mail.outbox))
@override_settings(
ADMINS=(('ada', 'ada@pasteur.fr'),),
)
class TestMailAdmin(CSPTooledTestCase):
def test_it(self):
mail_count = len(mail.outbox)
self.assertIn(self.report().status_code, [200])
mail_count += 1
self.assertEqual(mail_count, len(mail.outbox))
@override_settings(
ADMINS=(),
)
class TestMailNoAdmin(CSPTooledTestCase):
def test_it(self):
mail_count = len(mail.outbox)
self.assertIn(self.report().status_code, [200])
mail_count += 0 # in debug not mail to admin is sent
self.assertEqual(mail_count, len(mail.outbox))
@override_settings(
CSP_MAIL_REPORTS_MAX_BEFORE_COOLDOWN=-1,
)
class TestCheck1(CSPTooledTestCase):
def test_it(self):
cspmailreports.conf.app_settings._reset_cache()
self.assertEqual(len(cspmailreports.apps.check_settings(None)), 1)
@override_settings(
CSP_MAIL_REPORTS_COOLDOWN_IN_SECONDS=-1,
)
class TestCheck2(CSPTooledTestCase):
def test_it(self):
cspmailreports.conf.app_settings._reset_cache()
self.assertEqual(len(cspmailreports.apps.check_settings(None)), 1)
@override_settings(
CSP_MAIL_REPORTS_MAX_BEFORE_COOLDOWN=-1,
CSP_MAIL_REPORTS_COOLDOWN_IN_SECONDS=-1,
)
class TestCheckAll(CSPTooledTestCase):
def test_it(self):
cspmailreports.conf.app_settings._reset_cache()
self.assertEqual(len(cspmailreports.apps.check_settings(None)), 2)
from django.urls import re_path
import cspmailreports.views
app_name = 'cspmailreports'
urlpatterns = [
re_path(r'^report/$', cspmailreports.views.report_csp, name='csp-report'),
]
import json
import logging
from django.core.cache import cache
from cspmailreports.conf import app_settings
logger = logging.getLogger(__name__)
def is_flagged_as_dos(request) -> bool:
ip_address = request.META.get("REMOTE_ADDR")
cache_key = f"csp_report_attempts:{ip_address}"
csp_report_attempts = cache.get(cache_key, 0) + 1
cache.set(cache_key, csp_report_attempts, app_settings.dos_cooldown)
return csp_report_attempts > app_settings.max_report_before_cooldown
def create_report(request) -> str:
agent = request.META.get('HTTP_USER_AGENT', '')
report = request.body
if isinstance(report, bytes):
report = report.decode('utf-8')
try:
report = json.dumps(
json.loads(report),
indent=4,
sort_keys=True,
)
except ValueError as e:
print(e)
ip = request.META.get("REMOTE_ADDR")
logger.warning(f'Invalid CSP report violation from "{ip}": "{report[:20]}..."')
return f"HTTP user agent :\n{agent}\n\nReport:\n{report}"
import logging
from django.core.mail import mail_admins
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import cspmailreports.conf
import cspmailreports.utils
logger = logging.getLogger(__name__)
@require_POST
@csrf_exempt
def report_csp(request):
if request.content_type != 'application/csp-report':
return HttpResponse(status=415)
if cspmailreports.utils.is_flagged_as_dos(request):
return HttpResponse(status=429, headers=[("Retry-After", cspmailreports.conf.app_settings.dos_cooldown)])
report = cspmailreports.utils.create_report(request)
if cspmailreports.conf.app_settings.print_in_log:
logger.warning(f'CSP violation report:\n{report}')
mail_admins("CSP violation report", report)
return HttpResponse()
...@@ -7,6 +7,8 @@ python-decouple ...@@ -7,6 +7,8 @@ python-decouple
django-basetheme-bootstrap>=1.8.4 django-basetheme-bootstrap>=1.8.4
--extra-index-url https://gitlab.pasteur.fr/api/v4/projects/hub%2Fdjango-kubernetes-probes/packages/pypi/simple --extra-index-url https://gitlab.pasteur.fr/api/v4/projects/hub%2Fdjango-kubernetes-probes/packages/pypi/simple
django-kubernetes-probes>=1.1 django-kubernetes-probes>=1.1
--extra-index-url https://gitlab.pasteur.fr/api/v4/projects/hub%2Fdjango-csp-mail-reports/packages/pypi/simple
django-csp-mail-reports>=1.0
django-formtools django-formtools
csscompressor csscompressor
coverage coverage
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment