Repository: streamlit/streamlit

Issue:
Enable server-side CSRF guards and add support in file uploader client
### Problem

Cross-Site Request Forgery ([CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery)) is a category of security exploits where a vulnerable third-party webpages can be exploited in making unauthorized requests to your web application.  CSRF guards are important for Streamlit applications that are running in the Cloud with a publicly accessible URL.

The [recommended best practices](https://en.wikipedia.org/wiki/Cross-site_request_forgery#HTTP_verbs_and_CSRF) suggest that all `GET` endpoints should be idempotent, and all non-idempotent operations should be exposed via HTTP verbs such as `POST` or `PUT`.  Currently, Streamlit implements the file uploader functionality as a POST endpoint, and hence should be protected from CSRF attacks.

### Solution

**MVP:**
Implement the [Cookie-to-header token](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Cookie-to-header_token) pattern.

**Possible additions:** 
Investigate feasibility of the [Synchronizer Token](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Synchronizer_token_pattern) pattern.

**Preferred solution:**
None

### Additional context

* Tornado has built-in support for the cookie-to-header pattern: https://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection

* The server would need to be configured with a secret for signing the CSRF cookies.  In Streamlit, users could optionally provision the secret via config and/or environment variables.  If the secret was not provisioned, we could either:
  * auto-generate a cryptographically secure secret random string, or 
  * assume the app is running in a local dev environment and simply turn off the CSRF guard.

* When running the app with multiple replicas, all the replicas should have the signing secret in order to avoid any unpredictable errors for the app viewers.


Discussion:

Resolving patch:
diff --git a/lib/streamlit/config.py b/lib/streamlit/config.py
--- a/lib/streamlit/config.py
+++ b/lib/streamlit/config.py
@@ -17,6 +17,7 @@
 import os
 import toml
 import collections
+import secrets
 import urllib
 from typing import Dict
 
@@ -394,6 +395,19 @@ def _global_unit_test():
 )
 
 
+@_create_option("server.cookieSecret")
+@util.memoize
+def _server_cookie_secret():
+    """Symmetric key used to produce signed cookies. If deploying on multiple
+    replicas, this should be set to ensure all replicas share the same secret.
+
+    Default: Randomly generated secret key.
+    """
+    cookie_secret = os.getenv("STREAMLIT_COOKIE_SECRET")
+    cookie_secret = cookie_secret if cookie_secret else secrets.token_hex()
+    return cookie_secret
+
+
 @_create_option("server.headless", type_=bool)
 @util.memoize
 def _server_headless():
diff --git a/lib/streamlit/server/Server.py b/lib/streamlit/server/Server.py
--- a/lib/streamlit/server/Server.py
+++ b/lib/streamlit/server/Server.py
@@ -64,6 +64,7 @@
     "websocket_ping_interval": 20,  # Ping every 20s to keep WS alive.
     "websocket_ping_timeout": 30,  # Pings should be responded to within 30s.
     "websocket_max_message_size": MESSAGE_SIZE_LIMIT,  # Up the WS size limit.
+    "xsrf_cookies": True,
 }
 
 
@@ -329,7 +330,11 @@ def _create_app(self):
                 ]
             )
 
-        return tornado.web.Application(routes, **TORNADO_SETTINGS)
+        return tornado.web.Application(
+            routes,
+            cookie_secret=config.get_option("server.cookieSecret"),
+            **TORNADO_SETTINGS
+        )
 
     def _set_state(self, new_state):
         LOGGER.debug("Server state: %s -> %s" % (self._state, new_state))
@@ -571,6 +576,12 @@ class _BrowserWebSocketHandler(tornado.websocket.WebSocketHandler):
     def initialize(self, server):
         self._server = server
         self._session = None
+        # The XSRF cookie is normally set when xsrf_form_html is used, but in a pure-Javascript application
+        # that does not use any regular forms we just  need to read the self.xsrf_token manually to set the
+        # cookie as a side effect).
+        # See https://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection
+        # for more details.
+        self.xsrf_token
 
     def check_origin(self, origin):
         """Set up CORS."""
diff --git a/lib/streamlit/server/UploadFileRequestHandler.py b/lib/streamlit/server/UploadFileRequestHandler.py
--- a/lib/streamlit/server/UploadFileRequestHandler.py
+++ b/lib/streamlit/server/UploadFileRequestHandler.py
@@ -19,7 +19,9 @@
 import tornado.httputil
 
 from streamlit.UploadedFileManager import UploadedFile
+from streamlit import config
 from streamlit.logger import get_logger
+from streamlit.Report import Report
 from streamlit.server import routes
 
 LOGGER = get_logger(__name__)
@@ -42,8 +44,11 @@ def initialize(self, file_mgr):
         self._file_mgr = file_mgr
 
     def set_default_headers(self):
-        if routes.allow_cross_origin_requests():
-            self.set_header("Access-Control-Allow-Origin", "*")
+        self.set_header("Access-Control-Allow-Headers", "X-Xsrftoken")
+        self.set_header("Access-Control-Allow-Origin", Report.get_url(config.get_option("browser.serverAddress")))
+        self.set_header("Vary", "Origin")
+        self.set_header("Access-Control-Allow-Credentials", "true")
+
 
     def options(self):
         """/OPTIONS handler for preflight CORS checks.


Test patch:
diff --git a/frontend/src/lib/FileUploadClient.test.ts b/frontend/src/lib/FileUploadClient.test.ts
--- a/frontend/src/lib/FileUploadClient.test.ts
+++ b/frontend/src/lib/FileUploadClient.test.ts
@@ -62,6 +62,12 @@ describe("FileUploadClient", () => {
           } else if (data.get("sessionId") == null) {
             return [400]
           }
+
+          if (!("X-Xsrftoken" in config.headers)) {
+            return [403]
+          } else if (!("withCredentials" in config)) {
+            return [403]
+          }
         }
 
         return [status]
diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts
--- a/frontend/src/lib/utils.test.ts
+++ b/frontend/src/lib/utils.test.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { flattenElements } from "./utils"
+import { getCookie, flattenElements } from "./utils"
 import { BlockElement } from "lib/DeltaParser"
 import { List, Set as ImmutableSet, Map as ImmutableMap } from "immutable"
 
@@ -52,3 +52,52 @@ describe("flattenElements", () => {
     )
   })
 })
+
+describe("getCookie", () => {
+  afterEach(() => {
+    document.cookie.split(";").forEach(cookie => {
+      const eqPos = cookie.indexOf("=")
+      const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie
+      document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"
+    })
+  })
+
+  it("get existing cookie", () => {
+    document.cookie = "flavor=chocolatechip"
+    const cookie = getCookie("flavor")
+    expect(cookie).toEqual("chocolatechip")
+  })
+
+  it("get missing cookie", () => {
+    document.cookie = "sweetness=medium;"
+    document.cookie = "flavor=chocolatechip;"
+    document.cookie = "type=darkchocolate;"
+    const cookie = getCookie("recipe")
+    expect(cookie).toEqual(undefined)
+  })
+
+  it("find cookie in the front", () => {
+    document.cookie = "flavor=chocolatechip;"
+    document.cookie = "sweetness=medium;"
+    document.cookie = "type=darkchocolate;"
+    console.log(document.cookie)
+    const cookie = getCookie("flavor")
+    expect(cookie).toEqual("chocolatechip")
+  })
+
+  it("find cookie in the middle", () => {
+    document.cookie = "sweetness=medium;"
+    document.cookie = "flavor=chocolatechip;"
+    document.cookie = "type=darkchocolate;"
+    const cookie = getCookie("flavor")
+    expect(cookie).toEqual("chocolatechip")
+  })
+
+  it("find cookie in the end", () => {
+    document.cookie = "sweetness=medium;"
+    document.cookie = "type=darkchocolate;"
+    document.cookie = "flavor=chocolatechip;"
+    const cookie = getCookie("flavor")
+    expect(cookie).toEqual("chocolatechip")
+  })
+})
diff --git a/lib/tests/streamlit/config_test.py b/lib/tests/streamlit/config_test.py
--- a/lib/tests/streamlit/config_test.py
+++ b/lib/tests/streamlit/config_test.py
@@ -28,6 +28,8 @@
 from streamlit import env_util
 from streamlit.ConfigOption import ConfigOption
 
+os.environ["STREAMLIT_COOKIE_SECRET"] = "chocolatechip"
+
 SECTION_DESCRIPTIONS = copy.deepcopy(config._section_descriptions)
 CONFIG_OPTIONS = copy.deepcopy(config._config_options)
 
@@ -299,6 +301,7 @@ def test_config_option_keys(self):
                 "s3.url",
                 "server.enableCORS",
                 "server.baseUrlPath",
+                "server.cookieSecret",
                 "server.folderWatchBlacklist",
                 "server.fileWatcherType",
                 "server.headless",
@@ -473,6 +476,11 @@ def test_browser_server_port(self):
         config.set_option("server.port", 1234)
         self.assertEqual(1234, config.get_option("browser.serverPort"))
 
+    def test_server_cookie_secret(self):
+        self.assertEqual("chocolatechip", config.get_option("server.cookieSecret"))
+
+        del os.environ["STREAMLIT_COOKIE_SECRET"]
+
     def test_server_headless_via_liveSave(self):
         config.set_option("server.liveSave", True)
         self.assertEqual(True, config.get_option("server.headless"))