UrlPolicy.kt
117 lines · 5.4 KB
Changed in: 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 | } |


