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

PeerOnlySubscriptions.kt

package dev.stave.upnp

import org.jupnp.UpnpServiceConfiguration
import org.jupnp.UpnpServiceImpl
import org.jupnp.model.message.StreamRequestMessage
import org.jupnp.model.message.UpnpResponse
import org.jupnp.model.message.gena.OutgoingSubscribeResponseMessage
import org.jupnp.protocol.ProtocolFactory
import org.jupnp.protocol.ProtocolFactoryImpl
import org.jupnp.protocol.sync.ReceivingSubscribe
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URI
import java.net.URISyntaxException
import java.net.UnknownHostException

/**
 * GENA SUBSCRIBE hardening for the published MediaRenderer (CallStranger,
 * CVE-2020-12695 class).
 *
 * A new subscription names CALLBACK URLs that our event NOTIFYs (current track
 * URI and metadata) are then sent to. jUPnP 3.0.3 keeps only `http://`
 * callbacks and requires each host to resolve to a loopback, link-local or
 * site-local address. That still lets one LAN host point our NOTIFYs at
 * another, and it resolves callback host names through DNS, so a rebinding
 * name can pass the check and later resolve elsewhere.
 *
 * Here a new subscription is accepted only when every callback is `http` and
 * its host is a literal address equal to the address of the peer sending the
 * SUBSCRIBE. Host names are refused without any lookup. Anything else gets
 * 412 Precondition Failed, the status GENA uses for a bad CALLBACK. Renewals
 * (SID, no CALLBACK) and everything else go to jUPnP unchanged, and jUPnP's
 * own checks still run after ours.
 */
internal class PeerOnlySubscriptionsUpnpService(
    configuration: UpnpServiceConfiguration,
) : UpnpServiceImpl(configuration) {

    override fun createProtocolFactory(): ProtocolFactory =
        object : ProtocolFactoryImpl(this@PeerOnlySubscriptionsUpnpService) {
            override fun createReceivingSubscribe(message: StreamRequestMessage): ReceivingSubscribe =
                PeerOnlyReceivingSubscribe(this@PeerOnlySubscriptionsUpnpService, message)
        }
}

private class PeerOnlyReceivingSubscribe(
    service: org.jupnp.UpnpService,
    message: StreamRequestMessage,
) : ReceivingSubscribe(service, message) {

    override fun executeSync(): OutgoingSubscribeResponseMessage? {
        val callbacks = inputMessage.headers[CALLBACK_HEADER]
        if (!callbacks.isNullOrEmpty()) {
            val peer = inputMessage.connection?.remoteAddress
            val reason = callbackRejection(callbacks.joinToString(""), peer)
            if (reason != null) {
                println("[EarhornUPnP] Refused event subscription: $reason")
                return OutgoingSubscribeResponseMessage(UpnpResponse.Status.PRECONDITION_FAILED)
            }
        }
        return super.executeSync()
    }

    private companion object {
        const val CALLBACK_HEADER = "CALLBACK"
    }
}

/**
 * Why a raw CALLBACK header value (`<url><url>…`) from [peer] must be refused,
 * or null when every URL is `http` to [peer]'s own address. Never does a DNS
 * lookup.
 */
internal fun callbackRejection(headerValue: String, peer: InetAddress?): String? {
    if (peer == null) return "sender address unknown"
    val urls = CALLBACK_URL.findAll(headerValue).map { it.groupValues[1].trim() }.toList()
    if (urls.isEmpty()) return "no callback address"
    for (url in urls) {
        val uri = try {
            URI(url)
        } catch (_: URISyntaxException) {
            return "malformed callback address"
        }
        if (!uri.scheme.equals("http", ignoreCase = true)) return "callback is not http"
        val host = uri.host ?: return "callback has no host"
        val address = literalAddress(host) ?: return "callback names a host, not an address"
        if (!address.address.contentEquals(peer.normalised().address)) {
            return "callback points at a different machine than the sender"
        }
    }
    return null
}

private val CALLBACK_URL = Regex("<([^>]*)>")
private val IPV4_LITERAL = Regex("""(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})""")

/** [host] as an address if it is an IP literal (IPv6 in brackets, as
 *  [URI.getHost] gives it), else null. No name resolution either way. */
private fun literalAddress(host: String): InetAddress? {
    IPV4_LITERAL.matchEntire(host)?.let { m ->
        val octets = m.groupValues.drop(1).map { it.toInt() }
        if (octets.any { it > 255 }) return null
        return InetAddress.getByAddress(ByteArray(4) { octets[it].toByte() })
    }
    // Bracketed, the JDK parses an IPv6 literal or throws; it never looks it up.
    if (host.length > 2 && host.startsWith("[") && host.endsWith("]")) {
        return try {
            InetAddress.getByName(host).normalised()
        } catch (_: UnknownHostException) {
            null
        }
    }
    return null
}

/** An IPv4-mapped IPv6 peer (`::ffff:a.b.c.d`) compares as its IPv4 address. */
private fun InetAddress.normalised(): InetAddress {
    val bytes = address
    if (this is Inet6Address && bytes.size == 16 &&
        (0 until 10).all { bytes[it] == 0.toByte() } &&
        bytes[10] == 0xff.toByte() && bytes[11] == 0xff.toByte()
    ) {
        return InetAddress.getByAddress(bytes.copyOfRange(12, 16))
    }
    return this
}