diff --git a/client/configuration/webpack/cspHashPlugin.js b/client/configuration/webpack/cspHashPlugin.js new file mode 100644 index 00000000..d219dc82 --- /dev/null +++ b/client/configuration/webpack/cspHashPlugin.js @@ -0,0 +1,50 @@ +const cheerio = require("cheerio"); +const crypto = require("crypto"); +HtmlWebpackPlugin = require("html-webpack-plugin"); + +class CspHashPlugin { + constructor(opts) { + this.opts = { ...opts }; + } + + apply(compiler) { + compiler.hooks.compilation.tap("CspHashPlugin", (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync( + "CspHashPlugin", + (data, cb) => { + const { filename } = this.opts; + + if (filename) { + const $ = cheerio.load(data.html, { decodeEntities: false }); + const results = {}; + results["script-hashes"] = $("script:not([src])") + .map((i, elmt) => this.digest($(elmt).html())) + .get(); + results["style-hashes"] = $("style:not([href])") + .map((i, elmt) => this.digest($(elmt).html())) + .get(); + + const json = JSON.stringify(results); + compilation.assets[filename] = { + source: () => json, + size: () => json.length, + }; + } + + // Tell webpack to move on + cb(null, data); + } + ); + }); + } + + digest(str) { + const hash = crypto + .createHash("sha256") + .update(str, "utf8") + .digest("base64"); + return "sha256-" + hash; + } +} + +module.exports = CspHashPlugin; diff --git a/client/configuration/webpack/webpack.config.prod.js b/client/configuration/webpack/webpack.config.prod.js index 9cd53a9d..06e7b08c 100644 --- a/client/configuration/webpack/webpack.config.prod.js +++ b/client/configuration/webpack/webpack.config.prod.js @@ -5,6 +5,7 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); const { CleanWebpackPlugin } = require("clean-webpack-plugin"); +const CspHashPlugin = require("./cspHashPlugin"); const src = path.resolve("src"); const fonts = path.resolve("src/fonts"); @@ -21,7 +22,7 @@ module.exports = { entry: ["./src/index.js"], output: { path: path.resolve("build"), - publicPath + publicPath, }, module: { rules: [ @@ -29,7 +30,7 @@ module.exports = { test: /\.js$/, include: src, loader: "babel-loader", - options: babelOptions + options: babelOptions, }, { test: /\.css$/, @@ -41,12 +42,12 @@ module.exports = { loader: "css-loader", options: { modules: { - localIdentName: "[name]__[local]___[hash:base64:5]" + localIdentName: "[name]__[local]___[hash:base64:5]", }, - importLoaders: 1 - } - } - ] + importLoaders: 1, + }, + }, + ], }, { test: /index\.css$/, @@ -56,24 +57,24 @@ module.exports = { { loader: "css-loader", options: { - importLoaders: 1 - } - } - ] + importLoaders: 1, + }, + }, + ], }, { test: /\.json$/, include: [src, nodeModules], loader: "json-loader", - exclude: /manifest.json$/ + exclude: /manifest.json$/, }, { test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i, loader: "file-loader", include: [nodeModules, fonts], - query: { name: "static/assets/[name]-[contenthash].[ext]" } - } - ] + query: { name: "static/assets/[name]-[contenthash].[ext]" }, + }, + ], }, plugins: [ new HtmlWebpackPlugin({ @@ -91,13 +92,13 @@ module.exports = { keepClosingSlash: true, minifyJS: true, minifyCSS: true, - minifyURLs: true - } + minifyURLs: true, + }, }), new CleanWebpackPlugin({ verbose: true, protectWebpackAssets: false, - cleanAfterEveryBuildPatterns: ["main.js", "main.css"] + cleanAfterEveryBuildPatterns: ["main.js", "main.css"], }), new FaviconsWebpackPlugin({ logo: "./favicon.png", @@ -110,15 +111,18 @@ module.exports = { coast: false, firefox: false, windows: false, - yandex: false - } - } + yandex: false, + }, + }, }), new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin), - new MiniCssExtractPlugin() + new MiniCssExtractPlugin(), + new CspHashPlugin({ + filename: "csp-hashes.json", + }), ], performance: { maxEntrypointSize: 2000000, - maxAssetSize: 2000000 - } + maxAssetSize: 2000000, + }, }; diff --git a/client/package-lock.json b/client/package-lock.json index be12cfef..79864f3c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4345,6 +4345,47 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.3.tgz", + "integrity": "sha512-0td5ijfUPuubwLUu0OBoe98gZj8C/AA+RW3v67GPlGOrvxWjZmBXiBCRU+I8VEiNyJzjth40POfHiz2RB3gImA==", + "dev": true, + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.1", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash": "^4.15.0", + "parse5": "^3.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", + "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "dev": true, + "requires": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "parse5": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz", + "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==", + "dev": true, + "requires": { + "@types/node": "*" + } + } + } + }, "chokidar": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", @@ -5721,6 +5762,29 @@ "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", "dev": true }, + "csp-html-webpack-plugin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/csp-html-webpack-plugin/-/csp-html-webpack-plugin-4.0.0.tgz", + "integrity": "sha512-1YqQefNG0SrZisysThlly2bgs4Ab/W91xOM17S8wd+6vTo3E0OdL+y4IAR0MKpthRluNGzFB3QhPqdOhkXAExg==", + "dev": true, + "requires": { + "cheerio": "^1.0.0-rc.3", + "lodash": "^4.17.15", + "memory-fs": "^0.5.0" + }, + "dependencies": { + "memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + } + } + }, "css-loader": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.4.2.tgz", diff --git a/client/package.json b/client/package.json index 47d6c336..274dc126 100644 --- a/client/package.json +++ b/client/package.json @@ -67,6 +67,7 @@ "babel-loader": "^8.1.0", "babel-preset-modern-browsers": "^14.2.1", "chalk": "^4.0.0", + "cheerio": "^1.0.0-rc.3", "clean-webpack-plugin": "^3.0.0", "codecov": "^3.6.5", "connect-history-api-fallback": "^1.6.0", diff --git a/common.mk b/common.mk index a1c5a681..34bc380e 100644 --- a/common.mk +++ b/common.mk @@ -28,5 +28,6 @@ define copy_client_assets mkdir -p $(2)/common/web/templates/ cp $(1)/index.html $(2)/common/web/templates/ cp -r $(1)/static $(2)/common/web/ + cp $(1)/csp-hashes.json $(2)/common/web/ endef diff --git a/server/app/app.py b/server/app/app.py index 66c0fcd3..bc9d6990 100644 --- a/server/app/app.py +++ b/server/app/app.py @@ -242,9 +242,14 @@ def get_api_resources(bp_api): class Server: + @staticmethod + def _before_adding_routes(app, app_config): + """ will be called before routes are added, during __init__. Subclass protocol """ + pass + def __init__(self, app_config): self.app = Flask(__name__, static_folder="../common/web/static") - self._before_adding_routes(app_config) + self._before_adding_routes(self.app, app_config) self.app.json_encoder = Float32JSONEncoder if app_config.server__server_timing_headers: ServerTiming(self.app, force_debug=True) @@ -275,7 +280,3 @@ class Server: self.app.matrix_data_cache_manager = app_config.matrix_data_cache_manager self.app.annotations = app_config.user_annotations self.app.app_config = app_config - - def _before_adding_routes(self, app_config): - """ will be called before routes are added. Subclass protocol """ - pass diff --git a/server/cli/launch.py b/server/cli/launch.py index 1be05b7d..53a642ab 100644 --- a/server/cli/launch.py +++ b/server/cli/launch.py @@ -278,8 +278,9 @@ class CliLaunchServer(Server): def __init__(self, app_config): super().__init__(app_config) - def _before_adding_routes(self, app_config): - self.app.config["COMPRESS_MIMETYPES"] = [ + @staticmethod + def _before_adding_routes(app, app_config): + app.config["COMPRESS_MIMETYPES"] = [ "text/html", "text/css", "text/xml", @@ -287,9 +288,9 @@ class CliLaunchServer(Server): "application/javascript", "application/octet-stream", ] - Compress(self.app) + Compress(app) if app_config.server__debug: - CORS(self.app, supports_credentials=True) + CORS(app, supports_credentials=True) @sort_options diff --git a/server/common/health.py b/server/common/health.py index 0fee633d..90c95aa6 100644 --- a/server/common/health.py +++ b/server/common/health.py @@ -30,4 +30,6 @@ def health_check(config): ] health["status"] = "pass" if all(checks) else "fail" code = HTTPStatus.OK if health["status"] == "pass" else HTTPStatus.BAD_REQUEST - return make_response(jsonify(health), code, {"Content-Type": "application/health+json"},) + response = make_response(jsonify(health), code) + response.headers["Content-Type"] = "application/health+json" + return response diff --git a/server/eb/app.py b/server/eb/app.py index 29725e0f..5fba22c0 100644 --- a/server/eb/app.py +++ b/server/eb/app.py @@ -2,10 +2,10 @@ import sys import os +from flask import json import logging from flask_talisman import Talisman import boto3 -import json if os.path.isdir("/opt/python/log"): # This is the standard location where Amazon EC2 instances store the application logs. @@ -52,9 +52,38 @@ class WSGIServer(Server): def __init__(self, app_config): super().__init__(app_config) - def _before_adding_routes(self, app_config): - csp = {"default-src": "'self' 'unsafe-inline' 'unsafe-eval'", "img-src": ["'self'", "data:"]} - Talisman(self.app, force_https=app_config.server__force_https, content_security_policy=csp) + @staticmethod + def _before_adding_routes(app, app_config): + script_hashes, style_hashes = WSGIServer.load_csp_hashes(app) + csp = { + "default-src": "'self'", + "script-src": ["'unsafe-eval'", "'unsafe-inline'"] + script_hashes, + "img-src": ["'self'", "data:"], + "object-src": "'none'", + "base-uri": "'none'", + } + if len(style_hashes) > 0: + csp["style-src"] = style_hashes + + Talisman(app, force_https=app_config.server__force_https, content_security_policy=csp) + + @staticmethod + def load_csp_hashes(app): + csp_hashes = None + try: + with app.open_resource("../common/web/csp-hashes.json") as f: + csp_hashes = json.load(f) + except FileNotFoundError: + pass + if not isinstance(csp_hashes, dict): + csp_hashes = {} + script_hashes = [f"'{hash}'" for hash in csp_hashes.get("script-hashes", [])] + style_hashes = [f"'{hash}'" for hash in csp_hashes.get("style-hashes", [])] + + if len(script_hashes) == 0 or len(style_hashes) == 0: + logging.error("Content security policy hashes are missing, falling back to unsafe-inline policy") + + return (script_hashes, style_hashes) try: