Home servers over plain HTTP, on the home network only
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m1s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m58s

Ported from release 0.108.43.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 03:51:15 +00:00
co-authored by Claude Opus 5.5
parent 7835703cb2
commit c1d985ed5e
8 changed files with 153 additions and 25 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.software.leanback" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:label="Orbit TV" android:theme="@style/OrbitTheme" android:icon="@drawable/orbit_app_icon" android:banner="@drawable/orbit_banner" android:allowBackup="false" android:usesCleartextTraffic="false" android:supportsRtl="true">
<application android:label="Orbit TV" android:theme="@style/OrbitTheme" android:icon="@drawable/orbit_app_icon" android:banner="@drawable/orbit_banner" android:allowBackup="false" android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="landscape">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LEANBACK_LAUNCHER" /></intent-filter>
</activity>
@@ -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
@@ -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()
@@ -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.")
@@ -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> = { 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"
}
}
@@ -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
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Plain HTTP is permitted at this level only so a home server (Paul, 6 November 2026) can be reached; Android can't name
address ranges here. The app itself allows it for nothing else: HttpsEmbyTransport and SecureMediaDataSource take plain
HTTP only to the signed-in home server, and only while its name leads to private addresses (EmbyServer).
Certificates for HTTPS are the system's, as before.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors><certificates src="system" /></trust-anchors>
</base-config>
</network-security-config>
@@ -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"))
}
}