EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

UrlPolicy.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.net
2
3import java.net.Inet6Address
4import java.net.InetAddress
5import java.net.URI
6import java.net.URISyntaxException
7import java.net.UnknownHostException
8
9/**
10 * URL checks for addresses that reach us from other devices: AVTransport
11 * `SetAVTransportURI` payloads, Cast receiver metadata, musicnow responses and the
12 * `res` URLs a media server hands out.
13 *
14 * Pure string / literal checks — this object **never performs DNS**. `InetAddress`
15 * is only asked to parse IP *literals* (which it does without a lookup), never names.
16 * DNS rebinding to a loopback address through a hostname is therefore still possible;
17 * the scheme / literal check is the part that matters, because libvlc and OkHttp do
18 * their own resolution anyway.
19 *
20 * Everything fails closed: anything that doesn't parse cleanly is rejected.
21 */
22object UrlPolicy {
23
24 /** Strict dotted-quad decimal (no leading zeros beyond a single "0"). */
25 private val STRICT_V4 =
26 Regex("""(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}""")
27
28 /** A host label that inet_aton / the WHATWG URL parser would read as a number
29 * (decimal, octal-with-leading-zero or 0x-hex). */
30 private val NUMERIC_LABEL = Regex("""^(0x[0-9a-f]*|\d+)$""", RegexOption.IGNORE_CASE)
31
32 private fun parse(url: String?): URI? {
33 if (url.isNullOrEmpty()) return null
34 // No whitespace or control characters anywhere: they are how line-injection
35 // and parser-differential tricks get in (libvlc and java.net.URI disagree).
36 if (url.any { it.code <= 0x20 || it.code == 0x7f || it == '\u0085' || it == '
' || it == '
' }) return null
37 val u = try { URI(url) } catch (_: URISyntaxException) { return null }
38 val scheme = u.scheme?.lowercase() ?: return null
39 if (scheme != "http" && scheme != "https") return null
40 if (u.host.isNullOrBlank()) return null
41 return u
42 }
43
44 /** Host with brackets and a trailing root dot removed, lower-cased. */
45 private fun normalizedHost(u: URI): String =
46 u.host.removePrefix("[").removeSuffix("]").trimEnd('.').lowercase()
47
48 /** Parse an IP *literal* (never a name). Null for anything that isn't one. */
49 private fun literal(host: String): InetAddress? {
50 val isV4 = STRICT_V4.matches(host)
51 val isV6 = ':' in host && host.all { it.isLetterOrDigit() || it == ':' || it == '.' || it == '%' }
52 if (!isV4 && !isV6) return null
53 // Brackets make the JDK treat a v6 string strictly as a literal: a malformed one
54 // throws instead of falling through to a name lookup.
55 return try {
56 InetAddress.getByName(if (isV6) "[$host]" else host)
57 } catch (_: UnknownHostException) { null }
58 }
59
60 /** True when the host would be read as a number by some resolver but is not a
61 * strict dotted quad: `2130706433`, `0x7f.1`, `127.1`, `0177.0.0.1`, … */
62 private fun isNumericTrick(host: String): Boolean {
63 if (':' in host) return false
64 if (STRICT_V4.matches(host)) return false
65 val last = host.substringAfterLast('.')
66 return NUMERIC_LABEL.matches(last)
67 }
68
69 private fun isLocalhostName(host: String): Boolean =
70 host == "localhost" || host.endsWith(".localhost") ||
71 host == "localhost.localdomain" || host == "ip6-localhost" || host == "ip6-loopback"
72
73 /**
74 * May a remote peer ask us to open this URL? Accepts only http(s) with a real
75 * host that is not loopback, the wildcard address, link-local or multicast, and
76 * not written as a decimal / octal / hex number to dodge those checks.
77 *
78 * Used for `SetAVTransportURI` (what libvlc opens) and for art URLs a peer sends.
79 */
80 fun isPlayableRemoteUrl(url: String?): Boolean {
81 val u = parse(url) ?: return false
82 val host = normalizedHost(u)
83 if (host.isEmpty() || isLocalhostName(host)) return false
84 if (isNumericTrick(host)) return false
85 val a = literal(host)
86 if (a != null && (a.isLoopbackAddress || a.isAnyLocalAddress ||
87 a.isLinkLocalAddress || a.isMulticastAddress)) return false
88 return true
89 }
90
91 /**
92 * Does this http(s) URL stay on the local network? True for RFC 1918 / link-local
93 * / IPv6 unique-local / loopback literals, `.local` (mDNS) names, `.lan` / `.home.arpa`
94 * names, and single-label hosts such as `http://nas:9790`.
95 *
96 * This is a *privacy* test ("would this request leave the LAN?"), not a safety
97 * test — loopback counts as local here.
98 */
99 fun isLanUrl(url: String?): Boolean {
100 val u = parse(url) ?: return false
101 val host = normalizedHost(u)
102 if (host.isEmpty()) return false
103 val a = literal(host)
104 if (a != null) {
105 return a.isSiteLocalAddress || a.isLinkLocalAddress || a.isLoopbackAddress ||
106 isUniqueLocalV6(a)
107 }
108 if (isNumericTrick(host)) return false
109 if (isLocalhostName(host)) return true
110 if (host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) return true
111 return '.' !in host
112 }
113
114 /** fc00::/7 — the IPv6 equivalent of RFC 1918 (Java's isSiteLocal is fec0::/10). */
115 private fun isUniqueLocalV6(a: InetAddress): Boolean =
116 a is Inet6Address && (a.address[0].toInt() and 0xfe) == 0xfc
117}