Enable setting of security http headers

The following security policy headers are set:

X-XSS-Protection: 1; mode=block -- always set

X-Frame-Options: DENY is set for all pages except for the documentation
  pages, primarily because pgadmin4 loads them in an iframe which would
  break.

Content-Security-Policy: <x>-src
  Is set to allow the default of self only, then allowing scripts for
  google analytics and fonts for google fonts. Images are allowed from everywhere.
  frame-ancestors 'none' is set by the same rules as X-Frame-Options

This also adds a decorator for @script_sources to have a single view
allow extra sources, and this is used for recaptcha. A generic decorator
is also made for other types of exclusions, though we don't have any at
this point.

If the setting SECURITY_POLICY_REPORT_ONLY is set to True then the policy
will be report-only and not enforced (for testing), otherwise enforcing
mode is enabled.

The setting SECURITY_POLICY_REPORT_URI sets where to send security
policy reports, if any.
This commit is contained in:
Magnus Hagander
2018-12-20 16:50:24 +01:00
parent 48db40f71e
commit d36ea4a985
4 changed files with 62 additions and 2 deletions

View File

@ -3,7 +3,7 @@ from django.contrib.auth import login as django_login
import django.contrib.auth.views as authviews
from django.http import HttpResponseRedirect, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from pgweb.util.decorators import login_required
from pgweb.util.decorators import login_required, script_sources
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.contrib.auth.tokens import default_token_generator
@ -293,6 +293,8 @@ def reset_complete(request):
log.info("Password reset completed for user from {0}".format(get_client_ip(request)))
return authviews.password_reset_complete(request, template_name='account/password_reset_complete.html')
@script_sources('https://www.google.com/recaptcha/')
@script_sources('https://www.gstatic.com/recaptcha/')
def signup(request):
if request.user.is_authenticated():
return HttpServerError(request, "You must log out before you can sign up for a new account")

View File

@ -1,7 +1,7 @@
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect
from django.http import Http404
from pgweb.util.decorators import login_required
from pgweb.util.decorators import login_required, allow_frames
from django.db.models import Q
from django.conf import settings
@ -17,6 +17,7 @@ from pgweb.core.models import Version
from models import DocPage
from forms import DocCommentForm
@allow_frames
def docpage(request, version, filename):
loaddate = None
# Get the current version both to map the /current/ url, and to later

View File

@ -1,5 +1,6 @@
import datetime
from functools import wraps
from collections import defaultdict
from django.contrib.auth.decorators import login_required as django_login_required
def nocache(fn):
@ -20,6 +21,27 @@ def cache(days=0, hours=0, minutes=0, seconds=0):
return __cache
return _cache
def allow_frames(fn):
def _allow_frames(request, *_args, **_kwargs):
resp = fn(request, *_args, **_kwargs)
resp.x_allow_frames = True
return resp
return _allow_frames
def content_sources(what, source):
def _script_sources(fn):
def __script_sources(request, *_args, **_kwargs):
resp = fn(request, *_args, **_kwargs)
if not hasattr(resp, 'x_allow_extra_sources'):
resp.x_allow_extra_sources = defaultdict(list)
resp.x_allow_extra_sources[what].append(source)
return resp
return __script_sources
return _script_sources
def script_sources(source):
return content_sources('script', source)
# A wrapped version of login_required that throws an exception if it's
# used on a path that's not under /account/.
def login_required(f):

View File

@ -1,5 +1,8 @@
from django.conf import settings
from pgweb.util.templateloader import initialize_template_collection, get_all_templates
from collections import OrderedDict
import hashlib
# Use thread local storage to pass the username down.
@ -26,10 +29,42 @@ class PgMiddleware(object):
initialize_template_collection()
def process_response(self, request, response):
# Set xkey representing the templates that are in use so we can do efficient
# varnish purging on commits.
tlist = get_all_templates()
if 'base/esi.html' in tlist:
response['x-do-esi'] = "1"
tlist.remove('base/esi.html')
if tlist:
response['xkey'] = ' '.join(["pgwt_{0}".format(hashlib.md5(t).hexdigest()) for t in tlist])
# Set security headers
sources = OrderedDict([
('default', ["'self'", ]),
('img', ['*', ]),
('script', ["'self'", "www.google-analytics.com"]),
('media', ["'self'", ]),
('style', ["'self'", "fonts.googleapis.com"]),
('font', ["'self'", "fonts.gstatic.com"]),
])
if hasattr(response, 'x_allow_extra_sources'):
for k,v in response.x_allow_extra_sources.items():
sources[k].extend(v)
security_policies = ["{0}-src {1}".format(k," ".join(v)) for k,v in sources.items()]
if not getattr(response, 'x_allow_frames', False):
response['X-Frame-Options'] = 'DENY'
security_policies.append("frame-ancestors 'none'")
if hasattr(settings, 'SECURITY_POLICY_REPORT_URI'):
security_policies.append("report-uri " + settings.SECURITY_POLICY_REPORT_URI)
if security_policies:
if getattr(settings, 'SECURITY_POLICY_REPORT_ONLY', False):
response['Content-Security-Policy-Report-Only'] = " ; ".join(security_policies)
else:
response['Content-Security-Policy'] = " ; ".join(security_policies)
response['X-XSS-Protection'] = "1; mode=block"
return response