UrlPolicy.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.net |
| 2 | ||
| 3 | import java.net.Inet6Address | |
| 4 | import java.net.InetAddress | |
| 5 | import java.net.URI | |
| 6 | import java.net.URISyntaxException | |
| 7 | import 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 | */ | |
| 22 | object 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 | } |


