Skip to content

Vulnerability Report:Open Redirect in DeepBI #169

@Fushuling

Description

@Fushuling

Version

Introduction

DeepBI (commit 8baf98e) is vulnerable to Open Redirect via a URL parsing inconsistency between Python’s urlsplit() and browser URL interpretation. The get_next_path() function in bi/authentication/__init__.py attempts to prevent open redirects by stripping the scheme and netloc components, but can be bypassed using multi-slash payloads.

The core issue: urlsplit() parses URLs with 4+ leading slashes (e.g., ////evil.com) differently from how browsers interpret the resulting sanitized output:

  • urlsplit("////evil.com") treats the input as having an empty scheme and empty netloc, placing //evil.com in the path component
  • After clearing scheme and netloc (which are already empty), urlunsplit() produces //evil.com
  • Browsers interpret //evil.com as a protocol-relative URL, redirecting to evil.com

Since the sanitization operates on urlsplit() components but the output is consumed by the browser via Location header, the protection is completely bypassed.

Details

The open redirect protection in bi/authentication/__init__.py (lines 303-319) uses urlsplit() to decompose the URL and strips scheme/netloc:

# bi/authentication/__init__.py:303-319
def get_next_path(unsafe_next_path):
    if not unsafe_next_path:
        return ""

    # Preventing open redirection attacks
    parts = list(urlsplit(unsafe_next_path))
    parts[0] = ""  # clear scheme
    parts[1] = ""  # clear netloc
    safe_next_path = urlunsplit(parts)

    if not safe_next_path:
        safe_next_path = "./"

    return safe_next_path

This function is called during login in bi/handlers/authentication.py (lines 283-286/299):

# bi/handlers/authentication.py:272-299
@routes.route(org_scoped_rule("/login"), methods=["GET", "POST"])
def login(org_slug=None):
    ...
    index_url = url_for("bi.index", org_slug=org_slug)
    unsafe_next_path = request.args.get("next", index_url)
    next_path = get_next_path(unsafe_next_path)
    if current_user.is_authenticated:
        return redirect(next_path)          # line 286
    ...
        login_user(user, remember=remember)
        return redirect(next_path)          # line 299

With the payload ////evil.com:

  1. Sanitization: urlsplit("////evil.com")SplitResult(scheme='', netloc='', path='//evil.com', query='', fragment='') — scheme and netloc are already empty, clearing them has no effect
  2. Reconstruction: urlunsplit(['', '', '//evil.com', '', ''])//evil.com
  3. Redirect: redirect("//evil.com") sets Location: //evil.com — browser interprets as a protocol-relative URL, navigating to evil.com
urlsplit("////evil.com")
  → scheme='', netloc='', path='//evil.com', query='', fragment=''
    (4 slashes: urlsplit sees no authority component, entire "//evil.com" lands in path)

urlunsplit(['', '', '//evil.com', '', ''])
  → "//evil.com"
    (scheme and netloc were already empty — clearing is a no-op)

Browser receives Location: //evil.com
  → interprets as protocol-relative URL → navigates to http(s)://evil.com

PoC

Demo Code:

from urllib.parse import urlsplit, urlunsplit
from flask import Flask, request, redirect

app = Flask(__name__)

def get_next_path(unsafe_next_path):
    """Mirror from bi/authentication/__init__.py:303-319"""
    if not unsafe_next_path:
        return ""

    parts = list(urlsplit(unsafe_next_path))
    parts[0] = ""  # clear scheme
    parts[1] = ""  # clear netloc
    safe_next_path = urlunsplit(parts)

    if not safe_next_path:
        safe_next_path = "./"

    return safe_next_path

@app.route("/login")
def login():
    unsafe_next_path = request.args.get("next", "/")
    next_path = get_next_path(unsafe_next_path)
    # mirror from authentication.py:286/299
    return redirect(next_path)

if __name__ == "__main__":
    app.run(debug=True, port=5002)

Dependencies (matching target application):

Flask==1.1.1
Jinja2==3.0.3
markupsafe==2.0.1
itsdangerous==2.0.1
werkzeug==1.0.1

Payload:

http://localhost:5002/login?next=////evil.com

Using //evil.com or http://evil.com as the next parameter is correctly blocked by get_next_path, which redirects to ./:

  • //evil.comurlsplit extracts netloc='evil.com' → netloc is cleared → result is empty → falls back to ./
  • http://evil.comurlsplit extracts scheme='http', netloc='evil.com' → both cleared → result is empty → falls back to ./
Image Image

However, ////evil.com bypasses the protection: urlsplit parses it as scheme='', netloc='', path='//evil.com' — scheme and netloc are already empty, so clearing them is a no-op. The final redirect("//evil.com") sets Location: //evil.com, which the browser interprets as a protocol-relative URL and navigates to evil.com.

Image

Impact

Who is impacted:
All DeepBI deployments — the vulnerable get_next_path() function has been present since the open redirect protection was introduced and remains in the latest commit (8baf98e).

What an attacker can do:

  • Phishing: Craft a login URL like https://deepbi.example.com/login?next=////attacker.com/login that, after successful authentication, redirects the victim to a convincing phishing page to harvest credentials a second time
  • OAuth token theft: If combined with OAuth flows (Google OAuth is supported), redirect tokens or authorization codes to an attacker-controlled server

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions