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:
- 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
- Flask redirect:
redirect("////evil.com") generates a 302 response with Location: //evil.com (Werkzeug normalizes consecutive slashes in the path)
- 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:
Steps to reproduce:
- Start the demo:
python redirect_demo.py
- 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):

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

## 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
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 inLoginPost()usesurlparse().netlocandurlparse().schemeto detect external URLs, but Flask’sredirect()normalizes path-based inputs like////evil.cominto protocol-relative URLs (//evil.com), which browsers interpret as an external redirect.The core issue:
urlparse()and the browser disagree on whether////evil.comis an external URL:urlparse("////evil.com")→scheme='',netloc=''— treated as a relative path → passes the checkLocation: //evil.com→ protocol-relative URL → redirects toevil.comSince the protection validates the URL as a harmless relative path, but the browser follows the
Locationheader to an attacker-controlled domain, the protection is bypassed entirely.Details
The redirect protection in
login.py(lines 63-65) checks fornetlocandschemeto decide if the URL is external:With the payload
////evil.com:urlparse("////evil.com")returnsscheme='',netloc='',path='//evil.com'→ neitherparts.netlocnorparts.schemeis truthy → check passes,nextUrlis used directlyredirect("////evil.com")generates a302response withLocation: //evil.com(Werkzeug normalizes consecutive slashes in the path)Location: //evil.com→ interprets as a protocol-relative URL → navigates tohttp(s)://evil.comRoot Cause
urlparse()treats URLs with more than two leading slashes as path-only (no authority component), setting bothschemeandnetlocto empty strings. However, Flask/Werkzeug normalizes the redundant slashes when generating theLocationheader, producing//evil.com— which is a valid protocol-relative URL that browsers follow to an external domain. The protection only checks theurlparseresult and does not account for this normalization behavior.PoC
Demo Code (
redirect_demo.py):Payload:
Steps to reproduce:
python redirect_demo.pyBlocked case — directly using
//evil.comis correctly blocked by the protection (urlparse("//evil.com").netlocreturnsevil.com→ check triggers):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:
https://kindleear-instance.com/login?next=////evil.comthat, after successful login, redirects the victim to an attacker-controlled site mimicking the KindleEar interface to steal credentials or session tokens