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

UrlPolicy.kt

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
}