From 304552fd229d64f4845df9406b4d5a76edcf0158 Mon Sep 17 00:00:00 2001
From: Cal Herries <39073188+calherries@users.noreply.github.com>
Date: Thu, 5 Jan 2023 11:22:55 +0100
Subject: [PATCH] Fix: Setting a very high Session Timeout causes instance to
 stop working (#27347)

* Limit session timeout amount to less than 1 million

* Limit the max timeout to 100 years

* Validate on BE too

* Typo

* Add comment to FE

* Fix BE validation

* Fix BE validation

* Add BE test

* Change FE validation to mirror BE validation

* Add unit test for SessionTimeoutSetting

* Refactor FE unit tests

* Move comment

* Add check for positive amount to BE validation

* Add more BE tests

* Refactor validate function

* Remove cleanup()

* Use ToBeInTheDocument instead

* Use getByText

* Remove conditional expect

* Remove unused import

* Refactor for clarity

* Formatting

* Validate session-timeout in getter too

* Add docstring to check-session-timeout and make private

* Change getter to print warning string instead of throw exception

* Format import

* i18n and use log/warn in getter, and throw 400 in setter

* Reorder require

* Use cam's suggestion

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
---
 .../SessionTimeoutSetting.tsx                 | 25 ++++++---
 .../SessionTimeoutSetting.unit.spec.tsx       | 51 +++++++++++++++++
 src/metabase/server/middleware/session.clj    | 41 ++++++++++++--
 .../server/middleware/session_test.clj        | 56 ++++++++++++++++++-
 4 files changed, 157 insertions(+), 16 deletions(-)
 create mode 100644 enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.unit.spec.tsx

diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.tsx
index 7cd23083c0e..883acdee4ca 100644
--- a/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.tsx
@@ -30,13 +30,21 @@ interface SessionTimeoutSettingProps {
   onChange: (value: TimeoutValue | null) => void;
 }
 
-const validate = (
-  setting: SessionTimeoutSettingProps["setting"],
-  value: TimeoutValue,
-) =>
-  setting.value == null || value.amount > 0
-    ? null
-    : t`Timeout must be greater than 0`;
+// This should mirror the BE validation of the session-timeout setting.
+const validate = (value: TimeoutValue) => {
+  if (value.amount <= 0) {
+    return t`Timeout must be greater than 0`;
+  }
+  // We need to limit the duration from being too large because
+  // the year of the expires date must be 4 digits (#25253)
+  const unitsPerDay = { hours: 24, minutes: 24 * 60 }[value.unit] as number;
+  const days = value.amount / unitsPerDay;
+  const daysIn100Years = 365.25 * 100;
+  if (days >= daysIn100Years) {
+    return t`Timeout must be less than 100 years`;
+  }
+  return null;
+};
 
 const SessionTimeoutSetting = ({
   setting,
@@ -48,7 +56,7 @@ const SessionTimeoutSetting = ({
     setValue(prev => ({ ...prev, ...newValue }));
   };
 
-  const error = validate(setting, value);
+  const error = validate(value);
 
   const handleCommitSettings = (value: TimeoutValue | null) => {
     !error && onChange(value);
@@ -80,6 +88,7 @@ const SessionTimeoutSetting = ({
           <SessionTimeoutInputContainer>
             <SessionTimeoutInput
               type="number"
+              data-testid="session-timeout-input"
               placeholder=""
               value={value?.amount.toString()}
               onChange={e =>
diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.unit.spec.tsx
new file mode 100644
index 00000000000..712c0e0fe90
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SessionTimeoutSetting/SessionTimeoutSetting.unit.spec.tsx
@@ -0,0 +1,51 @@
+import React from "react";
+import { render, cleanup, screen } from "@testing-library/react";
+import SessionTimeoutSetting from "metabase-enterprise/auth/components/SessionTimeoutSetting";
+
+describe("SessionTimeoutSetting", () => {
+  beforeAll(() => {
+    window.HTMLElement.prototype.scrollIntoView = jest.fn();
+  });
+
+  const SUCCEED_TEST_CASES = [
+    { value: { amount: 1, unit: "minutes" } },
+    { value: { amount: 1, unit: "hours" } },
+    { value: { amount: 60 * 24 * 365.25 * 100 - 1, unit: "minutes" } },
+    { value: { amount: 24 * 365.25 * 100 - 1, unit: "hours" } },
+  ];
+
+  const FAIL_TEST_CASES = [
+    {
+      value: { amount: 0, unit: "minutes" },
+      error: "Timeout must be greater than 0",
+    },
+    {
+      value: { amount: 0, unit: "hours" },
+      error: "Timeout must be greater than 0",
+    },
+    {
+      value: { amount: 60 * 24 * 365.25 * 100, unit: "minutes" },
+      error: "Timeout must be less than 100 years",
+    },
+    {
+      value: { amount: 24 * 365.25 * 100, unit: "hours" },
+      error: "Timeout must be less than 100 years",
+    },
+  ];
+
+  SUCCEED_TEST_CASES.map(({ value }) => {
+    it(`validates ${value.amount} ${value.unit} correctly`, () => {
+      const setting = { value: value, key: "...", default: "..." };
+      render(<SessionTimeoutSetting setting={setting} onChange={jest.fn()} />);
+      expect(screen.queryByText(/Timeout must be/)).not.toBeInTheDocument();
+    });
+  });
+
+  FAIL_TEST_CASES.map(({ value, error }) => {
+    it(`validates ${value.amount} ${value.unit} correctly`, () => {
+      const setting = { value: value, key: "...", default: "..." };
+      render(<SessionTimeoutSetting setting={setting} onChange={jest.fn()} />);
+      expect(screen.getByText(error)).toBeInTheDocument();
+    });
+  });
+});
diff --git a/src/metabase/server/middleware/session.clj b/src/metabase/server/middleware/session.clj
index 333679e0b57..8784a6c9f69 100644
--- a/src/metabase/server/middleware/session.clj
+++ b/src/metabase/server/middleware/session.clj
@@ -28,7 +28,7 @@
    [metabase.public-settings.premium-features :as premium-features]
    [metabase.server.request.util :as request.u]
    [metabase.util :as u]
-   [metabase.util.i18n :as i18n :refer [deferred-trs deferred-tru tru]]
+   [metabase.util.i18n :as i18n :refer [deferred-trs deferred-tru trs tru]]
    [ring.util.response :as response]
    [schema.core :as s]
    [toucan.db :as db])
@@ -359,21 +359,52 @@
 ;;; |                                              reset-cookie-timeout                                             |
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
+(defn- check-session-timeout
+  "Returns nil if the [[session-timeout]] value is valid. Otherwise returns an error key."
+  [timeout]
+  (when (some? timeout)
+    (let [{:keys [unit amount]} timeout
+          units-in-24-hours (case unit
+                              "seconds" (* 60 60 24)
+                              "minutes" (* 60 24)
+                              "hours"   24)
+          units-in-100-years (* units-in-24-hours 365.25 100)]
+      (cond
+        (not (pos? amount))
+        :amount-must-be-positive
+        (>= amount units-in-100-years)
+        :amount-must-be-less-than-100-years))))
+
 (defsetting session-timeout
   ;; Should be in the form {:amount 60 :unit "minutes"} where the unit is one of "seconds", "minutes" or "hours".
   ;; The amount is nillable.
   (deferred-tru "Time before inactive users are logged out. By default, sessions last indefinitely.")
-  :type       :json
-  :default    nil)
+  :type    :json
+  :default nil
+  :getter  (fn []
+             (let [value (setting/get-value-of-type :json :session-timeout)]
+               (if-let [error-key (check-session-timeout value)]
+                 (do (log/warn (case error-key
+                                 :amount-must-be-positive            (trs "Session timeout amount must be positive.")
+                                 :amount-must-be-less-than-100-years (trs "Session timeout must be less than 100 years.")))
+                     nil)
+                 value)))
+  :setter  (fn [new-value]
+             (when-let [error-key (check-session-timeout new-value)]
+               (throw (ex-info (case error-key
+                                 :amount-must-be-positive            (tru "Session timeout amount must be positive.")
+                                 :amount-must-be-less-than-100-years (tru "Session timeout must be less than 100 years."))
+                               {:status-code 400})))
+             (setting/set-value-of-type! :json :session-timeout new-value)))
 
 (defn session-timeout->seconds
-  "Convert a session timeout setting to seconds."
+  "Convert the session-timeout setting value to seconds."
   [{:keys [unit amount]}]
   (when amount
     (-> (case unit
           "seconds" amount
           "minutes" (* amount 60)
-          "hours"  (* amount 3600))
+          "hours"   (* amount 3600))
         (max 60)))) ; Ensure a minimum of 60 seconds so a user can't lock themselves out
 
 (defn session-timeout-seconds
diff --git a/test/metabase/server/middleware/session_test.clj b/test/metabase/server/middleware/session_test.clj
index f3fd325bcb8..c6be49b1f4c 100644
--- a/test/metabase/server/middleware/session_test.clj
+++ b/test/metabase/server/middleware/session_test.clj
@@ -1,5 +1,6 @@
 (ns metabase.server.middleware.session-test
   (:require
+   [cheshire.core :as json]
    [clojure.string :as str]
    [clojure.test :refer :all]
    [environ.core :as env]
@@ -12,8 +13,7 @@
    [metabase.models :refer [PermissionsGroupMembership Session User]]
    [metabase.public-settings :as public-settings]
    [metabase.public-settings.premium-features :as premium-features]
-   [metabase.public-settings.premium-features-test
-    :as premium-features-test]
+   [metabase.public-settings.premium-features-test :as premium-features-test]
    [metabase.server.middleware.session :as mw.session]
    [metabase.test :as mt]
    [metabase.util.i18n :as i18n]
@@ -397,7 +397,57 @@
 
 ;;; ----------------------------------------------------- Session timeout -----------------------------------------------------
 
-(deftest session-timeout-tests
+(deftest session-timeout-validation-test
+  (testing "Setting the session timeout should fail if the timeout isn't positive"
+    (is (thrown-with-msg?
+         java.lang.Exception
+         #"Session timeout amount must be positive"
+         (mw.session/session-timeout! {:unit "hours", :amount 0})))
+    (is (thrown-with-msg?
+         java.lang.Exception
+         #"Session timeout amount must be positive"
+         (mw.session/session-timeout! {:unit "minutes", :amount -1}))))
+  (testing "Setting the session timeout should fail if the timeout is too large"
+    (is (thrown-with-msg?
+         java.lang.Exception
+         #"Session timeout must be less than 100 years"
+         (mw.session/session-timeout! {:unit "hours", :amount (* 100 365.25 24)})))
+    (is (thrown-with-msg?
+         java.lang.Exception
+         #"Session timeout must be less than 100 years"
+         (mw.session/session-timeout! {:unit "minutes", :amount (* 100 365.25 24 60)}))))
+  (testing "Setting the session timeout shouldn't fail if the timeout is between 0 and 100 years exclusive"
+    (is (some? (mw.session/session-timeout! {:unit "minutes", :amount 1})))
+    (is (some? (mw.session/session-timeout! {:unit "hours", :amount 1})))
+    (is (some? (mw.session/session-timeout! {:unit "minutes", :amount (dec (* 100 365.25 24 60))})))
+    (is (some? (mw.session/session-timeout! {:unit "hours", :amount (dec (* 100 365.25 24))}))))
+  (testing "Setting an invalid timeout via PUT /api/setting/:key endpoint should return a 400 status code"
+    (is (= "Session timeout amount must be positive."
+           (mt/user-http-request :crowberto :put 400 "setting/session-timeout" {:value {:unit "hours", :amount -1}})))))
+
+(deftest session-timeout-env-var-validation-test
+  (let [set-and-get (fn [timeout]
+                      (mt/with-temp-env-var-value [mb-session-timeout (json/generate-string timeout)]
+                        (mw.session/session-timeout)))]
+    (testing "Setting the session timeout with env var should work with valid timeouts"
+      (doseq [timeout [{:unit "hours", :amount 1}
+                       {:unit "hours", :amount (dec (* 100 365.25 24))}]]
+        (is (= timeout
+               (set-and-get timeout)))))
+    (testing "Setting the session timeout via the env var should fail if the timeout isn't positive"
+      (doseq [amount [0 -1]
+              :let [timeout {:unit "hours", :amount amount}]]
+        (is (nil? (set-and-get timeout)))
+        (is (= [[:warn nil "Session timeout amount must be positive."]]
+               (mt/with-log-messages-for-level :warn (set-and-get timeout))))))
+    (testing "Setting the session timeout via env var should fail if the timeout is too large"
+      (doseq [timeout [{:unit "hours", :amount (* 100 365.25 24)}
+                       {:unit "minutes", :amount (* 100 365.25 24 60)}]]
+        (is (nil? (set-and-get timeout)))
+        (is (= [[:warn nil "Session timeout must be less than 100 years."]]
+               (mt/with-log-messages-for-level :warn (set-and-get timeout))))))))
+
+(deftest session-timeout-test
   (let [request-time (t/zoned-date-time "2022-01-01T00:00:00.000Z")
         session-id   "8df268ab-00c0-4b40-9413-d66b966b696a"
         response     {:body    "some body",
-- 
GitLab