From 1ead699e7a184114f54f065be3a1f37745e5b9a4 Mon Sep 17 00:00:00 2001
From: Noah Moss <32746338+noahmoss@users.noreply.github.com>
Date: Fri, 9 Sep 2022 15:20:04 -0400
Subject: [PATCH] Fix issues with postgres PKCS-12 SSL connections (#25299)

* use .p12 file extension for pkcs-12 keys

* test

* fix messed up indentation

* default to empty string ssl password

* address feedback

* fix tests
---
 src/metabase/driver/postgres.clj       |  24 ++++--
 src/metabase/models/secret.clj         | 102 ++++++++++++++-----------
 test/metabase/driver/postgres_test.clj |  57 +++++++++-----
 3 files changed, 112 insertions(+), 71 deletions(-)

diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj
index 87651288d52..033abfe39c5 100644
--- a/src/metabase/driver/postgres.clj
+++ b/src/metabase/driver/postgres.clj
@@ -196,9 +196,9 @@
         (comp (mapcat get-typenames)
               (map keyword))
         (jdbc/query (sql-jdbc.conn/db->pooled-connection-spec database)
-                       [(str "SELECT nspname, typname "
-                             "FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace "
-                             "WHERE t.oid IN (SELECT DISTINCT enumtypid FROM pg_enum e)")])))
+                    [(str "SELECT nspname, typname "
+                          "FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace "
+                          "WHERE t.oid IN (SELECT DISTINCT enumtypid FROM pg_enum e)")])))
 
 (def ^:private ^:dynamic *enum-types* nil)
 
@@ -358,7 +358,7 @@
 ;; Postgres is not happy with JSON fields which are in group-bys or order-bys
 ;; being described twice instead of using the alias.
 ;; Therefore, force the alias, but only for JSON fields to avoid ambiguity.
-;; The alias names in JSON fields are unique wrt nfc path"
+;; The alias names in JSON fields are unique wrt nfc path
 (defmethod sql.qp/apply-top-level-clause
   [:postgres :breakout]
   [driver clause honeysql-form {breakout-fields :breakout, _fields-fields :fields :as query}]
@@ -509,9 +509,16 @@
     "inet"  :type/IPAddress
     nil))
 
+(defn- pkcs-12-key-value?
+  "If a value was uploaded for the SSL key, return whether it's using the PKCS-12 format."
+  [ssl-key-value]
+  (when ssl-key-value
+    (= (second (re-find secret/uploaded-base-64-prefix-pattern ssl-key-value))
+       "x-pkcs12")))
+
 (defn- ssl-params
   "Builds the params to include in the JDBC connection spec for an SSL connection."
-  [db-details]
+  [{:keys [ssl-key-value] :as db-details}]
   (let [ssl-root-cert   (when (contains? #{"verify-ca" "verify-full"} (:ssl-mode db-details))
                           (secret/db-details-prop->secret-map db-details "ssl-root-cert"))
         ssl-client-key  (when (:ssl-use-client-auth db-details)
@@ -531,13 +538,14 @@
       (assoc :sslrootcert (secret/value->file! ssl-root-cert :postgres))
 
       (has-value? ssl-client-key)
-      (assoc :sslkey (secret/value->file! ssl-client-key :postgres))
+      (assoc :sslkey (secret/value->file! ssl-client-key :postgres (when (pkcs-12-key-value? ssl-key-value) ".p12")))
 
       (has-value? ssl-client-cert)
       (assoc :sslcert (secret/value->file! ssl-client-cert :postgres))
 
-      (has-value? ssl-key-pw)
-      (assoc :sslpassword (secret/value->string ssl-key-pw))
+      ;; Pass an empty string as password if none is provided; otherwise the driver will prompt for one
+      true
+      (assoc :sslpassword (or (secret/value->string ssl-key-pw) ""))
 
       true
       (as-> params ;; from outer cond->
diff --git a/src/metabase/models/secret.clj b/src/metabase/models/secret.clj
index c6ee61bc318..7d78295a516 100644
--- a/src/metabase/models/secret.clj
+++ b/src/metabase/models/secret.clj
@@ -62,48 +62,54 @@
 
   `driver?` is an optional argument that is only used if an ostensibly existing file value (i.e. `:file-path`) can't be
   resolved, in order to render a more user-friendly error message (by looking up the display names of the connection
-  properties involved)."
+  properties involved).
+
+  `ext?` is an optional argument that sets the file extension used for the temporary file, if one needs to be created."
   {:added "0.42.0"}
-  ^File [{:keys [connection-property-name id value] :as secret} driver?]
-  (if (= :file-path (:source secret))
-    (let [secret-val          (value->string secret)
-          ^File existing-file (File. secret-val)]
-      (if (.exists existing-file)
-        existing-file
-        (let [error-source (cond
-                             id
-                             (tru "Secret ID {0}" id)
-
-                             (and connection-property-name driver?)
-                             (let [secret-props (-> (driver/connection-properties driver?)
-                                                    conn-props->secret-props-by-name)]
-                               (tru "File path for {0}" (-> (get secret-props connection-property-name)
-                                                          :display-name)))
-
-                             :else
-                             (tru "Path"))]
-          (throw (ex-info (tru "{0} points to non-existent file: {1}" error-source secret-val)
-                   {:file-path secret-val
-                    :secret    secret})))))
-    (let [^File tmp-file (doto (File/createTempFile "metabase-secret_" nil)
-                           ;; make the file only readable by owner
-                           (.setReadable false false)
-                           (.setReadable true true)
-                           (.deleteOnExit))]
-      (log/tracef "Creating temp file for secret %s value at %s" (or id "") (.getAbsolutePath tmp-file))
-      (with-open [out (io/output-stream tmp-file)]
-        (let [^bytes v (cond
-                         (string? value)
-                         (.getBytes ^String value "UTF-8")
-
-                         (bytes? value)
-                         ^bytes value)]
-          (.write out v)))
-      tmp-file)))
+  (^File [secret]
+   (value->file!* secret nil))
+  (^File [secret driver?]
+   (value->file!* secret driver? nil))
+  (^File [{:keys [connection-property-name id value] :as secret} driver? ext?]
+   (if (= :file-path (:source secret))
+     (let [secret-val          (value->string secret)
+           ^File existing-file (File. secret-val)]
+       (if (.exists existing-file)
+         existing-file
+         (let [error-source (cond
+                              id
+                              (tru "Secret ID {0}" id)
+
+                              (and connection-property-name driver?)
+                              (let [secret-props (-> (driver/connection-properties driver?)
+                                                     conn-props->secret-props-by-name)]
+                                (tru "File path for {0}" (-> (get secret-props connection-property-name)
+                                                             :display-name)))
+
+                              :else
+                              (tru "Path"))]
+           (throw (ex-info (tru "{0} points to non-existent file: {1}" error-source secret-val)
+                           {:file-path secret-val
+                            :secret    secret})))))
+     (let [^File tmp-file (doto (File/createTempFile "metabase-secret_" ext?)
+                            ;; make the file only readable by owner
+                            (.setReadable false false)
+                            (.setReadable true true)
+                            (.deleteOnExit))]
+       (log/tracef "Creating temp file for secret %s value at %s" (or id "") (.getAbsolutePath tmp-file))
+       (with-open [out (io/output-stream tmp-file)]
+         (let [^bytes v (cond
+                          (string? value)
+                          (.getBytes ^String value "UTF-8")
+
+                          (bytes? value)
+                          ^bytes value)]
+           (.write out v)))
+       tmp-file))))
 
 (def
   ^java.io.File
-  ^{:arglists '([{:keys [connection-property-name id value] :as secret} driver?])}
+  ^{:arglists '([{:keys [connection-property-name id value] :as secret} & [driver? ext?]])}
   value->file!
   "Returns the value of the given `secret` instance in the form of a file. If the given instance has a `:file-path` as
   its source, a `File` referring to that is returned. Otherwise, the `:value` is written to a temporary file, which is
@@ -111,13 +117,15 @@
 
   `driver?` is an optional argument that is only used if an ostensibly existing file value (i.e. `:file-path`) can't be
   resolved, in order to render a more user-friendly error message (by looking up the display names of the connection
-  properties involved)."
+  properties involved).
+
+  `ext?` is an optional argument that sets the file extension used for the temporary file, if one needs to be created."
   (memoize/memo
    (with-meta value->file!*
-     {::memoize/args-fn (fn [[secret _driver?]]
+     {::memoize/args-fn (fn [[secret _driver? ext?]]
                           ;; not clear if value->string could return nil due to the cond so we'll just cache on a key
                           ;; that is unique
-                          [(vec (:value secret))])})))
+                          [(vec (:value secret)) ext?])})))
 
 (defn get-sub-props
   "Return a map of secret subproperties for the property `connection-property-name`."
@@ -126,7 +134,9 @@
         sub-prop #(keyword (str connection-property-name "-" (name %)))]
     (zipmap sub-prop-types (map sub-prop sub-prop-types))))
 
-(def ^:private uploaded-base-64-pattern #"^data:application/([^;]*);base64,")
+(def uploaded-base-64-prefix-pattern
+  "Regex for parsing base64 encoded file uploads."
+  #"^data:application/([^;]*);base64,")
 
 (defn db-details-prop->secret-map
   "Returns a map containing `:value` and `:source` for the given `conn-prop-nm`. `conn-prop-nm` is expected to be the
@@ -149,13 +159,13 @@
         value  (cond
                  ;; ssl-root-certs will need their prefix removed, and to be base 64 decoded (#20319)
                  (and (value-kw details) (#{"ssl-client-cert" "ssl-root-cert"} conn-prop-nm)
-                      (re-find uploaded-base-64-pattern (value-kw details)))
-                 (-> (value-kw details) (str/replace-first uploaded-base-64-pattern "") u/decode-base64)
+                      (re-find uploaded-base-64-prefix-pattern (value-kw details)))
+                 (-> (value-kw details) (str/replace-first uploaded-base-64-prefix-pattern "") u/decode-base64)
 
                  (and (value-kw details) (#{"ssl-key"} conn-prop-nm)
-                      (re-find uploaded-base-64-pattern (value-kw details)))
+                      (re-find uploaded-base-64-prefix-pattern (value-kw details)))
                  (.decode (java.util.Base64/getDecoder)
-                          (str/replace-first (value-kw details) uploaded-base-64-pattern ""))
+                          (str/replace-first (value-kw details) uploaded-base-64-prefix-pattern ""))
 
                  ;; the -value suffix was specified; use that
                  (value-kw details)
diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj
index 53eb5828c71..b31526a8b44 100644
--- a/test/metabase/driver/postgres_test.clj
+++ b/test/metabase/driver/postgres_test.clj
@@ -75,6 +75,7 @@
             :user                          "camsaul"
             :ssl                           true
             :sslmode                       "require"
+            :sslpassword                   ""
             :ApplicationName               config/mb-version-and-process-identifier}
            (sql-jdbc.conn/connection-details->spec :postgres
              {:ssl    true
@@ -106,6 +107,7 @@
             :sslkey                        "my-key"
             :sslfactory                    "myfactoryoverride"
             :sslrootcert                   "myrootcert"
+            :sslpassword                   ""
             :ApplicationName               config/mb-version-and-process-identifier}
            (sql-jdbc.conn/connection-details->spec :postgres
              {:ssl         true
@@ -258,7 +260,7 @@
             (mt/with-temp Database [database {:engine :postgres, :details (assoc details :dbname db-name)}]
               (let [sync! #(sync/sync-database! database)]
                 ;; create a main partitioned table and two partitions for it
-                (exec! spec ["CREATE TABLE part_vals (val bigint NOT NULL) PARTITION BY RANGE (\"val\")";"
+                (exec! spec ["CREATE TABLE part_vals (val bigint NOT NULL) PARTITION BY RANGE (\"val\");"
                              "CREATE TABLE part_vals_0 (val bigint NOT NULL);"
                              "ALTER TABLE ONLY part_vals ATTACH PARTITION part_vals_0 FOR VALUES FROM (0) TO (1000);"
                              "CREATE TABLE part_vals_1 (val bigint NOT NULL);"
@@ -317,18 +319,18 @@
                         Field    [val-field {:table_id      (u/the-id table)
                                              :nfc_path      [:jsons "values" "qty"]
                                              :database_type "integer"}]]
-        (qp.store/with-store
-          (qp.store/fetch-and-store-database! (u/the-id database))
-          (qp.store/fetch-and-store-tables! [(u/the-id table)])
-          (qp.store/fetch-and-store-fields! [(u/the-id val-field)])
-          (let [field-clause [:field (u/the-id val-field) {:binning
-                                                           {:strategy :num-bins,
-                                                            :num-bins 100,
-                                                            :min-value 0.75,
-                                                            :max-value 54.0,
-                                                            :bin-width 0.75}}]]
-            (is (= ["((floor((((complicated_identifiers.jsons#>> ?::text[])::integer  - 0.75) / 0.75)) * 0.75) + 0.75)" "{values,qty}"]
-                   (hsql/format (sql.qp/->honeysql :postgres field-clause)))))))))))
+          (qp.store/with-store
+            (qp.store/fetch-and-store-database! (u/the-id database))
+            (qp.store/fetch-and-store-tables! [(u/the-id table)])
+            (qp.store/fetch-and-store-fields! [(u/the-id val-field)])
+            (let [field-clause [:field (u/the-id val-field) {:binning
+                                                             {:strategy :num-bins,
+                                                              :num-bins 100,
+                                                              :min-value 0.75,
+                                                              :max-value 54.0,
+                                                              :bin-width 0.75}}]]
+              (is (= ["((floor((((complicated_identifiers.jsons#>> ?::text[])::integer  - 0.75) / 0.75)) * 0.75) + 0.75)" "{values,qty}"]
+                     (hsql/format (sql.qp/->honeysql :postgres field-clause)))))))))))
 
 (deftest json-alias-test
   (mt/test-driver :postgres
@@ -559,7 +561,7 @@
                        :parameters
                        [{:type   "text"
                          :target ["dimension" ["template-tag" "user"]]
-                         :value  "4f01dcfd-13f7-430c-8e6f-e505c0851027"}])))))
+                         :value  "4f01dcfd-13f7-430c-8e6f-e505c0851027"}]))))))
       (testing "Check that we can filter by multiple UUIDs for SQL Field filters"
         (is (= [[1 #uuid "4f01dcfd-13f7-430c-8e6f-e505c0851027"]
                 [3 #uuid "da1d6ecc-e775-4008-b366-c38e7a2e8433"]]
@@ -577,7 +579,7 @@
                        [{:type   "text"
                          :target ["dimension" ["template-tag" "user"]]
                          :value  ["4f01dcfd-13f7-430c-8e6f-e505c0851027"
-                                  "da1d6ecc-e775-4008-b366-c38e7a2e8433"]}]))))))))))
+                                  "da1d6ecc-e775-4008-b366-c38e7a2e8433"]}])))))))))
 
 
 (mt/defdataset ip-addresses
@@ -962,7 +964,7 @@
 (deftest can-set-ssl-key-via-gui
   (testing "ssl key can be set via the gui (#20319)"
     (with-redefs [secret/value->file!
-                  (fn [{:keys [connection-property-name value] :as _secret} _driver?]
+                  (fn [{:keys [connection-property-name value] :as _secret} & [_driver? _ext?]]
                     (str "file:" connection-property-name "=" value))]
       (is (= "file:ssl-key=/clientkey.pkcs12"
              (:sslkey
@@ -972,7 +974,7 @@
                 :ssl-client-cert-path "/client.pem"
                 :ssl-key-options "local"
                 :ssl-key-password-value "sslclientkeypw!"
-                :ssl-key-path "/clientkey.pkcs12", ;; <-- this is what is set via ui.
+                :ssl-key-path "/clientkey.pkcs12" ;; <-- this is what is set via ui.
                 :ssl-mode "verify-ca"
                 :ssl-root-cert-options "local"
                 :ssl-root-cert-path "/root.pem"
@@ -985,3 +987,24 @@
                 :user "bcm"
                 :password "abcdef123"
                 :port 5432})))))))
+
+(deftest pkcs-12-extension-test
+  (testing "Uploaded PKCS-12 SSL keys are stored in a file with the .p12 extension (#20319)"
+    (is (true?
+         (-> (#'postgres/ssl-params
+              {:ssl true
+               :ssl-key-options "uploaded"
+               :ssl-key-value "data:application/x-pkcs12;base64,SGVsbG8="
+               :ssl-mode "require"
+               :ssl-use-client-auth true
+               :tunnel-enabled false
+               :advanced-options false
+               :dbname "metabase"
+               :engine :postgres
+               :host "localhost"
+               :user "bcm"
+               :password "abcdef123"
+               :port 5432})
+             :sslkey
+             .getAbsolutePath
+             (str/ends-with? ".p12"))))))
-- 
GitLab