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