PeerOnlySubscriptions.kt
126 lines · 5.2 KB
Changed in: 0.5.0
| 1 | package dev.stave.upnp |
| 2 | |
| 3 | import org.jupnp.UpnpServiceConfiguration |
| 4 | import org.jupnp.UpnpServiceImpl |
| 5 | import org.jupnp.model.message.StreamRequestMessage |
| 6 | import org.jupnp.model.message.UpnpResponse |
| 7 | import org.jupnp.model.message.gena.OutgoingSubscribeResponseMessage |
| 8 | import org.jupnp.protocol.ProtocolFactory |
| 9 | import org.jupnp.protocol.ProtocolFactoryImpl |
| 10 | import org.jupnp.protocol.sync.ReceivingSubscribe |
| 11 | import java.net.Inet6Address |
| 12 | import java.net.InetAddress |
| 13 | import java.net.URI |
| 14 | import java.net.URISyntaxException |
| 15 | import java.net.UnknownHostException |
| 16 | |
| 17 | /** |
| 18 | * GENA SUBSCRIBE hardening for the published MediaRenderer (CallStranger, |
| 19 | * CVE-2020-12695 class). |
| 20 | * |
| 21 | * A new subscription names CALLBACK URLs that our event NOTIFYs (current track |
| 22 | * URI and metadata) are then sent to. jUPnP 3.0.3 keeps only `http://` |
| 23 | * callbacks and requires each host to resolve to a loopback, link-local or |
| 24 | * site-local address. That still lets one LAN host point our NOTIFYs at |
| 25 | * another, and it resolves callback host names through DNS, so a rebinding |
| 26 | * name can pass the check and later resolve elsewhere. |
| 27 | * |
| 28 | * Here a new subscription is accepted only when every callback is `http` and |
| 29 | * its host is a literal address equal to the address of the peer sending the |
| 30 | * SUBSCRIBE. Host names are refused without any lookup. Anything else gets |
| 31 | * 412 Precondition Failed, the status GENA uses for a bad CALLBACK. Renewals |
| 32 | * (SID, no CALLBACK) and everything else go to jUPnP unchanged, and jUPnP's |
| 33 | * own checks still run after ours. |
| 34 | */ |
| 35 | internal class PeerOnlySubscriptionsUpnpService( |
| 36 | configuration: UpnpServiceConfiguration, |
| 37 | ) : UpnpServiceImpl(configuration) { |
| 38 | |
| 39 | override fun createProtocolFactory(): ProtocolFactory = |
| 40 | object : ProtocolFactoryImpl(this@PeerOnlySubscriptionsUpnpService) { |
| 41 | override fun createReceivingSubscribe(message: StreamRequestMessage): ReceivingSubscribe = |
| 42 | PeerOnlyReceivingSubscribe(this@PeerOnlySubscriptionsUpnpService, message) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | private class PeerOnlyReceivingSubscribe( |
| 47 | service: org.jupnp.UpnpService, |
| 48 | message: StreamRequestMessage, |
| 49 | ) : ReceivingSubscribe(service, message) { |
| 50 | |
| 51 | override fun executeSync(): OutgoingSubscribeResponseMessage? { |
| 52 | val callbacks = inputMessage.headers[CALLBACK_HEADER] |
| 53 | if (!callbacks.isNullOrEmpty()) { |
| 54 | val peer = inputMessage.connection?.remoteAddress |
| 55 | val reason = callbackRejection(callbacks.joinToString(""), peer) |
| 56 | if (reason != null) { |
| 57 | println("[EarhornUPnP] Refused event subscription: $reason") |
| 58 | return OutgoingSubscribeResponseMessage(UpnpResponse.Status.PRECONDITION_FAILED) |
| 59 | } |
| 60 | } |
| 61 | return super.executeSync() |
| 62 | } |
| 63 | |
| 64 | private companion object { |
| 65 | const val CALLBACK_HEADER = "CALLBACK" |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Why a raw CALLBACK header value (`<url><url>…`) from [peer] must be refused, |
| 71 | * or null when every URL is `http` to [peer]'s own address. Never does a DNS |
| 72 | * lookup. |
| 73 | */ |
| 74 | internal fun callbackRejection(headerValue: String, peer: InetAddress?): String? { |
| 75 | if (peer == null) return "sender address unknown" |
| 76 | val urls = CALLBACK_URL.findAll(headerValue).map { it.groupValues[1].trim() }.toList() |
| 77 | if (urls.isEmpty()) return "no callback address" |
| 78 | for (url in urls) { |
| 79 | val uri = try { |
| 80 | URI(url) |
| 81 | } catch (_: URISyntaxException) { |
| 82 | return "malformed callback address" |
| 83 | } |
| 84 | if (!uri.scheme.equals("http", ignoreCase = true)) return "callback is not http" |
| 85 | val host = uri.host ?: return "callback has no host" |
| 86 | val address = literalAddress(host) ?: return "callback names a host, not an address" |
| 87 | if (!address.address.contentEquals(peer.normalised().address)) { |
| 88 | return "callback points at a different machine than the sender" |
| 89 | } |
| 90 | } |
| 91 | return null |
| 92 | } |
| 93 | |
| 94 | private val CALLBACK_URL = Regex("<([^>]*)>") |
| 95 | private val IPV4_LITERAL = Regex("""(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})""") |
| 96 | |
| 97 | /** [host] as an address if it is an IP literal (IPv6 in brackets, as |
| 98 | * [URI.getHost] gives it), else null. No name resolution either way. */ |
| 99 | private fun literalAddress(host: String): InetAddress? { |
| 100 | IPV4_LITERAL.matchEntire(host)?.let { m -> |
| 101 | val octets = m.groupValues.drop(1).map { it.toInt() } |
| 102 | if (octets.any { it > 255 }) return null |
| 103 | return InetAddress.getByAddress(ByteArray(4) { octets[it].toByte() }) |
| 104 | } |
| 105 | // Bracketed, the JDK parses an IPv6 literal or throws; it never looks it up. |
| 106 | if (host.length > 2 && host.startsWith("[") && host.endsWith("]")) { |
| 107 | return try { |
| 108 | InetAddress.getByName(host).normalised() |
| 109 | } catch (_: UnknownHostException) { |
| 110 | null |
| 111 | } |
| 112 | } |
| 113 | return null |
| 114 | } |
| 115 | |
| 116 | /** An IPv4-mapped IPv6 peer (`::ffff:a.b.c.d`) compares as its IPv4 address. */ |
| 117 | private fun InetAddress.normalised(): InetAddress { |
| 118 | val bytes = address |
| 119 | if (this is Inet6Address && bytes.size == 16 && |
| 120 | (0 until 10).all { bytes[it] == 0.toByte() } && |
| 121 | bytes[10] == 0xff.toByte() && bytes[11] == 0xff.toByte() |
| 122 | ) { |
| 123 | return InetAddress.getByAddress(bytes.copyOfRange(12, 16)) |
| 124 | } |
| 125 | return this |
| 126 | } |


