From 8f4d867bc486221490d92f650f14e9d14e50c666 Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Tue, 5 Oct 2021 13:10:52 -0700 Subject: [PATCH] Fix Dashboard Subscription email validation (second pass) (#18237) * Fix Dashboard Subscription email validation (second pass) * Always validate Pulse channels in test endpoint --- .../advanced_config/api/pulse_test.clj | 96 ++++++++++--------- src/metabase/api/pulse.clj | 5 +- src/metabase/models/pulse_channel.clj | 32 ++++++- test/metabase/api/pulse_test.clj | 43 +++++---- test/metabase/models/pulse_channel_test.clj | 24 +++++ 5 files changed, 131 insertions(+), 69 deletions(-) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj index e921d47231a..47b0c02c02c 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj @@ -8,48 +8,54 @@ (deftest test-pulse-endpoint-should-respect-email-domain-allow-list-test (testing "POST /api/pulse/test" (mt/with-temp Card [card {:dataset_query (mt/mbql-query venues)}] - (letfn [(send! [expected-status-code] - (let [pulse-name (mt/random-name)] - (mt/with-fake-inbox - {:response (mt/user-http-request - :rasta :post expected-status-code "pulse/test" - {:name pulse-name - :cards [{:id (u/the-id card) - :include_csv false - :include_xls false - :dashboard_card_id nil}] - :channels [{:enabled true - :channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :schedule_day nil - :details {:emails ["test@metabase.com"]}}] - :skip_if_empty false}) - :recipients (set (keys (mt/regex-email-bodies (re-pattern pulse-name))))})))] - (testing "allowed email -- should pass" - (mt/with-temporary-setting-values [subscription-allowed-domains "metabase.com"] - (premium-features-test/with-premium-features #{:advanced-config} - (let [{:keys [response recipients]} (send! 200)] - (is (= {:ok true} - response)) - (is (contains? recipients "test@metabase.com")))) - (testing "No :advanced-config token" - (premium-features-test/with-premium-features #{} - (let [{:keys [response recipients]} (send! 200)] - (is (= {:ok true} - response)) - (is (contains? recipients "test@metabase.com"))))))) - (testing "disallowed email" - (mt/with-temporary-setting-values [subscription-allowed-domains "example.com"] - (testing "should fail when :advanced-config is enabled" - (premium-features-test/with-premium-features #{:advanced-config} - (let [{:keys [response recipients]} (send! 403)] - (is (= "You cannot create new subscriptions for the domain \"metabase.com\". Allowed domains are: example.com" - (:message response))) - (is (not (contains? recipients "test@metabase.com")))))) - (testing "No :advanced-config token -- should still pass" - (premium-features-test/with-premium-features #{} - (let [{:keys [response recipients]} (send! 200)] - (is (= {:ok true} - response)) - (is (contains? recipients "test@metabase.com"))))))))))) + ;; make sure we validate raw emails whether they're part of `:details` or part of `:recipients` -- we + ;; technically allow either right now + (doseq [channel [{:details {:emails ["test@metabase.com"]}} + {:recipients [{:email "test@metabase.com"}] + :details {}}]] + (testing (format "\nChannel = %s\n" (u/pprint-to-str channel)) + (letfn [(send! [expected-status-code] + (let [pulse-name (mt/random-name)] + (mt/with-fake-inbox + {:response (mt/user-http-request + :rasta :post expected-status-code "pulse/test" + {:name pulse-name + :cards [{:id (u/the-id card) + :include_csv false + :include_xls false + :dashboard_card_id nil}] + :channels [(merge {:enabled true + :channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :schedule_day nil} + channel)] + :skip_if_empty false}) + :recipients (set (keys (mt/regex-email-bodies (re-pattern pulse-name))))})))] + (testing "allowed email -- should pass" + (mt/with-temporary-setting-values [subscription-allowed-domains "metabase.com"] + (premium-features-test/with-premium-features #{:advanced-config} + (let [{:keys [response recipients]} (send! 200)] + (is (= {:ok true} + response)) + (is (contains? recipients "test@metabase.com")))) + (testing "No :advanced-config token" + (premium-features-test/with-premium-features #{} + (let [{:keys [response recipients]} (send! 200)] + (is (= {:ok true} + response)) + (is (contains? recipients "test@metabase.com"))))))) + (testing "disallowed email" + (mt/with-temporary-setting-values [subscription-allowed-domains "example.com"] + (testing "should fail when :advanced-config is enabled" + (premium-features-test/with-premium-features #{:advanced-config} + (let [{:keys [response recipients]} (send! 403)] + (is (= "You cannot create new subscriptions for the domain \"metabase.com\". Allowed domains are: example.com" + (:message response))) + (is (not (contains? recipients "test@metabase.com")))))) + (testing "No :advanced-config token -- should still pass" + (premium-features-test/with-premium-features #{} + (let [{:keys [response recipients]} (send! 200)] + (is (= {:ok true} + response)) + (is (contains? recipients "test@metabase.com"))))))))))))) diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index 0a7a26cc3cc..f0ac21013c3 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -206,9 +206,8 @@ dashboard_id (s/maybe su/IntGreaterThanZero)} (check-card-read-permissions cards) ;; make sure any email addresses that are specified are allowed before sending the test Pulse. - (doseq [{{:keys [emails]} :details, :as channel} channels] - (when (seq emails) - (pulse-channel/validate-email-domains channel))) + (doseq [channel channels] + (pulse-channel/validate-email-domains channel)) (p/send-pulse! (assoc body :creator_id api/*current-user-id*)) {:ok true}) diff --git a/src/metabase/models/pulse_channel.clj b/src/metabase/models/pulse_channel.clj index 55030a382da..a33ebfd8e1b 100644 --- a/src/metabase/models/pulse_channel.clj +++ b/src/metabase/models/pulse_channel.clj @@ -7,6 +7,7 @@ [metabase.models.user :as user :refer [User]] [metabase.plugins.classloader :as classloader] [metabase.util :as u] + [metabase.util.i18n :refer [tru]] [schema.core :as s] [toucan.db :as db] [toucan.models :as models])) @@ -145,9 +146,36 @@ the [[metabase-enterprise.advanced-config.models.pulse-channel/subscription-allowed-domains]] Setting, if set. This will no-op if `subscription-allowed-domains` is unset or if we do not have a premium token with the `:advanced-config` feature." - [{{:keys [emails]} :details, :as pulse-channel}] + [{{:keys [emails]} :details, :keys [recipients], :as pulse-channel}] + ;; Raw email addresses can be in either `[:details :emails]` or in `:recipients`, depending on who is invoking this + ;; function. Make sure we handle both situations. + ;; + ;; {:details {:emails [\"email@example.com\" ...]}} + ;; + ;; The Dashboard Subscription FE currently sends raw email address recipients in this format: + ;; + ;; {:recipients [{:email \"email@example.com\"} ...]} + ;; (u/prog1 pulse-channel - (validate-email-domains* emails))) + (let [raw-email-recipients (remove :id recipients) + user-recipients (filter :id recipients) + emails (concat emails (map :email raw-email-recipients))] + (validate-email-domains* emails) + ;; validate User `:id` & `:email` match up for User recipients. This is mostly to make sure people don't try to + ;; be sneaky and pass in a valid User ID but different email so they can send test Pulses out to arbitrary email + ;; addresses + (when-let [user-ids (not-empty (into #{} (comp (filter some?) (map :id)) user-recipients))] + (let [user-id->email (db/select-id->field :email User, :id [:in user-ids])] + (doseq [{:keys [id email]} user-recipients + :let [correct-email (get user-id->email id)]] + (when-not correct-email + (throw (ex-info (tru "User {0} does not exist." id) + {:status-code 404}))) + ;; only validate the email address if it was explicitly specified, which is not explicitly required. + (when (and email + (not= email correct-email)) + (throw (ex-info (tru "Wrong email address for User {0}." id) + {:status-code 403}))))))))) (u/strict-extend (class PulseChannel) models/IModel diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 2f42cc6846a..8c29130c785 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -848,25 +848,30 @@ (mt/with-temp Card [card {:dataset_query (mt/mbql-query venues)}] (with-redefs [pulse-channel/validate-email-domains (fn [& _] (throw (ex-info "Nope!" {:status-code 403})))] - (let [pulse-name (mt/random-name)] - (mt/with-fake-inbox - (is (= "Nope!" - (mt/user-http-request - :rasta :post 403 "pulse/test" - {:name pulse-name - :cards [{:id (u/the-id card) - :include_csv false - :include_xls false - :dashboard_card_id nil}] - :channels [{:enabled true - :channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :schedule_day nil - :details {:emails ["test@metabase.com"]}}] - :skip_if_empty false}))) - (is (not (contains? (set (keys (mt/regex-email-bodies (re-pattern pulse-name)))) - "test@metabase.com"))))))))) + ;; make sure we validate raw emails whether they're part of `:details` or part of `:recipients` -- we + ;; technically allow either right now + (doseq [channel [{:details {:emails ["test@metabase.com"]}} + {:recipients [{:email "test@metabase.com"}] + :details {}}] + :let [pulse-name (mt/random-name) + request-body {:name pulse-name + :cards [{:id (u/the-id card) + :include_csv false + :include_xls false + :dashboard_card_id nil}] + :channels [(assoc channel + :enabled true + :channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :schedule_day nil)] + :skip_if_empty false}]] + (testing (format "\nchannel =\n%s" (u/pprint-to-str channel)) + (mt/with-fake-inbox + (is (= "Nope!" + (mt/user-http-request :rasta :post 403 "pulse/test" request-body))) + (is (not (contains? (set (keys (mt/regex-email-bodies (re-pattern pulse-name)))) + "test@metabase.com")))))))))) ;; This test follows a flow that the user/UI would follow by first creating a pulse, then making a small change to ;; that pulse and testing it. The primary purpose of this test is to ensure tha the pulse/test endpoint accepts data diff --git a/test/metabase/models/pulse_channel_test.clj b/test/metabase/models/pulse_channel_test.clj index 893bc8081ed..afdf38a7356 100644 --- a/test/metabase/models/pulse_channel_test.clj +++ b/test/metabase/models/pulse_channel_test.clj @@ -6,6 +6,7 @@ [metabase.models.pulse-channel-recipient :as pcr :refer [PulseChannelRecipient]] [metabase.models.user :refer [User]] [metabase.test :as mt] + [metabase.util :as u] [toucan.db :as db] [toucan.hydrate :refer [hydrate]])) @@ -415,3 +416,26 @@ :last_name "Toucan" :common_name "Rasta Toucan"}])) (:recipients (hydrate channel :recipients))))))) + +(deftest validate-email-domains-check-user-ids-match-emails + (testing `pc/validate-email-domains + (testing "should check that User `:id` and `:email`s match for User `:recipients`" + (let [input {:recipients [{:email "rasta@metabase.com" + :id (mt/user->id :rasta)}]}] + (is (= input + (pc/validate-email-domains input)))) + (testing "Throw Exception if User does not exist" + ;; should validate even if `:email` isn't specified + (doseq [input [{:id Integer/MAX_VALUE} + {:email "rasta@example.com" + :id Integer/MAX_VALUE}]] + (testing (format "\ninput = %s" (u/pprint-to-str input)) + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"User [\d,]+ does not exist" + (pc/validate-email-domains {:recipients [input]})))))) + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Wrong email address for User [\d,]+" + (pc/validate-email-domains {:recipients [{:email "rasta@example.com" + :id (mt/user->id :rasta)}]})))))) -- GitLab