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
}