Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Unsafe inline scripts by adding nonce as CSP #3832

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contributing-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A react root element (or the first container element within it) must include the
If your react component isn't dependent on any Knockout observables or Vue components you can integrate it by adding a small script and a component reference directly in the HTML code. The following example integrates MyComponent as a react root in an HTML/mako file.

```HTML
<script type="text/javascript">
<script type="text/javascript" nonce="${ request.csp_nonce }">
(function () {
window.createReactComponents('#some-container');
})();
Expand Down
7 changes: 7 additions & 0 deletions desktop/core/src/desktop/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,13 @@ def has_ssl_no_renegotiation():
"child-src 'self' data: *.vimeo.com;" +
"object-src 'none'")


CSP_NONCE = Config(
tabraiz12 marked this conversation as resolved.
Show resolved Hide resolved
key="csp_nonce",
help=_('CSP NONCE can be true or false'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to improve help message

type=coerce_bool,
default=False)

SECURE_SSL_REDIRECT = Config(
key="secure_ssl_redirect",
help=_('If all non-SSL requests should be permanently redirected to SSL.'),
Expand Down
12 changes: 11 additions & 1 deletion desktop/core/src/desktop/js/onePageViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,12 @@ class OnePageViewModel {
const nextScriptPromise = scriptPromises.shift();
nextScriptPromise.done(scriptDetails => {
if (scriptDetails.contents) {
$.globalEval(scriptDetails.contents);
// $.globalEval(scriptDetails.contents);
const nonce = window.nonce || ''; // Fallback to an empty string if nonce is not defined
const script = document.createElement('script');
script.setAttribute('nonce', nonce); // Set the nonce attribute more explicitly
script.text = scriptDetails.contents;
document.head.appendChild(script).parentNode.removeChild(script);
}
evalScriptSync();
});
Expand Down Expand Up @@ -386,6 +391,11 @@ class OnePageViewModel {
self.extraEmbeddableURLParams('');

self.processHeaders(response).done($rawHtml => {
const nonce = window.nonce || '';
const scripts = $rawHtml.find('script');
scripts.each(function() {
this.setAttribute('nonce', nonce);
});
if (window.SKIP_CACHE.indexOf(app) === -1) {
self.embeddable_cache[app] = $rawHtml;
}
Expand Down
10 changes: 10 additions & 0 deletions desktop/core/src/desktop/lib/django_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,13 @@ def __init__(self, data, encoder=DjangoJSONEncoder, safe=True,
kwargs.setdefault('content_type', 'application/json')
data = json.dumps(data, cls=encoder, **json_dumps_params)
super(JsonResponse, self).__init__(content=data, **kwargs)


def nonce_attribute(context):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is context here?

# Assuming 'csp_nonce' is set in the context and CSP_NONCE controls its usage

if desktop.conf.CSP_NONCE.get():
csp_nonce = context.csp_nonce
if csp_nonce:
return f' nonce={csp_nonce}'
tabraiz12 marked this conversation as resolved.
Show resolved Hide resolved
return ''
139 changes: 127 additions & 12 deletions desktop/core/src/desktop/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import absolute_import

from builtins import object
import base64
import inspect
import json
import logging
Expand Down Expand Up @@ -54,7 +55,7 @@
from desktop.auth.backend import is_admin, find_or_create_user, ensure_has_a_group, rewrite_user
from desktop.conf import AUTH, HTTP_ALLOWED_METHODS, ENABLE_PROMETHEUS, KNOX, DJANGO_DEBUG_MODE, AUDIT_EVENT_LOG_DIR, \
METRICS, SERVER_USER, REDIRECT_WHITELIST, SECURE_CONTENT_SECURITY_POLICY, has_connectors, is_gunicorn_report_enabled, \
CUSTOM_CACHE_CONTROL, HUE_LOAD_BALANCER
CUSTOM_CACHE_CONTROL, HUE_LOAD_BALANCER, CSP_NONCE
from desktop.context_processors import get_app_name
from desktop.lib import apputil, i18n, fsmanager
from desktop.lib.django_util import JsonResponse, render, render_json
Expand All @@ -69,6 +70,83 @@
from libsaml.conf import CDP_LOGOUT_URL
from urllib.parse import urlparse

import base64
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Importing twice? Check line 21

import os

# Number of random bytes in the nonce:
NONCE_BYTES = 24
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason for setting this as 24?


def generate_nonce():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious: Instead of generating ourselves, can we try using this package? (check first answer)
cf: https://stackoverflow.com/questions/5590170/what-is-the-standard-method-for-generating-a-nonce-in-python

""" Return a unique base64 encoded nonce hash."""
nonce = os.urandom(NONCE_BYTES)
return base64.b64encode(nonce).decode()


def nonce_exists(response):
"""Check for preexisting nonce in style and script.

Args:
response (:obj:): Django response object

Returns:
nonce_found (dict): Dictionary of nonces found
has_nonce (bool): True if any nonce has been found
"""
try:
csp = response['Content-Security-Policy']
except KeyError:
csp = response['Content-Security-Policy-Report-Only']
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this line also throws KeyError? Shouldn't we have error handling here also or it will always have this key?


nonce_found = {}

if csp:
csp_split = csp.split(';')
for directive in csp_split:
if 'nonce-' not in directive:
continue
if 'script-src' in directive:
nonce_found['script'] = directive
if 'style-src' in directive:
nonce_found['style'] = directive

has_nonce = any(nonce_found)

return nonce_found, has_nonce


def get_header(response):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can reuse the above method to also get the headers and not have 2 almost similar methods?

"""Get the CSP header type.

This is basically a check for:
Content-Security-Policy or Content-Security-Policy-Report-Only

Args:
response (:obj:): Django response object

Returns:
dict:
name: CPS header policy. i.e. Report-Only or not
csp: CSP directives associated with the header
bool: False if neither policy header is found
"""
policies = [
"Content-Security-Policy",
"Content-Security-Policy-Report-Only"
]

try:
name = policies[0]
csp = response[policies[0]]
except KeyError:
try:
name = policies[1]
csp = response[policies[1]]
except KeyError:
return False

return {'name': name, 'csp': csp}


if sys.version_info[0] > 2:
from django.utils.translation import gettext as _
from django.utils.http import url_has_allowed_host_and_scheme
Expand Down Expand Up @@ -900,19 +978,56 @@ def process_response(self, request, response):


class ContentSecurityPolicyMiddleware(MiddlewareMixin):
def __init__(self, get_response):
self.get_response = get_response
self.secure_content_security_policy = SECURE_CONTENT_SECURITY_POLICY.get()
if not self.secure_content_security_policy:
LOG.info('Unloading ContentSecurityPolicyMiddleware')
raise exceptions.MiddlewareNotUsed

def process_response(self, request, response):
if self.secure_content_security_policy and not 'Content-Security-Policy' in response:
response["Content-Security-Policy"] = self.secure_content_security_policy
def __init__(self, get_response):
self.get_response = get_response
self.secure_content_security_policy = SECURE_CONTENT_SECURITY_POLICY.get()
if not self.secure_content_security_policy:
logger.info('Unloading ContentSecurityPolicyMiddleware')
raise exceptions.MiddlewareNotUsed

def process_request(self, request):
nonce = generate_nonce()
request.csp_nonce = nonce

def process_response(self, request, response):
# Add the secure CSP if it doesn't exist
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the comment correct here?

if not CSP_NONCE.get():
return response
Comment on lines +983 to +984
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If let's say this condition is True, then it will return but isn't this regressing from the old change where it was setting Content-Security-Policy header in response and then returning?


if self.secure_content_security_policy and 'Content-Security-Policy' not in response:
response["Content-Security-Policy"] = self.secure_content_security_policy

header = get_header(response)
if not header or not hasattr(request, 'csp_nonce'):
return response

nonce_found, has_nonce = nonce_exists(response)
if has_nonce:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need 2 return values here?

logger.error("Nonce already exists: {}".format(nonce_found))
return response

nonce = getattr(request, 'csp_nonce', None)
csp_split = header['csp'].split(';')
new_csp = []
nonce_directive = f"'nonce-{nonce}'" if nonce else ''

for p in csp_split:
directive = p.lstrip().split(' ')[0]
if nonce:
# Remove unsafe-inline from scripts
# TODO remove unsafe-inline from styles
if directive in ('script-src'):
new_directive_parts = [part for part in p.split(' ') if part and part != "'unsafe-inline'"]
new_directive_parts.append(nonce_directive)
new_csp.append(' '.join(new_directive_parts))
else:
new_csp.append(p)
else:
new_csp.append(p)

return response
response[header['name']] = "; ".join(new_csp).strip() + ';'

return response

class MimeTypeJSFileFixStreamingMiddleware(MiddlewareMixin):
"""
Expand Down
2 changes: 1 addition & 1 deletion desktop/core/src/desktop/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@
'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages',
# Not default
'desktop.context_processors.app_name',
'desktop.context_processors.app_name'
)

TEMPLATES = [
Expand Down
4 changes: 3 additions & 1 deletion desktop/core/src/desktop/templates/common_footer.mako
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ if sys.version_info[0] > 2:
from django.utils.translation import gettext as _
else:
from django.utils.translation import ugettext as _

from desktop.lib.django_util import nonce_attribute
%>

<%namespace name="commonHeaderFooterComponents" file="/common_header_footer_components.mako" />
Expand All @@ -34,7 +36,7 @@ ${ smart_unicode(login_modal(request).content) | n,unicode }

<iframe id="zoomDetectFrame" style="width: 250px; display: none" ></iframe>

${ commonHeaderFooterComponents.footer(messages) }
${ commonHeaderFooterComponents.footer(messages, nonce_attribute(request) ) }

</body>
</html>
32 changes: 16 additions & 16 deletions desktop/core/src/desktop/templates/common_header.mako
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if sys.version_info[0] > 2:
from django.utils.translation import gettext as _
else:
from django.utils.translation import ugettext as _

from desktop.lib.django_util import nonce_attribute
home_url = url('desktop_views_home')
if USE_NEW_EDITOR.get():
home_url = url('desktop_views_home2')
Expand Down Expand Up @@ -89,7 +89,7 @@ if USE_NEW_EDITOR.get():
<link href="${ static('desktop/css/hue3.css') }" rel="stylesheet">
<link href="${ static('desktop/css/hue3-extra.css') }" rel="stylesheet">

<style type="text/css">
<style ${nonce_attribute(request)} type="text/css">
% if banner_message or conf.CUSTOM.BANNER_TOP_HTML.get():
body {
display: none;
Expand Down Expand Up @@ -142,24 +142,24 @@ if USE_NEW_EDITOR.get():
<%
global_constants_url = '/desktop/globalJsConstants.js?v=' + hue_version()
%>
<script src="${global_constants_url}"></script>
<script ${nonce_attribute(request)} src="${global_constants_url}"></script>
% endif

% if not conf.DEV.get():
<script src="${ static('desktop/js/hue.errorcatcher.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/js/hue.errorcatcher.js') }"></script>
% endif

% for bundle in get_hue_bundles('login' if section == 'login' else 'hue', 'LOGIN' if section == 'login' else 'DEFAULT'):
${ render_bundle(bundle, config='LOGIN' if section == 'login' else 'DEFAULT') | n,unicode }
% endfor

<script src="${ static('desktop/js/bootstrap-tooltip.js') }"></script>
<script src="${ static('desktop/js/bootstrap-typeahead-touchscreen.js') }"></script>
<script src="${ static('desktop/ext/js/bootstrap-better-typeahead.min.js') }"></script>
<script src="${ static('desktop/js/popover-extra-placements.js') }"></script>
<script src="${ static('desktop/ext/js/moment-with-locales.min.js') }"></script>
<script src="${ static('desktop/ext/js/moment-timezone-with-data.min.js') }" type="text/javascript" charset="utf-8"></script>
<script src="${ static('desktop/ext/js/tzdetect.js') }" type="text/javascript" charset="utf-8"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/js/bootstrap-tooltip.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/js/bootstrap-typeahead-touchscreen.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/ext/js/bootstrap-better-typeahead.min.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/js/popover-extra-placements.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/ext/js/moment-with-locales.min.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/ext/js/moment-timezone-with-data.min.js') }" type="text/javascript" charset="utf-8"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/ext/js/tzdetect.js') }" type="text/javascript" charset="utf-8"></script>

% if user.is_authenticated:
${ hueAceAutocompleter.hueAceAutocompleter() }
Expand All @@ -168,9 +168,9 @@ if USE_NEW_EDITOR.get():
${ commonHeaderFooterComponents.header_pollers(user, is_s3_enabled, apps) }

% if user.is_authenticated:
<script src="${ static('desktop/ext/js/localforage.min.js') }"></script>
<script ${nonce_attribute(request)} src="${ static('desktop/ext/js/localforage.min.js') }"></script>

<script type="text/javascript">
<script ${nonce_attribute(request)} type="text/javascript">
$(document).ready(function () {
localforage.config({
version: 1.0,
Expand Down Expand Up @@ -200,7 +200,7 @@ ${ hueIcons.symbols() }


% if hasattr(request, 'environ') and request.environ.get("PATH_INFO").find("/hue/") < 0:
<script>
<script ${nonce_attribute(request)}>
window.location.replace(window.HUE_BASE_URL || "/");
</script>
% endif
Expand Down Expand Up @@ -556,8 +556,8 @@ ${ hueIcons.symbols() }
</ul>

<!-- UserVoice JavaScript SDK -->
<script>(function(){var uv=document.createElement('script');uv.type='text/javascript';uv.async=true;uv.src='//widget.uservoice.com/8YpsDfIl1Y2sNdONoLXhrg.js';var s=document.getElementsByTagName('script')[0];s.parentNode.insertBefore(uv,s)})()</script>
<script>
<script ${nonce_attribute(request)}>(function(){var uv=document.createElement('script');uv.type='text/javascript';uv.async=true;uv.src='//widget.uservoice.com/8YpsDfIl1Y2sNdONoLXhrg.js';var s=document.getElementsByTagName('script')[0];s.parentNode.insertBefore(uv,s)})()</script>
<script ${nonce_attribute(request)}>
UserVoice = window.UserVoice || [];
function showClassicWidget() {
UserVoice.push(['showLightbox', 'classic_widget', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ if sys.version_info[0] > 2:
from django.utils.translation import gettext as _
else:
from django.utils.translation import ugettext as _
from desktop.lib.django_util import nonce_attribute
%>

<%def name="header_pollers(user, is_s3_enabled, apps)">
<script type="text/javascript">
<script ${nonce_attribute(request)} type="text/javascript">
Dropzone.autoDiscover = false;
moment.locale(window.navigator.userLanguage || window.navigator.language);
localeFormat = function (time) {
Expand Down Expand Up @@ -230,7 +231,7 @@ else:

</%def>

<%def name="footer(messages)">
<%def name="footer(messages, nonce)">

<div id="progressStatus" class="uploadstatus well hide">
<h4>${ _('Upload progress') }</h4>
Expand Down Expand Up @@ -267,7 +268,7 @@ else:

<div class="clipboard-content"></div>

<script type="text/javascript">
<script ${nonce_attribute(request)} type="text/javascript">

$(document).ready(function () {

Expand Down
Loading