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

PeerOnlySubscriptions.kt

126 lines · 5.2 KB

Changed in: 0.5.0

1package dev.stave.upnp
2
3import org.jupnp.UpnpServiceConfiguration
4import org.jupnp.UpnpServiceImpl
5import org.jupnp.model.message.StreamRequestMessage
6import org.jupnp.model.message.UpnpResponse
7import org.jupnp.model.message.gena.OutgoingSubscribeResponseMessage
8import org.jupnp.protocol.ProtocolFactory
9import org.jupnp.protocol.ProtocolFactoryImpl
10import org.jupnp.protocol.sync.ReceivingSubscribe
11import java.net.Inet6Address
12import java.net.InetAddress
13import java.net.URI
14import java.net.URISyntaxException
15import 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 */
35internal 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
46private 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 */
74internal 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
94private val CALLBACK_URL = Regex("<([^>]*)>")
95private 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. */
99private 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. */
117private 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}