CVE-2021-43777: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: Redash is a package for data visualization and sharing. In Redash version 10.0 and prior, the implementation of Google Login (via OAuth) incorrectly uses the `state` parameter to pass the next URL to redirect the user to after login. The `state` parameter should be used for a Cross-Site Request Forgery (CSRF) token, not a static and easily predicted value. This vulnerability does not affect users who do not use Google Login for their instance of Redash. A patch in the `master` and `release/10.x.x` branches addresses this by replacing `Flask-Oauthlib` with `Authlib` which automatically provides and validates a CSRF token for the state variable. The new implementation stores the next URL on the user session object. As a workaround, one may disable Google Login to mitigate the vulnerability.
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 6.1
Published: 2021-11-24T16:15Z
Repository: https://github.com/getredash/redash
Language: Python
Files changed: redash/authentication/__init__.py, redash/authentication/google_oauth.py, requirements.txt
Commit date: 2021-11-23 14:22:02 -0800
Commit message: Merge pull request from GHSA-vhc7-w7r8-8m34

* WIP: break the flask_oauthlib behavior

* Refactor google-oauth to use cryptographic state.

* Clean up comments

* Fix: tests didn't pass because of the scope issues.

Moved outside the create_blueprint method because this does not depend
on the Authlib object.

* Apply Arik's fixes. Tests pass.
Diff: diff --git a/redash/authentication/__init__.py b/redash/authentication/__init__.py
index 94bf53be..f06cd3cd 100644
--- a/redash/authentication/__init__.py
+++ b/redash/authentication/__init__.py
@@ -241,28 +241,30 @@ def logout_and_redirect_to_index():
     return redirect(index_url)
 
 
 def init_app(app):
     from redash.authentication import (
-        google_oauth,
         saml_auth,
         remote_user_auth,
         ldap_auth,
     )
 
+    from redash.authentication.google_oauth import create_google_oauth_blueprint
+
     login_manager.init_app(app)
     login_manager.anonymous_user = models.AnonymousUser
     login_manager.REMEMBER_COOKIE_DURATION = settings.REMEMBER_COOKIE_DURATION
 
     @app.before_request
     def extend_session():
         session.permanent = True
         app.permanent_session_lifetime = timedelta(seconds=settings.SESSION_EXPIRY_TIME)
 
     from redash.security import csrf
-    for auth in [google_oauth, saml_auth, remote_user_auth, ldap_auth]:
-        blueprint = auth.blueprint
+
+    # Authlib's flask oauth client requires a Flask app to initialize
+    for blueprint in [create_google_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]:
         csrf.exempt(blueprint)
         app.register_blueprint(blueprint)
 
     user_logged_in.connect(log_user_logged_in)
     login_manager.request_loader(request_loader)
diff --git a/redash/authentication/google_oauth.py b/redash/authentication/google_oauth.py
index 59d49ef9..a1f58d3f 100644
--- a/redash/authentication/google_oauth.py
+++ b/redash/authentication/google_oauth.py
@@ -1,54 +1,19 @@
 import logging
 import requests
 from flask import redirect, url_for, Blueprint, flash, request, session
-from flask_oauthlib.client import OAuth
+
 
 from redash import models, settings
 from redash.authentication import (
     create_and_login_user,
     logout_and_redirect_to_index,
     get_next_path,
 )
 from redash.authentication.org_resolving import current_org
 
-logger = logging.getLogger("google_oauth")
-
-oauth = OAuth()
-blueprint = Blueprint("google_oauth", __name__)
-
-
-def google_remote_app():
-    if "google" not in oauth.remote_apps:
-        oauth.remote_app(
-            "google",
-            base_url="https://www.google.com/accounts/",
-            authorize_url="https://accounts.google.com/o/oauth2/auth?prompt=select_account+consent",
-            request_token_url=None,
-            request_token_params={
-                "scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
-            },
-            access_token_url="https://accounts.google.com/o/oauth2/token",
-            access_token_method="POST",
-            consumer_key=settings.GOOGLE_CLIENT_ID,
-            consumer_secret=settings.GOOGLE_CLIENT_SECRET,
-        )
-
-    return oauth.google
-
-
-def get_user_profile(access_token):
-    headers = {"Authorization": "OAuth {}".format(access_token)}
-    response = requests.get(
-        "https://www.googleapis.com/oauth2/v1/userinfo", headers=headers
-    )
-
-    if response.status_code == 401:
-        logger.warning("Failed getting user profile (response code 401).")
-        return None
-
-    return response.json()
+from authlib.integrations.flask_client import OAuth
 
 
 def verify_profile(org, profile):
     if org.is_public:
         return True
@@ -63,62 +28,104 @@ def verify_profile(org, profile):
         return True
 
     return False
 
 
-@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
-def org_login(org_slug):
-    session["org_slug"] = current_org.slug
-    return redirect(url_for(".authorize", next=request.args.get("next", None)))
+def create_google_oauth_blueprint(app):
+    oauth = OAuth(app)
 
+    logger = logging.getLogger("google_oauth")
+    blueprint = Blueprint("google_oauth", __name__)
 
-@blueprint.route("/oauth/google", endpoint="authorize")
-def login():
-    callback = url_for(".callback", _external=True)
-    next_path = request.args.get(
-        "next", url_for("redash.index", org_slug=session.get("org_slug"))
+    CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
+    oauth = OAuth(app)
+    oauth.register(
+        name="google",
+        server_metadata_url=CONF_URL,
+        client_kwargs={"scope": "openid email profile"},
     )
-    logger.debug("Callback url: %s", callback)
-    logger.debug("Next is: %s", next_path)
-    return google_remote_app().authorize(callback=callback, state=next_path)
-
-
-@blueprint.route("/oauth/google_callback", endpoint="callback")
-def authorized():
-    resp = google_remote_app().authorized_response()
-    access_token = resp["access_token"]
-
-    if access_token is None:
-        logger.warning("Access token missing in call back request.")
-        flash("Validation error. Please retry.")
-        return redirect(url_for("redash.login"))
-
-    profile = get_user_profile(access_token)
-    if profile is None:
-        flash("Validation error. Please retry.")
-        return redirect(url_for("redash.login"))
-
-    if "org_slug" in session:
-        org = models.Organization.get_by_slug(session.pop("org_slug"))
-    else:
-        org = current_org
-
-    if not verify_profile(org, profile):
-        logger.warning(
-            "User tried to login with unauthorized domain name: %s (org: %s)",
-            profile["email"],
-            org,
+
+    def get_user_profile(access_token):
+        headers = {"Authorization": "OAuth {}".format(access_token)}
+        response = requests.get(
+            "https://www.googleapis.com/oauth2/v1/userinfo", headers=headers
         )
-        flash("Your Google Apps account ({}) isn't allowed.".format(profile["email"]))
-        return redirect(url_for("redash.login", org_slug=org.slug))
 
-    picture_url = "%s?sz=40" % profile["picture"]
-    user = create_and_login_user(org, profile["name"], profile["email"], picture_url)
-    if user is None:
-        return logout_and_redirect_to_index()
+        if response.status_code == 401:
+            logger.warning("Failed getting user profile (response code 401).")
+            return None
 
-    unsafe_next_path = request.args.get("state") or url_for(
-        "redash.index", org_slug=org.slug
-    )
-    next_path = get_next_path(unsafe_next_path)
+        return response.json()
+
+    @blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
+    def org_login(org_slug):
+        session["org_slug"] = current_org.slug
+        return redirect(url_for(".authorize", next=request.args.get("next", None)))
+
+    @blueprint.route("/oauth/google", endpoint="authorize")
+    def login():
+
+        redirect_uri = url_for(".callback", _external=True)
+
+        next_path = request.args.get(
+            "next", url_for("redash.index", org_slug=session.get("org_slug"))
+        )
+        logger.debug("Callback url: %s", redirect_uri)
+        logger.debug("Next is: %s", next_path)
+
+        session["next_url"] = next_path
+
+        return oauth.google.authorize_redirect(redirect_uri)
+
+    @blueprint.route("/oauth/google_callback", endpoint="callback")
+    def authorized():
+
+        logger.debug("Authorized user inbound")
+
+        resp = oauth.google.authorize_access_token()
+        user = resp.get("userinfo")
+        if user:
+            session["user"] = user
+
+        access_token = resp["access_token"]
+
+        if access_token is None:
+            logger.warning("Access token missing in call back request.")
+            flash("Validation error. Please retry.")
+            return redirect(url_for("redash.login"))
+
+        profile = get_user_profile(access_token)
+        if profile is None:
+            flash("Validation error. Please retry.")
+            return redirect(url_for("redash.login"))
+
+        if "org_slug" in session:
+            org = models.Organization.get_by_slug(session.pop("org_slug"))
+        else:
+            org = current_org
+
+        if not verify_profile(org, profile):
+            logger.warning(
+                "User tried to login with unauthorized domain name: %s (org: %s)",
+                profile["email"],
+                org,
+            )
+            flash(
+                "Your Google Apps account ({}) isn't allowed.".format(profile["email"])
+            )
+            return redirect(url_for("redash.login", org_slug=org.slug))
+
+        picture_url = "%s?sz=40" % profile["picture"]
+        user = create_and_login_user(
+            org, profile["name"], profile["email"], picture_url
+        )
+        if user is None:
+            return logout_and_redirect_to_index()
+
+        unsafe_next_path = session.get("next_url") or url_for(
+            "redash.index", org_slug=org.slug
+        )
+        next_path = get_next_path(unsafe_next_path)
+
+        return redirect(next_path)
 
-    return redirect(next_path)
+    return blueprint
diff --git a/requirements.txt b/requirements.txt
index 62e64f3a..03eb0e9e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,13 +6,10 @@ MarkupSafe==1.1.1
 pyOpenSSL==19.0.0
 httplib2==0.14.0
 wtforms==2.2.1
 Flask-RESTful==0.3.7
 Flask-Login==0.4.1
-Flask-OAuthLib==0.9.5
-# pin this until https://github.com/lepture/flask-oauthlib/pull/388 is released
-requests-oauthlib>=0.6.2,<1.2.0
 Flask-SQLAlchemy==2.4.1
 Flask-Migrate==2.5.2
 flask-mail==0.9.1
 flask-talisman==0.7.0
 Flask-Limiter==0.9.3
@@ -65,5 +62,6 @@ werkzeug==0.16.1
 # It has its own requirements file to simplify the frontend client build process
 -r requirements_bundles.txt
 # Uncomment the requirement for ldap3 if using ldap.
 # It is not included by default because of the GPL license conflict.
 # ldap3==2.2.4
+Authlib==0.15.5
\ No newline at end of file

Vulnerable code:         google_oauth,
    for auth in [google_oauth, saml_auth, remote_user_auth, ldap_auth]:
        blueprint = auth.blueprint
from flask_oauthlib.client import OAuth
logger = logging.getLogger("google_oauth")

oauth = OAuth()
blueprint = Blueprint("google_oauth", __name__)


def google_remote_app():
    if "google" not in oauth.remote_apps:
        oauth.remote_app(
            "google",
            base_url="https://www.google.com/accounts/",
            authorize_url="https://accounts.google.com/o/oauth2/auth?prompt=select_account+consent",
            request_token_url=None,
            request_token_params={
                "scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
            },
            access_token_url="https://accounts.google.com/o/oauth2/token",
            access_token_method="POST",
            consumer_key=settings.GOOGLE_CLIENT_ID,
            consumer_secret=settings.GOOGLE_CLIENT_SECRET,
        )

    return oauth.google


def get_user_profile(access_token):
    headers = {"Authorization": "OAuth {}".format(access_token)}
    response = requests.get(
        "https://www.googleapis.com/oauth2/v1/userinfo", headers=headers
    )

    if response.status_code == 401:
        logger.warning("Failed getting user profile (response code 401).")
        return None

    return response.json()
@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
def org_login(org_slug):
    session["org_slug"] = current_org.slug
    return redirect(url_for(".authorize", next=request.args.get("next", None)))
@blueprint.route("/oauth/google", endpoint="authorize")
def login():
    callback = url_for(".callback", _external=True)
    next_path = request.args.get(
        "next", url_for("redash.index", org_slug=session.get("org_slug"))
    logger.debug("Callback url: %s", callback)
    logger.debug("Next is: %s", next_path)
    return google_remote_app().authorize(callback=callback, state=next_path)


@blueprint.route("/oauth/google_callback", endpoint="callback")
def authorized():
    resp = google_remote_app().authorized_response()
    access_token = resp["access_token"]

    if access_token is None:
        logger.warning("Access token missing in call back request.")
        flash("Validation error. Please retry.")
        return redirect(url_for("redash.login"))

    profile = get_user_profile(access_token)
    if profile is None:
        flash("Validation error. Please retry.")
        return redirect(url_for("redash.login"))

    if "org_slug" in session:
        org = models.Organization.get_by_slug(session.pop("org_slug"))
    else:
        org = current_org

    if not verify_profile(org, profile):
        logger.warning(
            "User tried to login with unauthorized domain name: %s (org: %s)",
            profile["email"],
            org,
        flash("Your Google Apps account ({}) isn't allowed.".format(profile["email"]))
        return redirect(url_for("redash.login", org_slug=org.slug))
    picture_url = "%s?sz=40" % profile["picture"]
    user = create_and_login_user(org, profile["name"], profile["email"], picture_url)
    if user is None:
        return logout_and_redirect_to_index()
    unsafe_next_path = request.args.get("state") or url_for(
        "redash.index", org_slug=org.slug
    )
    next_path = get_next_path(unsafe_next_path)
    return redirect(next_path)
Flask-OAuthLib==0.9.5
# pin this until https://github.com/lepture/flask-oauthlib/pull/388 is released
requests-oauthlib>=0.6.2,<1.2.0
Fixed code:     from redash.authentication.google_oauth import create_google_oauth_blueprint


    # Authlib's flask oauth client requires a Flask app to initialize
    for blueprint in [create_google_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]:

from authlib.integrations.flask_client import OAuth
def create_google_oauth_blueprint(app):
    oauth = OAuth(app)
    logger = logging.getLogger("google_oauth")
    blueprint = Blueprint("google_oauth", __name__)
    CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
    oauth = OAuth(app)
    oauth.register(
        name="google",
        server_metadata_url=CONF_URL,
        client_kwargs={"scope": "openid email profile"},

    def get_user_profile(access_token):
        headers = {"Authorization": "OAuth {}".format(access_token)}
        response = requests.get(
            "https://www.googleapis.com/oauth2/v1/userinfo", headers=headers
        if response.status_code == 401:
            logger.warning("Failed getting user profile (response code 401).")
            return None
        return response.json()

    @blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
    def org_login(org_slug):
        session["org_slug"] = current_org.slug
        return redirect(url_for(".authorize", next=request.args.get("next", None)))

    @blueprint.route("/oauth/google", endpoint="authorize")
    def login():

        redirect_uri = url_for(".callback", _external=True)

        next_path = request.args.get(
            "next", url_for("redash.index", org_slug=session.get("org_slug"))
        )
        logger.debug("Callback url: %s", redirect_uri)
        logger.debug("Next is: %s", next_path)

        session["next_url"] = next_path

        return oauth.google.authorize_redirect(redirect_uri)

    @blueprint.route("/oauth/google_callback", endpoint="callback")
    def authorized():

        logger.debug("Authorized user inbound")

        resp = oauth.google.authorize_access_token()
        user = resp.get("userinfo")
        if user:
            session["user"] = user

        access_token = resp["access_token"]

        if access_token is None:
            logger.warning("Access token missing in call back request.")
            flash("Validation error. Please retry.")
            return redirect(url_for("redash.login"))

        profile = get_user_profile(access_token)
        if profile is None:
            flash("Validation error. Please retry.")
            return redirect(url_for("redash.login"))

        if "org_slug" in session:
            org = models.Organization.get_by_slug(session.pop("org_slug"))
        else:
            org = current_org

        if not verify_profile(org, profile):
            logger.warning(
                "User tried to login with unauthorized domain name: %s (org: %s)",
                profile["email"],
                org,
            )
            flash(
                "Your Google Apps account ({}) isn't allowed.".format(profile["email"])
            )
            return redirect(url_for("redash.login", org_slug=org.slug))

        picture_url = "%s?sz=40" % profile["picture"]
        user = create_and_login_user(
            org, profile["name"], profile["email"], picture_url
        )
        if user is None:
            return logout_and_redirect_to_index()

        unsafe_next_path = session.get("next_url") or url_for(
            "redash.index", org_slug=org.slug
        )
        next_path = get_next_path(unsafe_next_path)

        return redirect(next_path)
    return blueprint
Authlib==0.15.5