Skip to content
New issue

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

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

Already on GitHub? # to your account

Add support for calculating CSP hashes of inline scripts #1371

Merged
merged 5 commits into from
Sep 3, 2020
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) .

### Changed
- [#1385](https://github.com/plotly/dash/pull/1385) Closes [#1350](https://github.com/plotly/dash/issues/1350) and fixes a previously undefined callback behavior when multiple elements are stacked on top of one another and their `n_clicks` props are used as inputs of the same callback. The callback will now trigger once with all the triggered `n_clicks` props changes.

Expand Down
38 changes: 38 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Copy link
Collaborator

Choose a reason for hiding this comment

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

@anders-kiaer this looks great, and fantastic tests - I love does_not_raise, hadn't seen that trick before but I can see it coming in handy elsewhere.

The name is a little long, would it be ambiguous if we just call it csp_hashes or script_hashes?

Can you add a bit of usage example into the docstring here? Something like:

Calculate these hashes after all callbacks are defined, and add them to your CSP headers
immediately before starting the server, for example:

flask_talisman.Talisman(app.server, content_security_policy={
    "default-src": "'self'",
    "script-src": ["'self'"] + app.csp_hashes_inline_scripts()
})

Am I understanding correctly that this is what you'd normally do?

I haven't used flask_talisman before, but I presume that it works just as well (and with no changes to your code) with gunicorn as it does with run_server?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The name is a little long, would it be ambiguous if we just call it csp_hashes or script_hashes?

Agree, it is currently a bit long. I think both of the two alternatives should be ok. If there is any ambiguity, that probably is:

  • csp_hashes: CSP hashes are, generally, used both for inline styles and inline scripts. So from the name alone it is not clear if it is script hashes, style hashes or both.

    However, in Dash framework context (since dynamic style attributes from Python side are set in a CSP compatible way through React, see Separate CSS file on build dash-core-components#753 (comment)), only script hashes should be necessary, which reduces the risk of the ambiguity. There are of course Dash components out there that needs to do changes similar to Separate CSS file on build dash-core-components#753, but that is on the different custom/standard Dash components to solve/fix in their webpack build setup, not something concerning the Dash framework.

  • script_hashes: Solves the style vs script ambiguity, but it does not indicate that the hashes are in "CSP format" and ready to go ('{hash algorithm}-{base 64 hash}', including the '').

I like csp_hashes best of the two 👍 If there appears e.g. a style hash use case later, for some reason, this can be solved with a natural non-breaking extension with proper default value, e.g. app.csp_hashes(hash_algorithm, source="scripts").

Am I understanding correctly that this is what you'd normally do?

I haven't used flask_talisman before, but I presume that it works just as well (and with no changes to your code) with gunicorn as it does with run_server?

Yes and yes 🙂 gunicorn passes by default the (CSP) headers through. E.g. with that example CSP configuration in the suggested docstring, Dash users will get 🔒 A+ rating out of the box on Mozilla observatory.


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,
Expand Down
1 change: 1 addition & 0 deletions requires-testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions tests/integration/test_csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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_incs001_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"