Skip to content

Commit

Permalink
Merge pull request #50 from MartinPetkov/feature/44_support_both_csp_…
Browse files Browse the repository at this point in the history
…headers

Add support for both report-only and content-security CSP headers
  • Loading branch information
Gee19 authored Aug 19, 2020
2 parents 25073d9 + a5abb51 commit 34efd06
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 78 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# python compiled files
*.pyc
*.pyo
*.egg-info

# back up files from VIM / Emacs
*~
Expand Down
2 changes: 1 addition & 1 deletion security/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def validate(password):
raise ValidationError(message)
return validate


# The error messages from the RegexValidators don't display properly unless we
# explicitly supply an empty error code.

lowercase = RegexValidator(
r"[a-z]",
_("It must contain at least one lowercase letter."),
Expand Down
179 changes: 119 additions & 60 deletions security/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@


logger = logging.getLogger(__name__)
DJANGO_SECURITY_MIDDLEWARE_URL = ("https://docs.djangoproject.com/en/1.11/ref"
DJANGO_SECURITY_MIDDLEWARE_URL = (
"https://docs.djangoproject.com/en/1.11/ref"
"/middleware/#django.middleware.security.SecurityMiddleware")
DJANGO_CLICKJACKING_MIDDLEWARE_URL = ("https://docs.djangoproject.com/en/1.11/"
DJANGO_CLICKJACKING_MIDDLEWARE_URL = (
"https://docs.djangoproject.com/en/1.11/"
"ref/clickjacking/")


Expand All @@ -47,12 +49,12 @@ def perform_logout(self, request):
return

try:
module_path, function_name = self.CUSTOM_LOGOUT_MODULE.rsplit('.', 1)
module_path, func_name = self.CUSTOM_LOGOUT_MODULE.rsplit('.', 1)
except ValueError:
err = self.Messages.NOT_A_MODULE_PATH
raise Exception(err.format(self.CUSTOM_LOGOUT_MODULE))

if not module_path or not function_name:
if not module_path or not func_name:
err = self.Messages.NOT_A_MODULE_PATH
raise Exception(err.format(self.CUSTOM_LOGOUT_MODULE))

Expand All @@ -63,10 +65,10 @@ def perform_logout(self, request):
raise Exception(err.format(module_path, e))

try:
func = getattr(module, function_name)
func = getattr(module, func_name)
except AttributeError:
err = self.Messages.MISSING_FUNCTION
raise Exception(err.format(function_name, module_path))
raise Exception(err.format(func_name, module_path))

return func(request)

Expand All @@ -91,10 +93,9 @@ def load_setting(self, setting, value):
raise NotImplementedError()

def _on_setting_changed(self, sender, setting, value, **kwargs):
if (
setting in self.REQUIRED_SETTINGS or
setting in self.OPTIONAL_SETTINGS
):
required = setting in self.REQUIRED_SETTINGS
optional = setting in self.OPTIONAL_SETTINGS
if required or optional:
self.load_setting(setting, value)

def __init__(self, get_response=None):
Expand Down Expand Up @@ -225,9 +226,11 @@ class XssProtectMiddleware(BaseMiddleware):

def __init__(self, get_response=None):
super().__init__(get_response)
warnings.warn('DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for an '
'alternative approach with regards to the settings: {settings}'.format(
warnings.warn((
'DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for '
'an alternative approach with regards to the settings: {settings}'
).format(
name=self.__class__.__name__,
url=DJANGO_SECURITY_MIDDLEWARE_URL,
settings="SECURE_BROWSER_XSS_FILTER"))
Expand Down Expand Up @@ -337,14 +340,15 @@ class ContentNoSniff(MiddlewareMixin):

def __init__(self, get_response=None):
super().__init__(get_response)
warnings.warn('DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for an '
'alternative approach with regards to the settings: {settings}'.format(
warnings.warn((
'DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for '
'an alternative approach with regards to the settings: {settings}'
).format(
name=self.__class__.__name__,
url=DJANGO_SECURITY_MIDDLEWARE_URL,
settings="SECURE_CONTENT_TYPE_NOSNIFF"))


def process_response(self, request, response):
"""
Add ``X-Content-Options: nosniff`` to the response header.
Expand Down Expand Up @@ -530,8 +534,10 @@ class XFrameOptionsMiddleware(BaseMiddleware):

def __init__(self, get_response=None):
super().__init__(get_response)
warnings.warn('An official middleware "{name}" is supported by Django.'
' Refer to {url} to see if its approach fits the use case.'.format(
warnings.warn((
'An official middleware "{name}" is supported by Django. '
'Refer to {url} to see if its approach fits the use case.'
).format(
name="XFrameOptionsMiddleware",
url=DJANGO_CLICKJACKING_MIDDLEWARE_URL))

Expand Down Expand Up @@ -561,9 +567,8 @@ def load_setting(self, setting, value):
self.exclude_urls = [compile(url) for url in value]
except TypeError:
raise ImproperlyConfigured(
self.__class__.__name__ +
" invalid option for X_FRAME_OPTIONS_EXCLUDE_URLS",
)
"{0} invalid option for X_FRAME_OPTIONS_EXCLUDE_URLS"
.format(self.__class__.__name__))

def process_response(self, request, response):
"""
Expand All @@ -577,6 +582,7 @@ def process_response(self, request, response):

return response


# preserve older django-security API
# new API uses "deny" as default to maintain compatibility
XFrameOptionsDenyMiddleware = XFrameOptionsMiddleware
Expand Down Expand Up @@ -808,36 +814,84 @@ def __init__(self, get_response=None):
# sanity checks
self.get_response = get_response

csp_mode = getattr(django.conf.settings, 'CSP_MODE', None)
conf_csp_mode = getattr(django.conf.settings, 'CSP_MODE', None)
self._csp_mode = conf_csp_mode or 'enforce'
csp_string = getattr(django.conf.settings, 'CSP_STRING', None)
csp_dict = getattr(django.conf.settings, 'CSP_DICT', None)
err_msg = 'Middleware requires either CSP_STRING or CSP_DICT setting'

if not csp_mode or csp_mode == 'enforce':
self._enforce = True
elif csp_mode == 'report-only':
self._enforce = False
else:
logger.warn(
'Invalid CSP_MODE %s, "enforce" or "report-only" allowed',
csp_mode
csp_report_string = getattr(django.conf.settings, 'CSP_REPORT_STRING',
None)
csp_report_dict = getattr(django.conf.settings, 'CSP_REPORT_DICT',
None)

set_csp_str = self._csp_mode in ['enforce', 'enforce-and-report-only']
set_csp_report_str = self._csp_mode in ['report-only',
'enforce-and-report-only']

if not (set_csp_str or set_csp_report_str):
logger.error(
'Invalid CSP_MODE %s, "enforce", "report-only" '
'or "enforce-and-report-only" allowed',
self._csp_mode
)
raise django.core.exceptions.MiddlewareNotUsed

if set_csp_str:
self._set_csp_str(csp_dict, csp_string)

if set_csp_report_str:
self._set_csp_report_str(csp_report_dict, csp_report_string)

def _set_csp_str(self, csp_dict, csp_string):
err_msg = 'Middleware requires either CSP_STRING or CSP_DICT setting'
if not (csp_dict or csp_string):
logger.warning('%s, none found', err_msg)
logger.error('%s, none found', err_msg)
raise django.core.exceptions.MiddlewareNotUsed

if csp_dict and csp_string:
logger.warning('%s, not both', err_msg)
raise django.core.exceptions.MiddlewareNotUsed
self._csp_string = self._choose_csp_str(csp_dict, csp_string,
err_msg + ', not both')

# build or copy CSP as string
if csp_string:
self._csp_string = csp_string
def _set_csp_report_str(self, csp_report_dict, csp_report_string):
report_err_msg = (
'Middleware requires either CSP_REPORT_STRING, '
'CSP_REPORT_DICT setting, or neither. If neither, '
'middleware requires CSP_STRING or CSP_DICT, '
'but not both.'
)

# Default to the regular CSP string if report string not configured
if not (csp_report_dict or csp_report_string):
self._csp_report_string = self._csp_string
else:
self._csp_report_string = self._choose_csp_str(
csp_report_dict,
csp_report_string,
report_err_msg
)

def _choose_csp_str(self, csp_dict, csp_str, err_msg):
"""
Choose the Content-Security-Policy string to return.
Args:
csp_dict: a dictionary of values for building a CSP string
csp_str: the fallback CSP string if no dictionary is provided
err_msg: the message to log if both a dict and string are provided
Returns:
The Content-Security-Policy string by either building it from a
dictionary or using the provided string.
Log an error message if both are provided.
"""
if csp_dict and csp_str:
logger.error('%s', err_msg)
raise django.core.exceptions.MiddlewareNotUsed

if csp_dict:
self._csp_string = self._csp_builder(csp_dict)
return self._csp_builder(csp_dict)
elif csp_str:
return csp_str
else:
return ''

def process_response(self, request, response):
"""
Expand All @@ -850,16 +904,19 @@ def process_response(self, request, response):
parsed_ua = ParseUserAgent(request.META['HTTP_USER_AGENT'])
is_ie = parsed_ua['family'] == 'IE'

if self._enforce:
if is_ie:
header = 'X-Content-Security-Policy'
else:
header = 'Content-Security-Policy'
else:
header = 'Content-Security-Policy-Report-Only'
csp_header = 'Content-Security-Policy'
if is_ie:
csp_header = 'X-Content-Security-Policy'
report_only_header = 'Content-Security-Policy-Report-Only'

# actually add appropriate headers
response[header] = self._csp_string
if self._csp_mode == 'enforce':
response[csp_header] = self._csp_string
elif self._csp_mode == 'report-only':
response[report_only_header] = self._csp_report_string
elif self._csp_mode == 'enforce-and-report-only':
response[csp_header] = self._csp_string
response[report_only_header] = self._csp_report_string

return response

Expand Down Expand Up @@ -894,9 +951,11 @@ class StrictTransportSecurityMiddleware(MiddlewareMixin):
- `Preloaded HSTS sites <http://www.chromium.org/sts>`_
"""
def __init__(self, get_response=None):
warnings.warn('DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for an '
'alternative approach with regards to the settings: {settings}'.format(
warnings.warn((
'DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library. Refer to {url} for '
'an alternative approach with regards to the settings: {settings}'
).format(
name=self.__class__.__name__,
url=DJANGO_SECURITY_MIDDLEWARE_URL,
settings=", ".join([
Expand Down Expand Up @@ -962,10 +1021,10 @@ class P3PPolicyMiddleware(BaseMiddleware):

def __init__(self, get_response=None):
super().__init__(get_response)
warnings.warn('DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library.'.format(
name=self.__class__.__name__
))
warnings.warn((
'DEPRECATED: The middleware "{name}" will no longer be '
'supported in future releases of this library.'
).format(name=self.__class__.__name__))

def load_setting(self, setting, value):
if setting == 'P3P_COMPACT_POLICY':
Expand Down Expand Up @@ -1092,10 +1151,10 @@ def process_request(self, request):
return

if (
self.START_TIME_KEY not in request.session or
self.LAST_ACTIVITY_KEY not in request.session or
timezone.is_naive(self.get_start_time(request)) or
timezone.is_naive(self.get_last_activity(request))
self.START_TIME_KEY not in request.session
or self.LAST_ACTIVITY_KEY not in request.session
or timezone.is_naive(self.get_start_time(request))
or timezone.is_naive(self.get_last_activity(request))
):
response = self.process_new_session(request)
else:
Expand Down
6 changes: 2 additions & 4 deletions security/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,8 @@ def csp_report(request, csp_save=False, csp_log=True):
log.debug('Unexpect CSP report method %s', request.method)
return HttpResponseForbidden()

if (
'CONTENT_TYPE' not in request.META or
request.META['CONTENT_TYPE'] != 'application/json'
):
content_type = request.META.get('CONTENT_TYPE', None)
if content_type != 'application/json':
log.debug('Missing CSP report Content-Type %s', request.META)
return HttpResponseForbidden()

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ def run(self):
install_requires=[
'django>=1.11',
'ua_parser>=0.7.1',
'python-dateutil==2.8.0',
'python-dateutil==2.8.1',
],
cmdclass={'test': Test})
12 changes: 9 additions & 3 deletions testing/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,22 +972,28 @@ def test_csp_gen_err6(self):

def test_enforced_by_default(self):
with self.settings(CSP_MODE=None):
response = self.client.get('/accounts/login/')
response = self.client.get(settings.LOGIN_URL)
self.assertIn('Content-Security-Policy', response)
self.assertNotIn('Content-Security-Policy-Report-Only', response)

def test_enforced_when_on(self):
with self.settings(CSP_MODE='enforce'):
response = self.client.get('/accounts/login/')
response = self.client.get(settings.LOGIN_URL)
self.assertIn('Content-Security-Policy', response)
self.assertNotIn('Content-Security-Policy-Report-Only', response)

def test_report_only_set(self):
with self.settings(CSP_MODE='report-only'):
response = self.client.get('/accounts/login/')
response = self.client.get(settings.LOGIN_URL)
self.assertNotIn('Content-Security-Policy', response)
self.assertIn('Content-Security-Policy-Report-Only', response)

def test_both_enforce_and_report_only(self):
with self.settings(CSP_MODE='enforce-and-report-only'):
response = self.client.get(settings.LOGIN_URL)
self.assertIn('Content-Security-Policy', response)
self.assertIn('Content-Security-Policy-Report-Only', response)

def test_invalid_csp_mode(self):
with self.settings(CSP_MODE='invalid'):
self.assertRaises(
Expand Down
Loading

0 comments on commit 34efd06

Please sign in to comment.