package dev.stave.net import java.net.Inet6Address import java.net.InetAddress import java.net.URI import java.net.URISyntaxException import java.net.UnknownHostException /** * URL checks for addresses that reach us from other devices: AVTransport * `SetAVTransportURI` payloads, Cast receiver metadata, musicnow responses and the * `res` URLs a media server hands out. * * Pure string / literal checks — this object **never performs DNS**. `InetAddress` * is only asked to parse IP *literals* (which it does without a lookup), never names. * DNS rebinding to a loopback address through a hostname is therefore still possible; * the scheme / literal check is the part that matters, because libvlc and OkHttp do * their own resolution anyway. * * Everything fails closed: anything that doesn't parse cleanly is rejected. */ object UrlPolicy { /** Strict dotted-quad decimal (no leading zeros beyond a single "0"). */ private val STRICT_V4 = 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}""") /** A host label that inet_aton / the WHATWG URL parser would read as a number * (decimal, octal-with-leading-zero or 0x-hex). */ private val NUMERIC_LABEL = Regex("""^(0x[0-9a-f]*|\d+)$""", RegexOption.IGNORE_CASE) private fun parse(url: String?): URI? { if (url.isNullOrEmpty()) return null // No whitespace or control characters anywhere: they are how line-injection // and parser-differential tricks get in (libvlc and java.net.URI disagree). if (url.any { it.code <= 0x20 || it.code == 0x7f || it == '\u0085' || it == '
' || it == '
' }) return null val u = try { URI(url) } catch (_: URISyntaxException) { return null } val scheme = u.scheme?.lowercase() ?: return null if (scheme != "http" && scheme != "https") return null if (u.host.isNullOrBlank()) return null return u } /** Host with brackets and a trailing root dot removed, lower-cased. */ private fun normalizedHost(u: URI): String = u.host.removePrefix("[").removeSuffix("]").trimEnd('.').lowercase() /** Parse an IP *literal* (never a name). Null for anything that isn't one. */ private fun literal(host: String): InetAddress? { val isV4 = STRICT_V4.matches(host) val isV6 = ':' in host && host.all { it.isLetterOrDigit() || it == ':' || it == '.' || it == '%' } if (!isV4 && !isV6) return null // Brackets make the JDK treat a v6 string strictly as a literal: a malformed one // throws instead of falling through to a name lookup. return try { InetAddress.getByName(if (isV6) "[$host]" else host) } catch (_: UnknownHostException) { null } } /** True when the host would be read as a number by some resolver but is not a * strict dotted quad: `2130706433`, `0x7f.1`, `127.1`, `0177.0.0.1`, … */ private fun isNumericTrick(host: String): Boolean { if (':' in host) return false if (STRICT_V4.matches(host)) return false val last = host.substringAfterLast('.') return NUMERIC_LABEL.matches(last) } private fun isLocalhostName(host: String): Boolean = host == "localhost" || host.endsWith(".localhost") || host == "localhost.localdomain" || host == "ip6-localhost" || host == "ip6-loopback" /** * May a remote peer ask us to open this URL? Accepts only http(s) with a real * host that is not loopback, the wildcard address, link-local or multicast, and * not written as a decimal / octal / hex number to dodge those checks. * * Used for `SetAVTransportURI` (what libvlc opens) and for art URLs a peer sends. */ fun isPlayableRemoteUrl(url: String?): Boolean { val u = parse(url) ?: return false val host = normalizedHost(u) if (host.isEmpty() || isLocalhostName(host)) return false if (isNumericTrick(host)) return false val a = literal(host) if (a != null && (a.isLoopbackAddress || a.isAnyLocalAddress || a.isLinkLocalAddress || a.isMulticastAddress)) return false return true } /** * Does this http(s) URL stay on the local network? True for RFC 1918 / link-local * / IPv6 unique-local / loopback literals, `.local` (mDNS) names, `.lan` / `.home.arpa` * names, and single-label hosts such as `http://nas:9790`. * * This is a *privacy* test ("would this request leave the LAN?"), not a safety * test — loopback counts as local here. */ fun isLanUrl(url: String?): Boolean { val u = parse(url) ?: return false val host = normalizedHost(u) if (host.isEmpty()) return false val a = literal(host) if (a != null) { return a.isSiteLocalAddress || a.isLinkLocalAddress || a.isLoopbackAddress || isUniqueLocalV6(a) } if (isNumericTrick(host)) return false if (isLocalhostName(host)) return true if (host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) return true return '.' !in host } /** fc00::/7 — the IPv6 equivalent of RFC 1918 (Java's isSiteLocal is fec0::/10). */ private fun isUniqueLocalV6(a: InetAddress): Boolean = a is Inet6Address && (a.address[0].toInt() and 0xfe) == 0xfc }