Home servers over plain HTTP, on the home network only
Ported from release 0.108.43. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user