From 644b1b134f58f8fb5bc204576a97504f67ad4481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20Fredrik=20Ki=C3=A6r?= Date: Tue, 18 Aug 2020 12:35:37 +0200 Subject: [PATCH] Add support for calculating CSP hashes of inline scripts --- CHANGELOG.md | 3 ++ dash/dash.py | 38 ++++++++++++++++++++++ requires-testing.txt | 1 + tests/integration/test_csp.py | 59 +++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 tests/integration/test_csp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbc913ce2..2854ece58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] +### Added +- [#1371](https://github.com/plotly/dash/pull/1371) You can now get [CSP `script-src` hashes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src) of all added inline scripts by calling `app.csp_hashes()` (both Dash internal inline scripts, and those added with `app.clientside_callback`) . + ### Fixed - [#1384](https://github.com/plotly/dash/pull/1384) Fixed a bug introduced by [#1180](https://github.com/plotly/dash/pull/1180) breaking use of `prevent_initial_call` as a positional arg in callback definitions diff --git a/dash/dash.py b/dash/dash.py index c17ddbe271..4720c83f4f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -10,6 +10,8 @@ import re import logging import mimetypes +import hashlib +import base64 from functools import wraps from future.moves.urllib.parse import urlparse @@ -1128,6 +1130,42 @@ def _serve_default_favicon(): pkgutil.get_data("dash", "favicon.ico"), content_type="image/x-icon" ) + def csp_hashes(self, hash_algorithm="sha256"): + """Calculates CSP hashes (sha + base64) of all inline scripts, such that + one of the biggest benefits of CSP (disallowing general inline scripts) + can be utilized together with Dash clientside callbacks (inline scripts). + + Calculate these hashes after all inline callbacks are defined, + and add them to your CSP headers before starting the server, for example + with the flask-talisman package from PyPI: + + flask_talisman.Talisman(app.server, content_security_policy={ + "default-src": "'self'", + "script-src": ["'self'"] + app.csp_hashes() + }) + + :param hash_algorithm: One of the recognized CSP hash algorithms ('sha256', 'sha384', 'sha512'). + :return: List of CSP hash strings of all inline scripts. + """ + + HASH_ALGORITHMS = ["sha256", "sha384", "sha512"] + if hash_algorithm not in HASH_ALGORITHMS: + raise ValueError( + "Possible CSP hash algorithms: " + ", ".join(HASH_ALGORITHMS) + ) + + method = getattr(hashlib, hash_algorithm) + + return [ + "'{hash_algorithm}-{base64_hash}'".format( + hash_algorithm=hash_algorithm, + base64_hash=base64.b64encode( + method(script.encode("utf-8")).digest() + ).decode("utf-8"), + ) + for script in self._inline_scripts + [self.renderer] + ] + def get_asset_url(self, path): asset = get_asset_path( self.config.requests_pathname_prefix, diff --git a/requires-testing.txt b/requires-testing.txt index 1662b98057..1b14e7d6e9 100644 --- a/requires-testing.txt +++ b/requires-testing.txt @@ -10,3 +10,4 @@ cryptography==3.0 requests[security]==2.21.0 beautifulsoup4==4.8.2 waitress==1.4.3 +flask-talisman==0.7.0 diff --git a/tests/integration/test_csp.py b/tests/integration/test_csp.py new file mode 100644 index 0000000000..a6e3df6fe7 --- /dev/null +++ b/tests/integration/test_csp.py @@ -0,0 +1,59 @@ +import contextlib + +import pytest +import flask_talisman +from selenium.common.exceptions import NoSuchElementException + +import dash +import dash_core_components as dcc +import dash_html_components as html +from dash.dependencies import Input, Output + + +@contextlib.contextmanager +def does_not_raise(): + yield + + +@pytest.mark.parametrize( + "add_hashes, hash_algorithm, expectation", + [ + (False, None, pytest.raises(NoSuchElementException)), + (True, "sha256", does_not_raise()), + (True, "sha384", does_not_raise()), + (True, "sha512", does_not_raise()), + (True, "sha999", pytest.raises(ValueError)), + ], +) +def test_csp_hashes_inline_scripts(dash_duo, add_hashes, hash_algorithm, expectation): + app = dash.Dash(__name__) + + app.layout = html.Div( + [dcc.Input(id="input_element", type="text"), html.Div(id="output_element")] + ) + + app.clientside_callback( + """ + function(input) { + return input; + } + """, + Output("output_element", "children"), + [Input("input_element", "value")], + ) + + with expectation: + csp = { + "default-src": "'self'", + "script-src": ["'self'"] + + (app.csp_hashes(hash_algorithm) if add_hashes else []), + } + + flask_talisman.Talisman( + app.server, content_security_policy=csp, force_https=False + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#input_element").send_keys("xyz") + assert dash_duo.wait_for_element("#output_element").text == "xyz"