Skip to content

Open Redirect Vulnerability in KindleEar #734

Description

@Fushuling

Introduction

KindleEar (version 3.4.4, commit 5f51b48) is vulnerable to Open Redirect via a URL parsing inconsistency in application/view/login.py. The redirect protection in LoginPost() uses urlparse().netloc and urlparse().scheme to detect external URLs, but Flask’s redirect() normalizes path-based inputs like ////evil.com into protocol-relative URLs (//evil.com), which browsers interpret as an external redirect.

The core issue: urlparse() and the browser disagree on whether ////evil.com is an external URL:

  • urlparse("////evil.com")scheme='', netloc='' — treated as a relative path → passes the check
  • Browser receives Location: //evil.com → protocol-relative URL → redirects to evil.com

Since the protection validates the URL as a harmless relative path, but the browser follows the Location header to an attacker-controlled domain, the protection is bypassed entirely.

Details

The redirect protection in login.py (lines 63-65) checks for netloc and scheme to decide if the URL is external:

# login.py:63-65
if nextUrl:
    parts = urlparse(nextUrl)
    url = url_for("bpLogs.Mylogs") if parts.netloc or parts.scheme else nextUrl

With the payload ////evil.com:

  1. SSRF/redirect check: urlparse("////evil.com") returns scheme='', netloc='', path='//evil.com' → neither parts.netloc nor parts.scheme is truthy → check passes, nextUrl is used directly
  2. Flask redirect: redirect("////evil.com") generates a 302 response with Location: //evil.com (Werkzeug normalizes consecutive slashes in the path)
  3. Browser: receives Location: //evil.com → interprets as a protocol-relative URL → navigates to http(s)://evil.com
urlparse("////evil.com")
  → scheme=''
  → netloc=''
  → path='//evil.com'

KindleEar check:  parts.netloc=False, parts.scheme=False  → PASS ← WRONG
Flask Location:   //evil.com                                → external redirect

Root Cause

urlparse() treats URLs with more than two leading slashes as path-only (no authority component), setting both scheme and netloc to empty strings. However, Flask/Werkzeug normalizes the redundant slashes when generating the Location header, producing //evil.com — which is a valid protocol-relative URL that browsers follow to an external domain. The protection only checks the urlparse result and does not account for this normalization behavior.

PoC

Demo Code (redirect_demo.py):

#!/usr/bin/env python3
"""
KindleEar login redirect logic - minimal reproduction demo
Original logic from application/view/login.py LoginPost()
"""
from urllib.parse import urlparse
from flask import Flask, request, redirect, render_template_string

app = Flask(__name__)
app.secret_key = "test-secret-key"

LOGIN_PAGE = """
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Login Demo</title></head>
<body>
<h2>Login (Open Redirect Test)</h2>
<form method="POST" action="/login">
  <input type="hidden" name="next" value="{{ next}}">
  <p>Username: <input name="username" value="admin"></p>
  <p>Password: <input name="password" type="password" value="admin"></p>
  <p>next = <code>{{ next}}</code></p>
  <button type="submit">Login</button>
</form>
</body>
</html>
"""

@app.route("/login", methods=["GET"])
def login_get():
    next_url = request.args.get("next", "")
    return render_template_string(LOGIN_PAGE, next=next_url)

@app.route("/login", methods=["POST"])
def login_post():
    next_url = request.form.get("next", "")

    # ---- KindleEar original protection logic (login.py:63-65) ----
    if next_url:
        parts = urlparse(next_url)
        url = "/safe" if parts.netloc or parts.scheme else next_url
    else:
        url = "/safe"
    # ---- end ----

    return redirect(url)

@app.route("/safe")
def safe():
    return "<h1>Safe Page (default redirect target)</h1>"

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

Payload:

////evil.com

Steps to reproduce:

  1. Start the demo: python redirect_demo.py
  2. Send the POST request:

Blocked case — directly using //evil.com is correctly blocked by the protection (urlparse("//evil.com").netloc returns evil.com → check triggers):

Image Bypass case — with ////evil.com, urlparse sees no netloc or scheme, but Flask normalizes it to //evil.com in the Location header: Image Image ## Impact

Who is impacted:
All KindleEar deployments running version 3.4.4 (commit 5f51b48) and prior versions containing this redirect protection logic in login.py.

What an attacker can do:

  • Phishing: craft a login link like https://kindleear-instance.com/login?next=////evil.com that, after successful login, redirects the victim to an attacker-controlled site mimicking the KindleEar interface to steal credentials or session tokens
  • Credential harvesting: the redirect occurs immediately after a successful login, when the user’s trust is highest — the attacker’s phishing page can present a “session expired, please re-enter password” prompt
  • OAuth/token theft: if KindleEar is integrated with any OAuth flow that passes tokens via redirect, the attacker can intercept authentication tokens by redirecting to a domain they control

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions