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 all 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
3 changes: 3 additions & 0 deletions desktop/conf.dist/hue.ini
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ http_500_debug_mode=false
# X-Content-Type-Options: nosniff This is a HTTP response header feature that helps prevent attacks based on MIME-type confusion.
## secure_content_security_policy="script-src 'self' 'unsafe-inline' 'unsafe-eval' *.googletagmanager.com *.doubleclick.net data:;img-src 'self' *.doubleclick.net http://*.tile.osm.org *.tile.osm.org *.gstatic.com data:;style-src 'self' 'unsafe-inline' fonts.googleapis.com;connect-src 'self' *.google-analytics.com;frame-src *;child-src 'self' data: *.vimeo.com;object-src 'none'"

# Enable nonce attribute to remove unsafe-inline and auto remove unsafe-inline from csp
## csp_nonce=True

# Strict-Transport-Security HTTP Strict Transport Security(HSTS) is a policy which is communicated by the server to the user agent via HTTP response header field name "Strict-Transport-Security". HSTS policy specifies a period of time during which the user agent(browser) should only access the server in a secure fashion(https).
## secure_ssl_redirect=False
## secure_redirect_host=0.0.0.0
Expand Down
12 changes: 12 additions & 0 deletions desktop/core/src/desktop/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,18 @@ 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=_(
'Enables or disables the use of a cryptographic nonce in Content Security '
'Policy headers. When set to true, a unique nonce will be generated for each '
'request, and used in scripts and styles'
),
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
9 changes: 9 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,12 @@ 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(request):
# Assuming 'csp_nonce' is set in the request and CSP_NONCE controls its usage
if desktop.conf.CSP_NONCE.get():
csp_nonce = request.csp_nonce
if csp_nonce:
return f' nonce={csp_nonce}'
tabraiz12 marked this conversation as resolved.
Show resolved Hide resolved
return ''
127 changes: 115 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 All @@ -29,6 +30,7 @@
import tempfile
import time
import traceback
import secrets

import kerberos
import django.db
Expand All @@ -54,7 +56,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 @@ -68,6 +70,69 @@

from libsaml.conf import CDP_LOGOUT_URL
from urllib.parse import urlparse
import os


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
"""
try:
csp = response['Content-Security-Policy']
except KeyError:
csp = response.get('Content-Security-Policy-Report-Only', '')

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

return nonce_found

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 _
Expand Down Expand Up @@ -900,19 +965,57 @@ 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 __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 = secrets.token_urlsafe()
request.csp_nonce = nonce

def process_response(self, request, response):
if self.secure_content_security_policy and 'Content-Security-Policy' not in response:
response["Content-Security-Policy"] = self.secure_content_security_policy

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?



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

nonce_found = nonce_exists(response)
has_nonce = bool(nonce_found)
if has_nonce:
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)

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

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