From 458818663170ed4f0996559f5d9e05f489590d46 Mon Sep 17 00:00:00 2001 From: Walter Leibbrandt <23798+walterl@users.noreply.github.com> Date: Wed, 7 Aug 2019 00:31:10 +0200 Subject: [PATCH] Compute Content-Security-Policy hashes for inline JS (#10504) * Split out inline JS from index/init templates to separate files * Read inline JS from resources at run time * Calculate inline JS hashes for CSP header from content * Move inline JS to resource sub-directory * Update and memoize inline JS loading * Revert debug code * Deduplicate `resp/response` calls * Fix paths to moved inline JS resources * Force creation of test data DB so things don't get left in the cache This fix was provided by @camsaul. * Combine and `defonce` inline JS hashes * s/inlinejs/inline-js/ --- resources/frontend_client/index_template.html | 52 +------------- resources/frontend_client/init.html | 70 +------------------ .../inline_js/index_bootstrap.js | 26 +++++++ .../inline_js/index_ganalytics.js | 12 ++++ .../inline_js/index_webfontconfig.js | 5 ++ resources/frontend_client/inline_js/init.js | 67 ++++++++++++++++++ src/metabase/middleware/security.clj | 59 +++++++++++----- src/metabase/routes/index.clj | 22 ++++-- .../middleware/resolve_source_table_test.clj | 2 + 9 files changed, 174 insertions(+), 141 deletions(-) create mode 100644 resources/frontend_client/inline_js/index_bootstrap.js create mode 100644 resources/frontend_client/inline_js/index_ganalytics.js create mode 100644 resources/frontend_client/inline_js/index_webfontconfig.js create mode 100644 resources/frontend_client/inline_js/init.js diff --git a/resources/frontend_client/index_template.html b/resources/frontend_client/index_template.html index b4abcd35dac..18e68fa362c 100644 --- a/resources/frontend_client/index_template.html +++ b/resources/frontend_client/index_template.html @@ -34,34 +34,7 @@ </script> <!-- If you modify this script, make sure you update the whitelisted Content-Security-Policy hash in metabase.middleware.security --> - <script type="text/javascript"> - (function() { - window.MetabaseBootstrap = JSON.parse(document.getElementById("_metabaseBootstrap").textContent); - window.MetabaseLocalization = JSON.parse(document.getElementById("_metabaseLocalization").textContent); - - var configuredRoot = document.head.querySelector("meta[name='base-href']").content; - var actualRoot = "/"; - - // Add trailing slashes - var backendPathname = document.head.querySelector("meta[name='uri']").content.replace(/\/*$/, "/"); - // e.x. "/questions/" - var frontendPathname = window.location.pathname.replace(/\/*$/, "/"); - // e.x. "/metabase/questions/" - if (backendPathname === frontendPathname.slice(-backendPathname.length)) { - // Remove the backend pathname from the end of the frontend pathname - actualRoot = frontendPathname.slice(0, -backendPathname.length) + "/"; - // e.x. "/metabase/" - } - - if (actualRoot !== configuredRoot) { - console.warn("Warning: the Metabase site URL basename \"" + configuredRoot + "\" does not match the actual basename \"" + actualRoot + "\"."); - console.warn("You probably want to update the Site URL setting to \"" + window.location.origin + actualRoot + "\""); - document.getElementsByTagName("base")[0].href = actualRoot; - } - - window.MetabaseRoot = actualRoot; - })(); - </script> + <script type="text/javascript">{{{bootstrapJS}}}</script> </head> <body> @@ -69,13 +42,7 @@ <!-- Using the Web Font Loader lets us load the fonts asynchronously for faster page loads -- see https://github.com/typekit/webfontloader --> <!-- If you modify this script, make sure you update the whitelisted Content-Security-Policy hash in metabase.middleware.security --> - <script type="text/javascript"> - WebFontConfig = { - google: { - families: ["Lato:300,400,700,900"] - } - }; - </script> + <script type="text/javascript">{{{webFontConfigJS}}}</script> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js" async></script> <!-- If Google Auth is enabled load the Google Auth JS lib --> @@ -86,20 +53,7 @@ <!-- Google Analytics --> <!-- If you modify this script, make sure you update the whitelisted Content-Security-Policy hash in metabase.middleware.security --> {{#enableAnonTracking}} - <script type="text/javascript"> - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); - - // if we are not doing tracking then go ahead and disable GA now so we never even track the initial pageview - const tracking = window.MetabaseBootstrap.anon_tracking_enabled; - const ga_code = window.MetabaseBootstrap.ga_code; - if (!tracking) { - window['ga-disable-'+ga_code] = true; - } - - ga('create', ga_code, 'auto'); - </script> + <script type="text/javascript">{{{googleAnalyticsJS}}}</script> {{/enableAnonTracking}} </body> </html> diff --git a/resources/frontend_client/init.html b/resources/frontend_client/init.html index 5526fb2fd2a..c3f66e027a1 100644 --- a/resources/frontend_client/init.html +++ b/resources/frontend_client/init.html @@ -136,74 +136,6 @@ </div> </div> - <script type="text/javascript"> - var content = - [ - ['drill-through-img', 'Click on your charts to dive deeper.'], - ['metabot-img shadow', 'Bring your charts and data into Slack.'], - ['calendar-img', 'Dashboard filters let you filter all your charts at once.'], - ['charts-img shadow', 'Easily create and share beautiful dashboards.'], - ['column-heading-img', 'Click on column headings in your tables to explore them.'] - ]; - - var featureImage = document.getElementById("feature-image"); - var heading = document.getElementById("heading"); - - var counter = 0; - - function switcher() { - setInterval(function() { - counter++; - if (counter == content.length) counter = 0; - featureImage.className = featureImage.className.replace(" opaque", "") + " transparent"; - heading.className = "transparent"; - - // Need to somehow wait here for a sec before fading things back in - setTimeout(function() { - featureImage.className = content[counter][0] + " opaque"; - heading.innerHTML = content[counter][1]; - heading.className = "opaque"; - }, 200); - }, 4000); - } - - var messages = [ - "Polishing tables…", - "Scaling scalars…", - "Straightening columns…", - "Embiggening data…", - "Reticulating splines…" - ]; - var progressElement = document.getElementById("progress"); - var statusElement = document.getElementById("status"); - - function poll() { - var req = new XMLHttpRequest(); - req.open("GET", "/api/health", true); - req.onreadystatechange = function() { - if (req.readyState === 4) { - if (req.status === 200) { - window.location.reload(); - } else { - try { - var health = JSON.parse(req.responseText); - if (typeof health.progress === "number") { - var newValue = health.progress * 100; - if (newValue !== progressElement.value) { - progressElement.value = newValue; - statusElement.textContent = messages[Math.floor(Math.random() * messages.length)]; - } - } - } catch (e) {} - setTimeout(poll, 500); - } - } - } - req.send(); - } - - switcher(); - poll(); - </script> + <script type="text/javascript">{{{initJS}}}</script> </body> </html> diff --git a/resources/frontend_client/inline_js/index_bootstrap.js b/resources/frontend_client/inline_js/index_bootstrap.js new file mode 100644 index 00000000000..a7b9d4ef8b2 --- /dev/null +++ b/resources/frontend_client/inline_js/index_bootstrap.js @@ -0,0 +1,26 @@ +(function() { + window.MetabaseBootstrap = JSON.parse(document.getElementById("_metabaseBootstrap").textContent); + window.MetabaseLocalization = JSON.parse(document.getElementById("_metabaseLocalization").textContent); + + var configuredRoot = document.head.querySelector("meta[name='base-href']").content; + var actualRoot = "/"; + + // Add trailing slashes + var backendPathname = document.head.querySelector("meta[name='uri']").content.replace(/\/*$/, "/"); + // e.x. "/questions/" + var frontendPathname = window.location.pathname.replace(/\/*$/, "/"); + // e.x. "/metabase/questions/" + if (backendPathname === frontendPathname.slice(-backendPathname.length)) { + // Remove the backend pathname from the end of the frontend pathname + actualRoot = frontendPathname.slice(0, -backendPathname.length) + "/"; + // e.x. "/metabase/" + } + + if (actualRoot !== configuredRoot) { + console.warn("Warning: the Metabase site URL basename \"" + configuredRoot + "\" does not match the actual basename \"" + actualRoot + "\"."); + console.warn("You probably want to update the Site URL setting to \"" + window.location.origin + actualRoot + "\""); + document.getElementsByTagName("base")[0].href = actualRoot; + } + + window.MetabaseRoot = actualRoot; +})(); diff --git a/resources/frontend_client/inline_js/index_ganalytics.js b/resources/frontend_client/inline_js/index_ganalytics.js new file mode 100644 index 00000000000..20056b46477 --- /dev/null +++ b/resources/frontend_client/inline_js/index_ganalytics.js @@ -0,0 +1,12 @@ +(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) +})(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + +// if we are not doing tracking then go ahead and disable GA now so we never even track the initial pageview +const tracking = window.MetabaseBootstrap.anon_tracking_enabled; +const ga_code = window.MetabaseBootstrap.ga_code; +if (!tracking) { + window['ga-disable-'+ga_code] = true; +} + +ga('create', ga_code, 'auto'); diff --git a/resources/frontend_client/inline_js/index_webfontconfig.js b/resources/frontend_client/inline_js/index_webfontconfig.js new file mode 100644 index 00000000000..d5f2bb927ce --- /dev/null +++ b/resources/frontend_client/inline_js/index_webfontconfig.js @@ -0,0 +1,5 @@ +WebFontConfig = { + google: { + families: ["Lato:300,400,700,900"] + } +}; diff --git a/resources/frontend_client/inline_js/init.js b/resources/frontend_client/inline_js/init.js new file mode 100644 index 00000000000..42696b36561 --- /dev/null +++ b/resources/frontend_client/inline_js/init.js @@ -0,0 +1,67 @@ +var content = + [ + ['drill-through-img', 'Click on your charts to dive deeper.'], + ['metabot-img shadow', 'Bring your charts and data into Slack.'], + ['calendar-img', 'Dashboard filters let you filter all your charts at once.'], + ['charts-img shadow', 'Easily create and share beautiful dashboards.'], + ['column-heading-img', 'Click on column headings in your tables to explore them.'] + ]; + +var featureImage = document.getElementById("feature-image"); +var heading = document.getElementById("heading"); + +var counter = 0; + +function switcher() { + setInterval(function() { + counter++; + if (counter == content.length) counter = 0; + featureImage.className = featureImage.className.replace(" opaque", "") + " transparent"; + heading.className = "transparent"; + + // Need to somehow wait here for a sec before fading things back in + setTimeout(function() { + featureImage.className = content[counter][0] + " opaque"; + heading.innerHTML = content[counter][1]; + heading.className = "opaque"; + }, 200); + }, 4000); +} + +var messages = [ + "Polishing tables…", + "Scaling scalars…", + "Straightening columns…", + "Embiggening data…", + "Reticulating splines…" +]; +var progressElement = document.getElementById("progress"); +var statusElement = document.getElementById("status"); + +function poll() { + var req = new XMLHttpRequest(); + req.open("GET", "/api/health", true); + req.onreadystatechange = function() { + if (req.readyState === 4) { + if (req.status === 200) { + window.location.reload(); + } else { + try { + var health = JSON.parse(req.responseText); + if (typeof health.progress === "number") { + var newValue = health.progress * 100; + if (newValue !== progressElement.value) { + progressElement.value = newValue; + statusElement.textContent = messages[Math.floor(Math.random() * messages.length)]; + } + } + } catch (e) {} + setTimeout(poll, 500); + } + } + } + req.send(); +} + +switcher(); +poll(); diff --git a/src/metabase/middleware/security.clj b/src/metabase/middleware/security.clj index 341b89a633b..b21205ee89b 100644 --- a/src/metabase/middleware/security.clj +++ b/src/metabase/middleware/security.clj @@ -1,12 +1,39 @@ (ns metabase.middleware.security "Ring middleware for adding security-related headers to API responses." - (:require [clojure.string :as str] + (:require [clojure.java.io :as io] + [clojure.string :as str] [metabase.config :as config] [metabase.middleware.util :as middleware.u] [metabase.models.setting :refer [defsetting]] [metabase.util [date :as du] - [i18n :as ui18n :refer [tru]]])) + [i18n :as ui18n :refer [tru]]] + [ring.util.codec :refer [base64-encode]]) + (:import java.security.MessageDigest)) + +(defn- file-hash [resource-filename] + (base64-encode + (.digest (doto (java.security.MessageDigest/getInstance "SHA-256") + (.update (.getBytes (slurp (io/resource resource-filename)))))))) + +(def ^:private ^:const index-bootstrap-js-hash (file-hash "frontend_client/inline_js/index_bootstrap.js")) +(def ^:private ^:const index-ganalytics-js-hash (file-hash "frontend_client/inline_js/index_ganalytics.js")) +(def ^:private ^:const index-webfontconfig-js-hash (file-hash "frontend_client/inline_js/index_webfontconfig.js")) +(def ^:private ^:const init-js-hash (file-hash "frontend_client/inline_js/init.js")) + +(defonce ^:private ^:const inline-js-hashes + (let [file-hash (fn [resource-filename] + (base64-encode + (.digest (doto (java.security.MessageDigest/getInstance "SHA-256") + (.update (.getBytes (slurp (io/resource resource-filename))))))))] + (mapv file-hash [;; inline script in index.html that sets `MetabaseBootstrap` and the like + "frontend_client/inline_js/index_bootstrap.js" + ;; inline script in index.html that loads Google Analytics + "frontend_client/inline_js/index_ganalytics.js" + ;; Web Font Loader font configuration (WebFontConfig) in index.html + "frontend_client/inline_js/index_webfontconfig.js" + ;; inline script in init.html + "frontend_client/inline_js/init.js"]))) (defn- cache-prevention-headers "Headers that tell browsers not to cache a response." @@ -30,22 +57,18 @@ {"Content-Security-Policy" (str/join (for [[k vs] {:default-src ["'none'"] - :script-src ["'self'" - "'unsafe-eval'" ; TODO - we keep working towards removing this entirely - "https://maps.google.com" - "https://apis.google.com" - "https://www.google-analytics.com" ; Safari requires the protocol - "https://*.googleapis.com" - "*.gstatic.com" - ;; for webpack hot reloading - (when config/is-dev? - "localhost:8080") - ;; inline script in index.html that sets `MetabaseBootstrap` and the like - "'sha256-xlgrBEvjf72cXGba6bCV/PwIVp1DcbdhY74VIXN8fA4='" - ;; Web Font Loader font configuration (WebFontConfig) in index.html - "'sha256-6xC9z5Dcryu9jbxUZkBJ5yUmSofhJjt7Mbnp/ijPkFs='" - ;; inline script in index.html that loads Google Analytics - "'sha256-uKEj/Qp9AmQA2Xv83bZX9mNVV2VWZteZjIsVNVzLkA0='"] + :script-src (concat + ["'self'" + "'unsafe-eval'" ; TODO - we keep working towards removing this entirely + "https://maps.google.com" + "https://apis.google.com" + "https://www.google-analytics.com" ; Safari requires the protocol + "https://*.googleapis.com" + "*.gstatic.com" + ;; for webpack hot reloading + (when config/is-dev? + "localhost:8080")] + (map (partial format "'sha256-%s'") inline-js-hashes)) :child-src ["'self'" ;; TODO - double check that we actually need this for Google Auth "https://accounts.google.com"] diff --git a/src/metabase/routes/index.clj b/src/metabase/routes/index.clj index 70056e1a480..864b2f87f8e 100644 --- a/src/metabase/routes/index.clj +++ b/src/metabase/routes/index.clj @@ -53,6 +53,10 @@ (fn [] (memoized-load-localization *locale*)))) +(defn- load-inline-js* [resource-name] + (slurp (io/resource (format "frontend_client/inline_js/%s.js" resource-name)))) + +(def ^:private ^{:arglists '([resource-name])} load-inline-js (memoize load-inline-js*)) (defn- load-template [path variables] (try @@ -66,7 +70,10 @@ (load-template (str "frontend_client/" entrypoint-name ".html") (let [{:keys [anon_tracking_enabled google_auth_client_id], :as public-settings} (public-settings/public-settings)] - {:bootstrapJSON (escape-script (json/generate-string public-settings)) + {:bootstrapJS (load-inline-js "index_bootstrap") + :googleAnalyticsJS (load-inline-js "index_ganalytics") + :webFontConfigJS (load-inline-js "index_webfontconfig") + :bootstrapJSON (escape-script (json/generate-string public-settings)) :localizationJSON (escape-script (load-localization)) :uri (h.util/escape-html uri) :baseHref (h.util/escape-html (base-href)) @@ -74,14 +81,19 @@ :enableGoogleAuth (boolean google_auth_client_id) :enableAnonTracking (boolean anon_tracking_enabled)}))) +(defn- load-init-template [] + (load-template + "frontend_client/init.html" + {:initJS (load-inline-js "init")})) + (defn- entrypoint "Repsonse that serves up an entrypoint into the Metabase application, e.g. `index.html`." [entrypoint-name embeddable? {:keys [uri]} respond raise] (respond - (-> (if (init-status/complete?) - (resp/response (load-entrypoint-template entrypoint-name embeddable? uri)) - (resp/resource-response "frontend_client/init.html")) - (resp/content-type "text/html; charset=utf-8")))) + (-> (resp/response (if (init-status/complete?) + (load-entrypoint-template entrypoint-name embeddable? uri) + (load-init-template))) + (resp/content-type "text/html; charset=utf-8")))) (def index "main index.html entrypoint." (partial entrypoint "index" (not :embeddable))) (def public "/public index.html entrypoint." (partial entrypoint "public" :embeddable)) diff --git a/test/metabase/query_processor/middleware/resolve_source_table_test.clj b/test/metabase/query_processor/middleware/resolve_source_table_test.clj index e141bd8af53..de6d57671be 100644 --- a/test/metabase/query_processor/middleware/resolve_source_table_test.clj +++ b/test/metabase/query_processor/middleware/resolve_source_table_test.clj @@ -14,6 +14,8 @@ ((resolve-source-table/resolve-source-tables identity) query)) (defn- do-with-store-contents [f] + ;; force creation of test data DB so things don't get left in the cache before running tests below + (data/id) (qp.store/with-store (qp.store/fetch-and-store-database! (data/id)) (f) -- GitLab