CVE-2024-24808: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: pyLoad is an open-source Download Manager written in pure Python. There is an open redirect vulnerability due to incorrect validation of input values when redirecting users after login. pyLoad is validating URLs via the `get_redirect_url` function when redirecting users at login. This vulnerability has been patched with commit fe94451.
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: 2024-02-06T04:15Z
Repository: https://github.com/pyload/pyload
Language: Python
Files changed: src/pyload/webui/app/blueprints/app_blueprint.py, src/pyload/webui/app/helpers.py
Commit date: 2024-02-06 00:19:52 +0200
Commit message: fix GHSA-g3cm-qg2v-2hj5 security advisory

Diff: diff --git a/src/pyload/webui/app/blueprints/app_blueprint.py b/src/pyload/webui/app/blueprints/app_blueprint.py
index 73ff742074..9900293155 100644
--- a/src/pyload/webui/app/blueprints/app_blueprint.py
+++ b/src/pyload/webui/app/blueprints/app_blueprint.py
@@ -45,40 +45,40 @@ def robots():
 # TODO: Rewrite login route using flask-login
 @bp.route("/login", methods=["GET", "POST"], endpoint="login")
 def login():
     api = flask.current_app.config["PYLOAD_API"]
 
-    next = get_redirect_url(fallback=flask.url_for("app.dashboard"))
+    next_url = get_redirect_url(fallback="app.dashboard")
 
     if flask.request.method == "POST":
         user = flask.request.form["username"]
         password = flask.request.form["password"]
         user_info = api.check_auth(user, password)
 
         sanitized_user = user.replace("\n", "\\n").replace("\r", "\\r")
         if not user_info:
             log.error(f"Login failed for user '{sanitized_user}'")
-            return render_template("login.html", next=next, errors=True)
+            return render_template("login.html", errors=True)
 
         set_session(user_info)
         log.info(f"User '{sanitized_user}' successfully logged in")
         flask.flash("Logged in successfully")
 
     if is_authenticated():
-        return flask.redirect(next)
+        return flask.redirect(next_url)
 
     if api.get_config_value("webui", "autologin"):
         allusers = api.get_all_userdata()
         if len(allusers) == 1:  # TODO: check if localhost
             user_info = list(allusers.values())[0]
             set_session(user_info)
             # NOTE: Double-check authentication here because if session[name] is empty,
             #       next login_required redirects here again and all loop out.
             if is_authenticated():
-                return flask.redirect(next)
+                return flask.redirect(next_url)
 
-    return render_template("login.html", next=next)
+    return render_template("login.html")
 
 
 @bp.route("/logout", endpoint="logout")
 def logout():
     s = flask.session
diff --git a/src/pyload/webui/app/helpers.py b/src/pyload/webui/app/helpers.py
index 944df95920..2b50f966ea 100644
--- a/src/pyload/webui/app/helpers.py
+++ b/src/pyload/webui/app/helpers.py
@@ -1,13 +1,14 @@
 # -*- coding: utf-8 -*-
 
 import json
 from functools import wraps
-from urllib.parse import unquote, urljoin, urlparse
+from urllib.parse import urljoin, urlparse
 
 import flask
 import flask_themes2
+import werkzeug.routing.exceptions
 from pyload.core.api import Perms, Role, has_permission
 
 
 class JSONEncoder(json.JSONEncoder):
     def default(self, obj):
@@ -31,26 +32,29 @@ else:
             return json.loads(s, **kwargs)
 
 
 #: Checks if location belongs to same host address
 def is_safe_url(location):
-    ref_url = urlparse(flask.request.host_url)
-    test_url = urlparse(urljoin(flask.request.host_url, location))
-    return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
+    location_urlp = urlparse(location)
+    #: if relative URL then must start with "/"
+    if not location_urlp.netloc and location[0] != "/":
+        return False
+    host_urlp = urlparse(flask.request.host_url)
+    test_urlp = urlparse(urljoin(flask.request.host_url, location))
+    return test_urlp.scheme in ('http', 'https') and host_urlp.netloc == test_urlp.netloc
 
 
 def get_redirect_url(fallback=None):
-    login_url = urljoin(flask.request.url_root, flask.url_for('app.login'))
-    request_url = unquote(flask.request.url)
-    for location in flask.request.values.get("next"), flask.request.referrer:
-        if not location:
-            continue
-        if location in (request_url, login_url):  # don't redirect to same location
-            continue
-        if is_safe_url(location):
-            return location
-    return fallback
+    next_arg = flask.request.values.get("next")
+    redirect_url = flask.url_for(fallback)
+    if next_arg and next_arg != "login":  # don't redirect to same location
+        try:
+            redirect_url = flask.url_for(f"app.{next_arg}")
+        except werkzeug.routing.exceptions.BuildError:
+            pass
+
+    return urljoin(flask.request.url_root, redirect_url)
 
 
 def render_base(messages):
     return render_template("base.html", messages=messages)
 
@@ -204,11 +208,11 @@ def login_required(perm):
                     response = "Forbidden", 403
 
                 else:
                     location = flask.url_for(
                         "app.login",
-                        next=flask.request.url
+                        next=flask.request.endpoint.split(".")[-1]
                     )
                     response = flask.redirect(location)
 
             return response
 

Vulnerable code:     next = get_redirect_url(fallback=flask.url_for("app.dashboard"))
            return render_template("login.html", next=next, errors=True)
        return flask.redirect(next)
                return flask.redirect(next)
    return render_template("login.html", next=next)
from urllib.parse import unquote, urljoin, urlparse
    ref_url = urlparse(flask.request.host_url)
    test_url = urlparse(urljoin(flask.request.host_url, location))
    return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
    login_url = urljoin(flask.request.url_root, flask.url_for('app.login'))
    request_url = unquote(flask.request.url)
    for location in flask.request.values.get("next"), flask.request.referrer:
        if not location:
            continue
        if location in (request_url, login_url):  # don't redirect to same location
            continue
        if is_safe_url(location):
            return location
    return fallback
                        next=flask.request.url
Fixed code:     next_url = get_redirect_url(fallback="app.dashboard")
            return render_template("login.html", errors=True)
        return flask.redirect(next_url)
                return flask.redirect(next_url)
    return render_template("login.html")
from urllib.parse import urljoin, urlparse
import werkzeug.routing.exceptions
    location_urlp = urlparse(location)
    #: if relative URL then must start with "/"
    if not location_urlp.netloc and location[0] != "/":
        return False
    host_urlp = urlparse(flask.request.host_url)
    test_urlp = urlparse(urljoin(flask.request.host_url, location))
    return test_urlp.scheme in ('http', 'https') and host_urlp.netloc == test_urlp.netloc
    next_arg = flask.request.values.get("next")
    redirect_url = flask.url_for(fallback)
    if next_arg and next_arg != "login":  # don't redirect to same location
        try:
            redirect_url = flask.url_for(f"app.{next_arg}")
        except werkzeug.routing.exceptions.BuildError:
            pass

    return urljoin(flask.request.url_root, redirect_url)
                        next=flask.request.endpoint.split(".")[-1]