diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 26a8851..792939e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ - + diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 2763a7f..450e2b8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -5,7 +5,7 @@ import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URL import java.util.UUID -import javax.net.ssl.HttpsURLConnection +import java.net.HttpURLConnection internal class EmbyCheckException(message: String, val httpStatus: Int? = null, val retryable: Boolean = false) : Exception(message) internal data class SampleItem(val title: String, val type: String, val minutes: Long?, val format: String, val id: String = "", val durationMs: Long? = minutes?.takeIf { it in 1..(Long.MAX_VALUE / 60_000) }?.times(60_000)) @@ -14,7 +14,7 @@ internal class EmbyRequest(val method: String, val path: String, val body: Strin internal class EmbyReply(val status: Int, val body: String) internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply } -/** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */ +/** Network boundary: one fixed origin (HTTPS, or a home server: see [EmbyServer]), standard TLS checks, no redirects or response logging. */ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString(), /** The one server this transport ever talks to, fixed when it's made, so a session never follows a change of server. */ internal val origin: String = EmbyServer.origin) : EmbyTransport { @@ -38,7 +38,8 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI /** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */ fun fetchImage(path: String, token: String): ByteArray? { require(path.startsWith("/") && !path.startsWith("//")) - val connection = URL("$origin/emby$path").openConnection() as HttpsURLConnection + if (runCatching { EmbyServer.checkHomeConnection(origin) }.isFailure) return null + val connection = URL("$origin/emby$path").openConnection() as HttpURLConnection try { connection.instanceFollowRedirects = false connection.connectTimeout = 10_000 @@ -62,7 +63,10 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI override fun execute(request: EmbyRequest): EmbyReply { // Paths come from the protocol below, never from a provider response or a text field. require(request.path.startsWith("/") && !request.path.startsWith("//")) - val connection = URL("$origin/emby${request.path}").openConnection() as HttpsURLConnection + // HTTPS, or plain HTTP to a home server whose name still leads only to private addresses. + require(origin.startsWith("https://") || EmbyServer.isHome(origin)) + EmbyServer.checkHomeConnection(origin) + val connection = URL("$origin/emby${request.path}").openConnection() as HttpURLConnection try { connection.requestMethod = request.method connection.instanceFollowRedirects = false diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 40c4152..e9a57e1 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -632,7 +632,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(Modifier.width(460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Sign in to Emby", color = Ink, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) - // The server's address (release essentials, 6 November 2026): typed in, HTTPS only. + // The server's address (release essentials, 6 November 2026): typed in, HTTPS, or plain HTTP to a home server only. Text("Server address", color = Muted, fontSize = 14.sp) var serverFocused by remember { mutableStateOf(false) } BasicTextField(value = serverText, onValueChange = { if (it.length <= 255) serverText = it.replace("\n", "").trim() }, @@ -647,7 +647,12 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea true } else false }.padding(12.dp)) - Text("For example https://emby.example.com or https://myserver.example.com:8920", color = Muted, fontSize = 11.sp) + Text("For example https://emby.example.com, or a server at home: http://192.168.1.20:8096", color = Muted, fontSize = 11.sp) + // A home server over plain HTTP: say plainly what that means before the password goes. + if (serverText.trim().startsWith("http://", ignoreCase = true)) Text( + if (EmbyServer.normalise(serverText) != null) "Home network only: your sign-in and programmes travel unencrypted over your Wi-Fi. Orbit won't use plain http for anything outside your home." + else "Plain http:// is only for a server on your home network (like 192.168.x.x or name.local). Anywhere else needs https://.", + color = Yellow, fontSize = 11.sp) Text("Username", color = Muted, fontSize = 14.sp) var userFocused by remember { mutableStateOf(false) } BasicTextField(value = username, onValueChange = { if (it.length <= 256) username = it.replace("\n", "") }, @@ -686,7 +691,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val loginName = username.trim() val secret = password.text.toString() val server = EmbyServer.normalise(serverText) - if (server == null) { message = "Enter your server's https:// address, like https://emby.example.com."; return@OrbitButton } + if (server == null) { message = "Enter your server's https:// address, like https://emby.example.com, or http:// with a home-network address."; return@OrbitButton } clearPassword(); keyboard?.hide(); focus.clearFocus() closeSession() val ticket = sessionSlot.ticket() diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index e742f7f..455f895 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -16,14 +16,16 @@ internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourc internal object PlaybackUrls { fun isProvider(uri: URI, server: String = EmbyServer.origin): Boolean { val origin = URI(server) - val port = if (origin.port == -1) 443 else origin.port - return uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (if (uri.port == -1) 443 else uri.port) == port + fun portOf(u: URI) = if (u.port != -1) u.port else if (u.scheme.equals("https", true)) 443 else 80 + return uri.scheme.equals(origin.scheme, true) && uri.host.equals(origin.host, true) && portOf(uri) == portOf(origin) } fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = "", server: String = EmbyServer.origin): String { fun isProvider(uri: URI) = isProvider(uri, server) try { val uri = URI(base).resolve(value) - if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null) + // Plain HTTP only to the home server itself; anywhere else, HTTPS as always. + val secure = uri.scheme.equals("https", true) || (uri.scheme.equals("http", true) && EmbyServer.isHome(server) && isProvider(uri)) + if (!secure || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null) throw EmbyCheckException("This stream does not provide a supported secure media address.") if (!isProvider(uri) && token.isNotEmpty() && (URLDecoder.decode(uri.rawPath, "UTF-8").contains(token) || uri.rawAuthority.contains(token))) throw EmbyCheckException("The provider login cannot be sent to another media host.") diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt index 060f020..817fa7f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt @@ -1,12 +1,18 @@ package tv.orbit.classic +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress import java.net.URI /** - * Which Emby server Orbit talks to (release essentials, Paul, 6 November 2026). It used to be fixed in the app; now it's typed in - * at sign-in and saved with the sign-in, so anyone can use Orbit with their own server. The security rules stay as they were: - * HTTPS only, standard certificate checks, no redirects, and every request goes to exactly this origin. A plain-HTTP server on - * the home network is a separate, later change, because media from private addresses is refused by design. + * Which Emby server Orbit talks to (release essentials, Paul, 6 November 2026). It's typed in at sign-in and saved with the + * sign-in, so anyone can use Orbit with their own server. HTTPS is the default and the rule everywhere, with standard + * certificate checks, no redirects, and every request to exactly this origin. + * + * The one exception is a server on the home network (Paul, 6 November 2026): plain http:// is accepted only for a private + * address (192.168.x.x, 10.x.x.x, 172.16-31.x.x, an IPv6 fd00::/8 address) or a home-network name (.local, .lan, .home.arpa), + * and every connection checks again that the name still leads to a private address. Never a public one. */ internal object EmbyServer { /** Paul's server: what a box signed in before addresses could be typed was always using. */ @@ -15,21 +21,56 @@ internal object EmbyServer { val host: String get() = URI(origin).host val port: Int get() = URI(origin).port + private val homeNames = listOf(".local", ".lan", ".home.arpa") + + /** A private address on a home network: never on the internet. */ + fun isHomeAddress(address: InetAddress): Boolean = when (address) { + is Inet4Address -> address.address.map { it.toInt() and 255 }.let { b -> + b[0] == 10 || (b[0] == 172 && b[1] in 16..31) || (b[0] == 192 && b[1] == 168) } + is Inet6Address -> (address.address[0].toInt() and 0xfe) == 0xfc + else -> false + } + + /** A host that's a home address written out, or a home-network name, as typed. Names are checked again when connecting. */ + fun isHomeHost(host: String): Boolean { + val bare = host.removePrefix("[").removeSuffix("]") + if (homeNames.any { bare.endsWith(it, ignoreCase = true) && bare.length > it.length }) return true + val literal = Regex("\\d{1,3}(\\.\\d{1,3}){3}").matches(bare) || (':' in bare && bare.all { it.isLetterOrDigit() || it == ':' }) + return literal && runCatching { isHomeAddress(InetAddress.getByName(bare)) }.getOrDefault(false) + } + + /** Whether [origin] is a plain-HTTP home server. */ + fun isHome(origin: String) = origin.startsWith("http://") + /** - * The address someone typed, as an origin Orbit will use (`https://host` or `https://host:port`), or null if it isn't one. - * A bare host gets https://. Anything after the host other than a trailing slash or /emby is refused, as are user names, - * queries and plain http. + * Before each connection to a home server: every address its name leads to must be private, so a name that's been pointed + * somewhere else is refused. [resolve] is for tests. + */ + fun checkHomeConnection(origin: String, resolve: (String) -> List = { InetAddress.getAllByName(it).toList() }) { + if (!isHome(origin)) return + val host = URI(origin).host?.removePrefix("[")?.removeSuffix("]") ?: throw EmbyCheckException("That home server address isn't valid.") + val addresses = runCatching { resolve(host) }.getOrElse { throw EmbyCheckException("Your home server could not be found on this network.", retryable = true) } + if (addresses.isEmpty() || !addresses.all(::isHomeAddress)) throw EmbyCheckException("That address isn't on your home network, so Orbit won't use it without https.") + } + + /** + * The address someone typed, as an origin Orbit will use (`https://host[:port]`, or `http://host[:port]` for a home server), + * or null if it isn't one. A bare host gets https://. Anything after the host other than a trailing slash or /emby is + * refused, as are user names, queries, and plain http to anything but a home address. */ fun normalise(typed: String): String? { val text = typed.trim().let { if ("://" in it) it else "https://$it" } if (text.length > 255 || text.any { it.isWhitespace() || it.isISOControl() }) return null val uri = runCatching { URI(text) }.getOrNull() ?: return null - if (!uri.scheme.equals("https", true) || uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme !in setOf("https", "http") || uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() && it.matches(Regex("[a-z0-9.\\-\\[\\]:]+")) } ?: return null + if (scheme == "http" && !isHomeHost(host)) return null val path = uri.rawPath.orEmpty().trimEnd('/') if (path.isNotEmpty() && !path.equals("/emby", true)) return null val port = uri.port if (port != -1 && port !in 1..65535) return null - return "https://$host" + if (port == -1 || port == 443) "" else ":$port" + val usual = if (scheme == "https") 443 else 80 + return "$scheme://$host" + if (port == -1 || port == usual) "" else ":$port" } } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 7c43ca6..da708a0 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -9,14 +9,14 @@ import java.io.InputStream import java.net.InetAddress import java.net.URI import java.net.URL -import javax.net.ssl.HttpsURLConnection +import java.net.HttpURLConnection /** Redirect handling is explicit so provider credentials never follow a stream to a CDN. */ internal class MediaDeliveryException(message: String) : IOException(message) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) internal class SecureMediaDataSource(private val session: EmbySampleSession) : BaseDataSource(true) { - private var connection: HttpsURLConnection? = null + private var connection: HttpURLConnection? = null private var stream: InputStream? = null private var currentUri: Uri? = null private var remaining = C.LENGTH_UNSET.toLong() @@ -26,9 +26,15 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B try { var address = session.mediaUrl(dataSpec.uri.toString()) for (redirect in 0..5) { - val host = URI(address).host - if (InetAddress.getAllByName(host).any { !isPublicMediaAddress(it) }) throw MediaDeliveryException("Media delivery resolved to a non-public address.") - val next = URL(address).openConnection() as HttpsURLConnection + // Media comes from public HTTPS hosts, never from inside the network; the one exception is the home server + // itself, when that's what you signed in to, and only while its name still leads to private addresses. + val home = EmbyServer.isHome(session.origin) && PlaybackUrls.isProvider(URI(address), session.origin) + if (home) try { EmbyServer.checkHomeConnection(session.origin) } catch (_: EmbyCheckException) { throw MediaDeliveryException("The home server isn't on this network.") } + else { + val host = URI(address).host + if (!address.startsWith("https://") || InetAddress.getAllByName(host).any { !isPublicMediaAddress(it) }) throw MediaDeliveryException("Media delivery resolved to a non-public address.") + } + val next = URL(address).openConnection() as HttpURLConnection connection = next next.instanceFollowRedirects = false next.connectTimeout = 10_000 diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f913746 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt b/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt new file mode 100644 index 0000000..d1e71a5 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt @@ -0,0 +1,58 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.net.InetAddress +import java.net.URI + +/** Home servers over plain HTTP (Paul, 6 November 2026): private addresses only, checked on every connection; HTTPS everywhere else. */ +class HomeServerTest { + @After fun reset() { EmbyServer.origin = EmbyServer.DEFAULT } + private fun ip(text: String) = InetAddress.getByName(text) + + @Test fun plainHttpOnlyForAHomeAddress() { + assertEquals("http://192.168.1.20:8096", EmbyServer.normalise("http://192.168.1.20:8096")) + assertEquals("http://10.0.0.5:8096", EmbyServer.normalise("http://10.0.0.5:8096/emby")) + assertEquals("http://172.20.1.2", EmbyServer.normalise("HTTP://172.20.1.2:80/")) + assertEquals("http://nas.local:8096", EmbyServer.normalise("http://NAS.local:8096")) + assertEquals("http://media.home.arpa:8096", EmbyServer.normalise("http://media.home.arpa:8096")) + assertEquals("http://[fd12::5]:8096", EmbyServer.normalise("http://[fd12::5]:8096")) + // Public addresses, ordinary names and the edges of the private ranges: refused. + listOf("http://8.8.8.8:8096", "http://emby.example.com", "http://172.32.0.1", "http://172.15.0.1", "http://192.169.0.1", "http://127.0.0.1:8096", + "http://169.254.1.1", "http://local", "http://example.com.local.evil.com", "http://[2001:db8::1]") + .forEach { assertNull(it, EmbyServer.normalise(it)) } + // A bare address still means HTTPS. + assertEquals("https://192.168.1.20:8096", EmbyServer.normalise("192.168.1.20:8096")) + } + + @Test fun everyConnectionChecksTheNameStillLeadsHome() { + EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("192.168.0.9")) } + assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("192.168.0.9"), ip("93.184.216.34")) } } + assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("93.184.216.34")) } } + assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { emptyList() } } + // HTTPS servers aren't touched by this check. + EmbyServer.checkHomeConnection(EmbyServer.DEFAULT) { throw AssertionError("not resolved") } + } + + @Test fun mediaUsesPlainHttpOnlyToTheHomeServerItself() { + val home = "http://192.168.1.20:8096" + assertTrue(PlaybackUrls.isProvider(URI("$home/emby/Videos/1/stream.mkv"), home)) + assertFalse(PlaybackUrls.isProvider(URI("https://192.168.1.20:8096/emby/Videos/1/stream.mkv"), home)) + assertFalse(PlaybackUrls.isProvider(URI("http://192.168.1.21:8096/v"), home)) + assertEquals("$home/emby/Videos/1/stream.mkv", PlaybackUrls.resolve("/emby/Videos/1/stream.mkv?api_key=t", "$home/emby/", "t", home)) + // Plain HTTP anywhere else is refused, home server or not. + assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("http://cdn.example.com/v.mkv", "$home/emby/", "t", home) } + assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("http://cdn.example.com/v.mkv", "${EmbyServer.DEFAULT}/emby/", "t", EmbyServer.DEFAULT) } + assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("/emby/v.mkv", "http://192.168.1.20:8096/emby/", "t", EmbyServer.DEFAULT) } + // A session on a home server sends its token to that server only. + val session = EmbySampleSession(HttpsEmbyTransport("fixture-device", home), "fixture-token", "fixture-user", EmbySample(emptyList(), 0)) + assertEquals(mapOf("X-Emby-Token" to "fixture-token"), session.mediaHeaders("$home/emby/Videos/1/stream.mkv")) + assertTrue(session.mediaHeaders("http://192.168.1.21:8096/v").isEmpty()) + } + + @Test fun aHomeServerSignInIsSavedAndItsWorkspaceIsItsOwn() { + assertTrue(Workspaces.keyOf("http://192.168.1.20:8096") != Workspaces.keyOf("https://192.168.1.20:8096")) + assertEquals("http://192.168.1.20:8096", EmbyServer.normalise("http://192.168.1.20:8096")) + } +}