From ff2f454a65c8fee75cc2b5fcdf73301ba30bddca 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 | 1 + dash/dash.py | 29 +++++++++++++++++ requires-testing.txt | 1 + tests/integration/test_csp.py | 59 +++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 tests/integration/test_csp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fabadc37..3028e0c2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added - [#1355](https://github.com/plotly/dash/pull/1355) Removed redundant log message and consolidated logger initialization. You can now control the log level - for example suppress informational messages from Dash with `app.logger.setLevel(logging.WARNING)`. +- [#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_inline_scripts()` (both Dash internal inline scripts, and those added with `app.clientside_callback`) . ### Changed - [#1180](https://github.com/plotly/dash/pull/1180) `Input`, `Output`, and `State` in callback definitions don't need to be in lists. You still need to provide `Output` items first, then `Input` items, then `State`, and the list form is still supported. In particular, if you want to return a single output item wrapped in a length-1 list, you should still wrap the `Output` in a list. This can be useful for procedurally-generated callbacks. diff --git a/dash/dash.py b/dash/dash.py index ccce7c2418..059b7e59d0 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,33 @@ def _serve_default_favicon(): pkgutil.get_data("dash", "favicon.ico"), content_type="image/x-icon" ) + def csp_hashes_inline_scripts(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). + + :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 1aa667c03d..81e75f6c4b 100644 --- a/requires-testing.txt +++ b/requires-testing.txt @@ -9,3 +9,4 @@ percy==2.0.2 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..b0549da6ae --- /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(component_id="output_element", component_property="children"), + [Input(component_id="input_element", component_property="value")], + ) + + with expectation: + csp = { + "default-src": "'self'", + "script-src": ["'self'"] + + (app.csp_hashes_inline_scripts(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"