From 05fcdaf93cf23b3aaaedd4e1c0cbfc928b3b6175 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Tue, 28 Apr 2020 14:21:53 -0700 Subject: [PATCH] Revised terms and privacy consent dialog, analytics hooks (#1426) * revised terms and privacy consent * reorg code * fix conditional * Overlay reflects un-dissmissable state * add inline scripts, and consent callback * add csp_directive config hook * revert config.yaml * fix logic error Co-authored-by: Colin Megill --- client/index_template.html | 3 + client/src/components/framework/toasters.js | 28 ++--- client/src/components/termsPrompt/index.js | 125 ++++++++++++++++---- server/app/app.py | 5 +- server/common/app_config.py | 22 ++++ server/common/default_config.py | 2 + server/eb/app.py | 47 ++++++-- 7 files changed, 184 insertions(+), 48 deletions(-) diff --git a/client/index_template.html b/client/index_template.html index 3fd56340..168b1d4b 100644 --- a/client/index_template.html +++ b/client/index_template.html @@ -53,5 +53,8 @@ {% for script in SCRIPTS %} {% endfor %} + {% for ils in INLINE_SCRIPTS %} + + {% endfor %} diff --git a/client/src/components/framework/toasters.js b/client/src/components/framework/toasters.js index 8466ead4..fa173510 100644 --- a/client/src/components/framework/toasters.js +++ b/client/src/components/framework/toasters.js @@ -4,58 +4,50 @@ import { Position, Toaster, Intent } from "@blueprintjs/core"; const ToastTopCenter = Toaster.create({ className: "recipe-toaster", - position: Position.TOP + position: Position.TOP, }); const ToastBottomCenter = Toaster.create({ className: "recipe-toaster", - position: Position.BOTTOM + position: Position.BOTTOM, }); /* A "user" error - eg, bad input */ -export const postUserErrorToast = message => +export const postUserErrorToast = (message) => ToastTopCenter.show({ message, intent: Intent.WARNING }); /* A toast the user must dismiss manually, because they need to act on its information, ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix. */ -export const keepAroundErrorToast = message => +export const keepAroundErrorToast = (message) => ToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING }); /* a hard network error */ -export const postNetworkErrorToast = message => +export const postNetworkErrorToast = (message) => ToastTopCenter.show({ message, timeout: 30000, - intent: Intent.DANGER + intent: Intent.DANGER, }); /* Async message to user */ -export const postAsyncSuccessToast = message => +export const postAsyncSuccessToast = (message) => ToastTopCenter.show({ message, timeout: 10000, - intent: Intent.SUCCESS + intent: Intent.SUCCESS, }); -export const postAsyncFailureToast = message => +export const postAsyncFailureToast = (message) => ToastTopCenter.show({ message, timeout: 10000, - intent: Intent.WARNING - }); - -export const termsOfServiceToast = (message, _onDismiss) => - ToastBottomCenter.show({ - message, - timeout: 0, - intent: Intent.PRIMARY, - onDismiss: _onDismiss + intent: Intent.WARNING, }); diff --git a/client/src/components/termsPrompt/index.js b/client/src/components/termsPrompt/index.js index 992403ed..1b309488 100644 --- a/client/src/components/termsPrompt/index.js +++ b/client/src/components/termsPrompt/index.js @@ -1,9 +1,16 @@ import React from "react"; import { connect } from "react-redux"; +import { + Drawer, + Button, + Classes, + Position, + Colors, + Icon, +} from "@blueprintjs/core"; import * as globals from "../../globals"; -import { termsOfServiceToast } from "../framework/toasters"; -const TosDismissedKey = "cxg.tosDismissed"; +const CookieDecision = "cxg.cookieDecision"; function storageGet(key, defaultValue = null) { try { @@ -23,53 +30,129 @@ function storageSet(key, value) { } } -@connect(state => ({ - tosURL: state.config?.parameters?.about_legal_tos +@connect((state) => ({ + tosURL: state.config?.parameters?.about_legal_tos, + privacyURL: state.config?.parameters?.about_legal_privacy, })) class TermsPrompt extends React.PureComponent { constructor(props) { super(props); + const { tosURL, privacyURL } = this.props; + const cookieDecision = storageGet(CookieDecision, null); + const hasDecided = cookieDecision !== null; this.state = { - hasDismissed: storageGet(TosDismissedKey, false) + hasDecided, + isEnabled: !!tosURL || !!privacyURL, + isOpen: !hasDecided, }; } componentDidMount() { - const { hasDismissed } = this.state; - const { tosURL } = this.props; - if (!hasDismissed && tosURL) { - this.popTermsToast(); + const { hasDecided, isEnabled } = this.state; + if (isEnabled && !hasDecided) { + this.setState({ isOpen: true }); } } - onTermsToastDismissed = () => { - this.setState({ hasDismissed: "yes" }); - storageSet(TosDismissedKey, "yes"); + handleOK = () => { + this.setState({ isOpen: false }); + storageSet(CookieDecision, "yes"); + if (window.cookieDecisionCallback instanceof Function) { + try { + window.cookieDecisionCallback(); + } catch (e) {} + } }; - popTermsToast() { + handleNo = () => { + this.setState({ isOpen: false }); + storageSet(CookieDecision, "no"); + }; + + renderTos() { const { tosURL } = this.props; - termsOfServiceToast( + if (!tosURL) return null; + return ( - By using our site, you are agreeing to our{" "} + By using this site, you are + agreeing to our{" "} - Terms of Service + terms of service - , - this.onTermsToastDismissed + .{" "} + + ); + } + + renderPrivacy() { + const { privacyURL } = this.props; + if (!privacyURL) return null; + return ( + + To learn more, read our{" "} + + privacy policy + + .  + ); } render() { - return null; + const { isOpen, isEnabled } = this.state; + if (!isEnabled || !isOpen) return null; + return ( + +
+
+
+ {this.renderTos()} + + We use cookies to help us improve the site and to inform our + future efforts, and we also use necessary cookies to make our + site work.  + + {this.renderPrivacy()} +
+
+
+ {" "} + +
+
+
+ ); } } diff --git a/server/app/app.py b/server/app/app.py index 9d7e33c0..d22a6731 100644 --- a/server/app/app.py +++ b/server/app/app.py @@ -69,12 +69,15 @@ def dataset_index(dataset=None): location = path_join(config.multi_dataset__dataroot, dataset) scripts = config.server__scripts + inline_scripts = config.server__inline_scripts try: cache_manager = current_app.matrix_data_cache_manager with cache_manager.data_adaptor(location, config) as data_adaptor: dataset_title = config.get_title(data_adaptor) - return render_template("index.html", datasetTitle=dataset_title, SCRIPTS=scripts) + return render_template( + "index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts + ) except DatasetAccessError: return common_rest.abort_and_log( HTTPStatus.BAD_REQUEST, f"Invalid dataset {dataset}", loglevel=logging.INFO, include_exc_info=True diff --git a/server/common/app_config.py b/server/common/app_config.py index 2302f4e6..db13ea35 100644 --- a/server/common/app_config.py +++ b/server/common/app_config.py @@ -50,6 +50,7 @@ class AppConfig(object): self.server__host = dc["server"]["host"] self.server__port = dc["server"]["port"] self.server__scripts = dc["server"]["scripts"] + self.server__inline_scripts = dc["server"]["inline_scripts"] self.server__open_browser = dc["server"]["open_browser"] self.server__about_legal_tos = dc["server"]["about_legal_tos"] self.server__about_legal_privacy = dc["server"]["about_legal_privacy"] @@ -57,6 +58,7 @@ class AppConfig(object): self.server__flask_secret_key = dc["server"]["flask_secret_key"] self.server__generate_cache_control_headers = dc["server"]["generate_cache_control_headers"] self.server__server_timing_headers = dc["server"]["server_timing_headers"] + self.server__csp_directives = dc["server"]["csp_directives"] self.multi_dataset__dataroot = dc["multi_dataset"]["dataroot"] self.multi_dataset__index = dc["multi_dataset"]["index"] @@ -129,6 +131,12 @@ class AppConfig(object): mapping["adaptor__cxg_adaptor__tiledb_ctx"] = (("adaptor", "cxg_adaptor", "tiledb_ctx"), val) del dc["adaptor"]["cxg_adaptor"]["tiledb_ctx"] + # special case for csp_directives whose value is a dict. + val = config.get("server", {}).get("csp_directives", {}) + if val is not None: + mapping["server__csp_directives"] = (("server", "csp_directives"), val) + del dc["server"]["csp_directives"] + flat_config = flatten(dc) for key, value in flat_config.items(): # name of the attribute @@ -231,6 +239,7 @@ class AppConfig(object): self.__check_attr("server__host", str) self.__check_attr("server__port", (type(None), int)) self.__check_attr("server__scripts", (list, tuple)) + self.__check_attr("server__inline_scripts", (list, tuple)) self.__check_attr("server__open_browser", bool) self.__check_attr("server__force_https", bool) self.__check_attr("server__flask_secret_key", (type(None), str)) @@ -238,6 +247,7 @@ class AppConfig(object): self.__check_attr("server__about_legal_tos", (type(None), str)) self.__check_attr("server__about_legal_privacy", (type(None), str)) self.__check_attr("server__server_timing_headers", bool) + self.__check_attr("server__csp_directives", (type(None), dict)) if self.server__port: if not is_port_available(self.server__host, self.server__port): @@ -262,6 +272,18 @@ class AppConfig(object): # second, from config file self.server__flask_secret_key = os.environ.get("CXG_SECRET_KEY", self.server__flask_secret_key) + # CSP Directives are a dict of string: list(string) or string: string + if self.server__csp_directives is not None: + for k, v in self.server__csp_directives.items(): + if not isinstance(k, str): + raise ConfigurationError(f"CSP directive names must be a string.") + if isinstance(v, list): + for policy in v: + if not isinstance(policy, str): + raise ConfigurationError(f"CSP directive value must be a string or list of strings.") + elif not isinstance(v, str): + raise ConfigurationError(f"CSP directive value must be a string or list of strings.") + def handle_data_locator(self, context): self.__check_attr("data_locator__s3__region_name", (type(None), bool, str)) if self.data_locator__s3__region_name is True: diff --git a/server/common/default_config.py b/server/common/default_config.py index 64e042c4..7c3dba4a 100644 --- a/server/common/default_config.py +++ b/server/common/default_config.py @@ -9,6 +9,7 @@ server: host: "127.0.0.1" port : null scripts : [] + inline_scripts: [] open_browser: false about_legal_tos: null about_legal_privacy: null @@ -16,6 +17,7 @@ server: flask_secret_key: null generate_cache_control_headers: false server_timing_headers: false + csp_directives: null presentation: max_categories: 1000 diff --git a/server/eb/app.py b/server/eb/app.py index 9fd27492..912990ba 100644 --- a/server/eb/app.py +++ b/server/eb/app.py @@ -2,11 +2,14 @@ import sys import os +import hashlib +import base64 from flask import json import logging from flask_talisman import Talisman import boto3 + if os.path.isdir("/opt/python/log"): # This is the standard location where Amazon EC2 instances store the application logs. logging.basicConfig( @@ -54,24 +57,32 @@ class WSGIServer(Server): @staticmethod def _before_adding_routes(app, app_config): - script_hashes, style_hashes = WSGIServer.load_csp_hashes(app) + script_hashes, style_hashes = WSGIServer.get_csp_hashes(app, app_config) csp = { - "default-src": "'self'", + "default-src": ["'self'"], "script-src": ["'unsafe-eval'", "'unsafe-inline'"] + script_hashes, "img-src": ["'self'", "data:"], - "object-src": "'none'", - "base-uri": "'none'", - "upgrade-insecure-requests": "", - "frame-ancestors": "'none'", - "require-trusted-types-for": "'script'", + "object-src": ["'none'"], + "base-uri": ["'none'"], + "upgrade-insecure-requests": [""], + "frame-ancestors": ["'none'"], + "require-trusted-types-for": ["'script'"], } if len(style_hashes) > 0: csp["style-src"] = style_hashes + if app_config.server__inline_scripts: + csp["script-src"].append("'strict-dynamic'") + + if app_config.server__csp_directives: + for k, v in app_config.server__csp_directives.items(): + if not isinstance(v, list): + v = [v] + csp[k] = csp.get(k, []) + v Talisman(app, force_https=app_config.server__force_https, frame_options="DENY", content_security_policy=csp) @staticmethod - def load_csp_hashes(app): + def load_static_csp_hashes(app): csp_hashes = None try: with app.open_resource("../common/web/csp-hashes.json") as f: @@ -88,6 +99,26 @@ class WSGIServer(Server): return (script_hashes, style_hashes) + @staticmethod + def compute_inline_scp_hashes(app, app_config): + inline_scripts = app_config.server__inline_scripts + hashes = [] + for script in inline_scripts: + with app.open_resource(f"../common/web/templates/{script}") as f: + content = f.read() + # we use jinja2 template include, which trims final newline if present. + if content[-1] == 0x0A: + content = content[0:-1] + hash = base64.b64encode(hashlib.sha256(content).digest()) + hashes.append(f"'sha256-{hash.decode('utf-8')}'") + return hashes + + @staticmethod + def get_csp_hashes(app, app_config): + script_hashes, style_hashes = WSGIServer.load_static_csp_hashes(app) + script_hashes += WSGIServer.compute_inline_scp_hashes(app, app_config) + return (script_hashes, style_hashes) + try: app_config = AppConfig()