CVE-2021-43780: Server-Side Request Forgery (SSRF) (CWE-918)
value: Redash is a package for data visualization and sharing. In versions 10.0 and priorm the implementation of URL-loading data sources like JSON, CSV, or Excel is vulnerable to advanced methods of Server Side Request Forgery (SSRF). These vulnerabilities are only exploitable on installations where a URL-loading data source is enabled. As of time of publication, the `master` and `release/10.x.x` branches address this by applying the Advocate library for making http requests instead of the requests library directly. Users should upgrade to version 10.0.1 to receive this patch. There are a few workarounds for mitigating the vulnerability without upgrading. One can disable the vulnerable data sources entirely, by adding the following env variable to one's configuration, making them unavailable inside the webapp. One can switch any data source of certain types (viewable in the GitHub Security Advisory) to be `View Only` for all groups on the Settings > Groups > Data Sources screen. For users unable to update an admin may modify Redash's configuration through environment variables to mitigate this issue. Depending on the version of Redash, an admin may also need to run a CLI command to re-encrypt some fields in the database. The `master` and `release/10.x.x` branches as of time of publication have removed the default value for `REDASH_COOKIE_SECRET`. All future releases will also require this to be set explicitly. For existing installations, one will need to ensure that explicit values are set for the `REDASH_COOKIE_SECRET` and `REDASH_SECRET_KEY `variables.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 8.8
Published: 2021-11-24T16:15Z
Repository: https://github.com/getredash/redash
Language: Python
Files changed: redash/query_runner/__init__.py, redash/query_runner/csv.py, redash/query_runner/excel.py, redash/query_runner/json_ds.py, redash/utils/requests_session.py, requirements_all_ds.txt, tests/query_runner/test_http.py
Commit date: 2021-11-23 14:58:20 -0800
Commit message: Merge pull request from GHSA-fcpv-hgq6-87h7


Diff: diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py
index 66dfa6e43..6f8ef389b 100644
--- a/redash/query_runner/__init__.py
+++ b/redash/query_runner/__init__.py
@@ -11,11 +11,12 @@ from six import text_type
 from sshtunnel import open_tunnel
 from redash import settings, utils
 from redash.utils import json_loads, query_is_select_no_limit, add_limit_to_query
 from rq.timeouts import JobTimeoutException
 
-from redash.utils.requests_session import requests, requests_session
+from redash.utils.requests_session import requests_or_advocate, requests_session, UnacceptableAddressException
+
 
 logger = logging.getLogger(__name__)
 
 __all__ = [
     "BaseQueryRunner",
@@ -234,16 +235,10 @@ class BaseSQLQueryRunner(BaseQueryRunner):
             return combine_sql_statements(queries)
         else:
             return query_text
 
 
-def is_private_address(url):
-    hostname = urlparse(url).hostname
-    ip_address = socket.gethostbyname(hostname)
-    return ipaddress.ip_address(text_type(ip_address)).is_private
-
-
 class BaseHTTPQueryRunner(BaseQueryRunner):
     should_annotate_query = False
     response_error = "Endpoint returned unexpected status code"
     requires_authentication = False
     requires_url = True
@@ -283,12 +278,10 @@ class BaseHTTPQueryRunner(BaseQueryRunner):
             raise ValueError("Username and Password required")
         else:
             return None
 
     def get_response(self, url, auth=None, http_method="get", **kwargs):
-        if is_private_address(url) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
-            raise Exception("Can't query private addresses.")
 
         # Get authentication values if not given
         if auth is None:
             auth = self.get_auth()
 
@@ -305,16 +298,19 @@ class BaseHTTPQueryRunner(BaseQueryRunner):
 
             # Any other responses (e.g. 2xx and 3xx):
             if response.status_code != 200:
                 error = "{} ({}).".format(self.response_error, response.status_code)
 
-        except requests.HTTPError as exc:
+        except requests_or_advocate.HTTPError as exc:
             logger.exception(exc)
             error = "Failed to execute query. " "Return Code: {} Reason: {}".format(
                 response.status_code, response.text
             )
-        except requests.RequestException as exc:
+        except UnacceptableAddressException as exc:
+            logger.exception(exc)
+            error = "Can't query private addresses."
+        except requests_or_advocate.RequestException as exc:
             # Catch all other requests exceptions and return the error.
             logger.exception(exc)
             error = str(exc)
 
         # Return response and error.
diff --git a/redash/query_runner/csv.py b/redash/query_runner/csv.py
index 8514868e2..22aa148d5 100644
--- a/redash/query_runner/csv.py
+++ b/redash/query_runner/csv.py
@@ -1,11 +1,11 @@
 import logging
 import yaml
-import requests
 import io
 
-from redash import settings
+from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException
+
 from redash.query_runner import *
 from redash.utils import json_dumps
 
 logger = logging.getLogger(__name__)
 
@@ -50,18 +50,15 @@ class CSV(BaseQueryRunner):
             args = yaml.safe_load(query)
             path = args['url']
             args.pop('url', None)
             ua = args['user-agent']
             args.pop('user-agent', None)
-
-            if is_private_address(path) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
-                raise Exception("Can't query private addresses.")
         except:
             pass
 
         try:
-            response = requests.get(url=path, headers={"User-agent": ua})
+            response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
             workbook = pd.read_csv(io.BytesIO(response.content),sep=",", **args)
 
             df = workbook.copy()
             data = {'columns': [], 'rows': []}
             conversions = [
@@ -86,10 +83,13 @@ class CSV(BaseQueryRunner):
             json_data = json_dumps(data)
             error = None
         except KeyboardInterrupt:
             error = "Query cancelled by user."
             json_data = None
+        except UnacceptableAddressException:
+            error = "Can't query private addresses."
+            json_data = None
         except Exception as e:
             error = "Error reading {0}. {1}".format(path, str(e))
             json_data = None
 
         return json_data, error
diff --git a/redash/query_runner/excel.py b/redash/query_runner/excel.py
index a58c23ac9..792a821bc 100644
--- a/redash/query_runner/excel.py
+++ b/redash/query_runner/excel.py
@@ -1,10 +1,10 @@
 import logging
 import yaml
-import requests
 
-from redash import settings
+from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException
+
 from redash.query_runner import *
 from redash.utils import json_dumps
 
 logger = logging.getLogger(__name__)
 
@@ -47,17 +47,15 @@ class Excel(BaseQueryRunner):
             path = args['url']
             args.pop('url', None)
             ua = args['user-agent']
             args.pop('user-agent', None)
 
-            if is_private_address(path) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
-                raise Exception("Can't query private addresses.")
         except:
             pass
 
         try:
-            response = requests.get(url=path, headers={"User-agent": ua})
+            response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
             workbook = pd.read_excel(response.content, **args)
 
             df = workbook.copy()
             data = {'columns': [], 'rows': []}
             conversions = [
@@ -82,10 +80,13 @@ class Excel(BaseQueryRunner):
             json_data = json_dumps(data)
             error = None
         except KeyboardInterrupt:
             error = "Query cancelled by user."
             json_data = None
+        except UnacceptableAddressException:
+            error = "Can't query private addresses."
+            json_data = None
         except Exception as e:
             error = "Error reading {0}. {1}".format(path, str(e))
             json_data = None
 
         return json_data, error
diff --git a/redash/query_runner/json_ds.py b/redash/query_runner/json_ds.py
index ed8562bcc..a6cf42eb5 100644
--- a/redash/query_runner/json_ds.py
+++ b/redash/query_runner/json_ds.py
@@ -1,20 +1,21 @@
 import logging
 import yaml
 import datetime
 from funcy import compact, project
-from redash import settings
+
+from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException
+
 from redash.utils import json_dumps
 from redash.query_runner import (
     BaseHTTPQueryRunner,
     register,
     TYPE_BOOLEAN,
     TYPE_DATETIME,
     TYPE_FLOAT,
     TYPE_INTEGER,
     TYPE_STRING,
-    is_private_address,
 )
 
 
 class QueryParseError(Exception):
     pass
@@ -161,12 +162,10 @@ class JSON(BaseHTTPQueryRunner):
             )
 
         if "url" not in query:
             raise QueryParseError("Query must include 'url' option.")
 
-        if is_private_address(query["url"]) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
-            raise Exception("Can't query private addresses.")
 
         method = query.get("method", "get")
         request_options = project(query, ("params", "headers", "data", "auth", "json"))
 
         fields = query.get("fields")
diff --git a/redash/utils/requests_session.py b/redash/utils/requests_session.py
index e59731b0e..f38133a9a 100644
--- a/redash/utils/requests_session.py
+++ b/redash/utils/requests_session.py
@@ -1,10 +1,16 @@
-import requests
 from redash import settings
 
+from advocate.exceptions import UnacceptableAddressException
+if settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
+    import advocate as requests_or_advocate
+else:
+    import requests as requests_or_advocate
 
-class ConfiguredSession(requests.Session):
+
+
+class ConfiguredSession(requests_or_advocate.Session):
     def request(self, *args, **kwargs):
         if not settings.REQUESTS_ALLOW_REDIRECTS:
             kwargs.update({"allow_redirects": False})
         return super().request(*args, **kwargs)
 
diff --git a/requirements_all_ds.txt b/requirements_all_ds.txt
index f0ad7913a..b64ae2eae 100644
--- a/requirements_all_ds.txt
+++ b/requirements_all_ds.txt
@@ -39,5 +39,6 @@ pyodbc==4.0.28
 trino~=0.305
 cmem-cmempy==21.2.3
 xlrd==2.0.1
 openpyxl==3.0.7
 firebolt-sqlalchemy
+advocate==1.0.0
\ No newline at end of file
diff --git a/tests/query_runner/test_http.py b/tests/query_runner/test_http.py
index 00ab6500c..1eb499eaa 100644
--- a/tests/query_runner/test_http.py
+++ b/tests/query_runner/test_http.py
@@ -1,9 +1,9 @@
 import mock
 from unittest import TestCase
 
-from redash.utils.requests_session import requests, ConfiguredSession
+from redash.utils.requests_session import requests_or_advocate, ConfiguredSession
 from redash.query_runner import BaseHTTPQueryRunner
 
 
 class RequiresAuthQueryRunner(BaseHTTPQueryRunner):
     requires_authentication = True
@@ -82,11 +82,11 @@ class TestBaseHTTPQueryRunner(TestCase):
     @mock.patch.object(ConfiguredSession, "request")
     def test_get_response_httperror_exception(self, mock_get):
         mock_response = mock.Mock()
         mock_response.status_code = 500
         mock_response.text = "Server Error"
-        http_error = requests.HTTPError()
+        http_error = requests_or_advocate.HTTPError()
         mock_response.raise_for_status.side_effect = http_error
         mock_get.return_value = mock_response
 
         url = "https://example.com/"
         query_runner = BaseHTTPQueryRunner({})
@@ -99,11 +99,11 @@ class TestBaseHTTPQueryRunner(TestCase):
     def test_get_response_requests_exception(self, mock_get):
         mock_response = mock.Mock()
         mock_response.status_code = 500
         mock_response.text = "Server Error"
         exception_message = "Some requests exception"
-        requests_exception = requests.RequestException(exception_message)
+        requests_exception = requests_or_advocate.RequestException(exception_message)
         mock_response.raise_for_status.side_effect = requests_exception
         mock_get.return_value = mock_response
 
         url = "https://example.com/"
         query_runner = BaseHTTPQueryRunner({})

Vulnerable code: from redash.utils.requests_session import requests, requests_session
def is_private_address(url):
    hostname = urlparse(url).hostname
    ip_address = socket.gethostbyname(hostname)
    return ipaddress.ip_address(text_type(ip_address)).is_private


        if is_private_address(url) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
            raise Exception("Can't query private addresses.")
        except requests.HTTPError as exc:
        except requests.RequestException as exc:
import requests
from redash import settings

            if is_private_address(path) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
                raise Exception("Can't query private addresses.")
            response = requests.get(url=path, headers={"User-agent": ua})
import requests
from redash import settings
            if is_private_address(path) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
                raise Exception("Can't query private addresses.")
            response = requests.get(url=path, headers={"User-agent": ua})
from redash import settings
    is_private_address,
        if is_private_address(query["url"]) and settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
            raise Exception("Can't query private addresses.")
import requests
class ConfiguredSession(requests.Session):
from redash.utils.requests_session import requests, ConfiguredSession
        http_error = requests.HTTPError()
        requests_exception = requests.RequestException(exception_message)
Fixed code: from redash.utils.requests_session import requests_or_advocate, requests_session, UnacceptableAddressException

        except requests_or_advocate.HTTPError as exc:
        except UnacceptableAddressException as exc:
            logger.exception(exc)
            error = "Can't query private addresses."
        except requests_or_advocate.RequestException as exc:
from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException

            response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
        except UnacceptableAddressException:
            error = "Can't query private addresses."
            json_data = None
from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException

            response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
        except UnacceptableAddressException:
            error = "Can't query private addresses."
            json_data = None

from redash.utils.requests_session import requests_or_advocate, UnacceptableAddressException

from advocate.exceptions import UnacceptableAddressException
if settings.ENFORCE_PRIVATE_ADDRESS_BLOCK:
    import advocate as requests_or_advocate
else:
    import requests as requests_or_advocate


class ConfiguredSession(requests_or_advocate.Session):
advocate==1.0.0
from redash.utils.requests_session import requests_or_advocate, ConfiguredSession
        http_error = requests_or_advocate.HTTPError()
        requests_exception = requests_or_advocate.RequestException(exception_message)