/
/
/
1"""Helpers for validating redirect URLs in OAuth/auth flows."""
2
3from __future__ import annotations
4
5import ipaddress
6import logging
7from typing import TYPE_CHECKING
8from urllib.parse import urlparse
9
10from music_assistant.constants import MASS_LOGGER_NAME
11
12if TYPE_CHECKING:
13 from aiohttp import web
14
15LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.redirect_validation")
16
17# Allowed redirect URI patterns
18# Add custom URL schemes for mobile apps here
19ALLOWED_REDIRECT_PATTERNS = [
20 # Custom URL schemes for mobile apps
21 "musicassistant://", # Music Assistant mobile app
22 # Home Assistant domains
23 "https://my.home-assistant.io/",
24 "http://homeassistant.local/",
25 "https://homeassistant.local/",
26]
27
28
29def is_allowed_redirect_url(
30 url: str,
31 request: web.Request | None = None,
32 base_url: str | None = None,
33) -> tuple[bool, str]:
34 """
35 Validate if a redirect URL is allowed for OAuth/auth flows.
36
37 Security rules (in order of priority):
38 1. Must use http, https, or registered custom scheme (e.g., musicassistant://)
39 2. Same origin as the request - auto-allowed (trusted)
40 3. Localhost (127.0.0.1, ::1, localhost) - auto-allowed (trusted)
41 4. Private network IPs (RFC 1918) - auto-allowed (trusted)
42 5. Configured base_url - auto-allowed (trusted)
43 6. Matches allowed redirect patterns - auto-allowed (trusted)
44 7. Everything else - requires user consent (external)
45
46 :param url: The redirect URL to validate.
47 :param request: Optional aiohttp request to compare origin.
48 :param base_url: Optional configured base URL to allow.
49 :return: Tuple of (is_valid, category) where category is:
50 - "trusted": Auto-allowed, no consent needed
51 - "external": Valid but requires user consent
52 - "blocked": Invalid/dangerous URL
53 """
54 if not url:
55 return False, "blocked"
56
57 try:
58 parsed = urlparse(url)
59
60 # Check for custom URL schemes (mobile apps)
61 for pattern in ALLOWED_REDIRECT_PATTERNS:
62 if url.startswith(pattern):
63 LOGGER.debug("Redirect URL trusted (pattern match): %s", url)
64 return True, "trusted"
65
66 # Only http/https for web URLs
67 if parsed.scheme not in ("http", "https"):
68 LOGGER.warning("Redirect URL blocked (invalid scheme): %s", url)
69 return False, "blocked"
70
71 hostname = parsed.hostname
72 if not hostname:
73 LOGGER.warning("Redirect URL blocked (no hostname): %s", url)
74 return False, "blocked"
75
76 # 1. Same origin as request - always trusted
77 if request:
78 request_host = request.host
79 if parsed.netloc == request_host:
80 LOGGER.debug("Redirect URL trusted (same origin): %s", url)
81 return True, "trusted"
82
83 # 2. Localhost - always trusted (for development and mobile app testing)
84 if hostname in ("localhost", "127.0.0.1", "::1"):
85 LOGGER.debug("Redirect URL trusted (localhost): %s", url)
86 return True, "trusted"
87
88 # 3. Private network IPs - always trusted (for local network access)
89 if _is_private_ip(hostname):
90 LOGGER.debug("Redirect URL trusted (private IP): %s", url)
91 return True, "trusted"
92
93 # 4. Configured base_url - always trusted
94 if base_url:
95 base_parsed = urlparse(base_url)
96 if parsed.netloc == base_parsed.netloc:
97 LOGGER.debug("Redirect URL trusted (base_url): %s", url)
98 return True, "trusted"
99
100 # If we get here, URL is external and requires user consent
101 LOGGER.info("Redirect URL is external (requires consent): %s", url)
102 return True, "external"
103
104 except Exception as e:
105 LOGGER.exception("Error validating redirect URL: %s", e)
106 return False, "blocked"
107
108
109def _is_private_ip(hostname: str) -> bool:
110 """Check if hostname is a private IP address (RFC 1918)."""
111 try:
112 ip = ipaddress.ip_address(hostname)
113 return ip.is_private
114 except ValueError:
115 # Not a valid IP address
116 return False
117