diff --git a/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt b/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt index 26b918d9..f16bea56 100644 --- a/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt +++ b/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt @@ -19,6 +19,11 @@ data class ConnectionTarget( const val DEFAULT_HOST = "192.168.1.2" const val DEFAULT_PORT = 9100 const val DEFAULT_LATENCY_MS = 120 + private const val MAX_PAIRING_QUERY_LENGTH = 1024 + private const val MAX_HOST_LENGTH = 253 + private const val MAX_NAME_LENGTH = 128 + private const val MIN_LATENCY_MS = 80 + private const val MAX_LATENCY_MS = 200 /** Brackets a bare IPv6 literal so it is safe to embed in a URL authority. */ internal fun bracketForUrl(host: String): String { @@ -40,14 +45,30 @@ data class ConnectionTarget( } fun fromPairingUri(uri: Uri): ConnectionTarget? { - if (uri.scheme != "shin" || uri.host != "connect") return null - val host = uri.getQueryParameter("host")?.trim().orEmpty() - if (host.isBlank()) return null - val port = uri.getQueryParameter("port")?.toIntOrNull()?.coerceIn(1, 65535) ?: DEFAULT_PORT - val latencyMs = uri.getQueryParameter("latency")?.toIntOrNull()?.coerceIn(80, 200) ?: DEFAULT_LATENCY_MS - val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.toIntOrNull() - ?.coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS) - val name = uri.getQueryParameter("name")?.ifBlank { DEFAULT_NAME } ?: DEFAULT_NAME + val scheme = uri.scheme ?: return null + if (scheme != "shin" && scheme != "openstream") return null + if (uri.encodedAuthority != "connect" || uri.path.orEmpty().isNotEmpty() || uri.fragment != null) return null + if ((uri.encodedQuery?.length ?: 0) > MAX_PAIRING_QUERY_LENGTH) return null + + val host = uri.getQueryParameter("host") ?: return null + if (!isValidPairingHost(host)) return null + + val rawPort = uri.getQueryParameter("port") + val port = rawPort?.let { raw -> + raw.toIntOrNull()?.takeIf { it in 1..65535 } + } ?: if (rawPort == null) DEFAULT_PORT else return null + val rawLatency = uri.getQueryParameter("latency") + val latencyMs = rawLatency?.let { raw -> + raw.toIntOrNull()?.takeIf { it in MIN_LATENCY_MS..MAX_LATENCY_MS } + } ?: if (rawLatency == null) DEFAULT_LATENCY_MS else return null + val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.let { raw -> + raw.toIntOrNull()?.takeIf { + it in StreamConfig.MIN_BITRATE_MBPS..StreamConfig.MAX_BITRATE_MBPS + } ?: return null + } + val rawName = uri.getQueryParameter("name") + if (rawName != null && (rawName.isBlank() || rawName.length > MAX_NAME_LENGTH)) return null + val name = rawName?.ifBlank { DEFAULT_NAME } ?: DEFAULT_NAME return ConnectionTarget( name = name, host = host, @@ -56,5 +77,38 @@ data class ConnectionTarget( bitrateMbps = bitrateMbps, ) } + + private fun isValidPairingHost(host: String): Boolean { + if (host.isEmpty() || host.length > MAX_HOST_LENGTH) return false + if (host.any { it.isWhitespace() || it.isISOControl() || it in "/?#" }) return false + + val ipv6 = when { + host.startsWith('[') && host.endsWith(']') -> host.substring(1, host.length - 1) + '[' in host || ']' in host -> return false + ':' in host -> host + else -> null + } + if (ipv6 != null) { + if (':' !in ipv6) return false + return try { + java.net.URI("http://[$ipv6]/").host != null + } catch (_: java.net.URISyntaxException) { + false + } + } + + val labels = host.split('.') + if (labels.size == 4 && labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) { + return labels.all { label -> label.toIntOrNull()?.let { it in 0..255 } == true } + } + return labels.all { label -> + label.isNotEmpty() && label.length <= 63 && + label.first().isAsciiAlphanumeric() && label.last().isAsciiAlphanumeric() && + label.all { it.isAsciiAlphanumeric() || it == '-' } + } + } + + private fun Char.isAsciiAlphanumeric(): Boolean = + this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' } } diff --git a/android/app/src/test/java/dev/openstream/app/stream/ConnectionTargetTest.kt b/android/app/src/test/java/dev/openstream/app/stream/ConnectionTargetTest.kt index 8365ea6f..1a93c452 100644 --- a/android/app/src/test/java/dev/openstream/app/stream/ConnectionTargetTest.kt +++ b/android/app/src/test/java/dev/openstream/app/stream/ConnectionTargetTest.kt @@ -1,9 +1,79 @@ -package dev.openstream.app.stream +// android.net.Uri.parse is an unmocked Android stub in local JVM tests. Keep +// this test in android.net so its adapter can call Uri's package-private constructor. +package android.net +import android.os.Parcel +import dev.openstream.app.stream.ConnectionTarget import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class ConnectionTargetTest { + @Test + fun publishedOpenStreamPairingUriParses() { + val target = ConnectionTarget.fromPairingUri( + TestUri("openstream://connect?host=192.168.1.20&port=9100&latency=120&name=CAM%20A"), + ) + + assertNotNull(target) + assertEquals(ConnectionTarget("CAM A", "192.168.1.20", 9100, 120), target) + } + + @Test + fun currentShinPairingUriRemainsSupported() { + assertEquals( + ConnectionTarget("CAM A", "192.168.1.20", 9100, 120), + ConnectionTarget.fromPairingUri( + TestUri("shin://connect?host=192.168.1.20&port=9100&latency=120&name=CAM%20A"), + ), + ) + } + + @Test + fun pairingUriAcceptsIpv4HostnameAndIpv6Hosts() { + listOf("192.168.1.20", "obs.local", "2001:db8::1", "[2001:db8::1]").forEach { host -> + val target = ConnectionTarget.fromPairingUri( + TestUri("openstream://connect?host=${encodeQueryValue(host)}"), + ) + assertEquals(host, target?.host) + } + } + + @Test + fun pairingUriRejectsMalformedOrOversizedAuthorityAndInputs() { + val invalidUris = listOf( + "openstream://user@connect?host=192.168.1.20", + "openstream://connect:80?host=192.168.1.20", + "openstream://connect/path?host=192.168.1.20", + "openstream://connect?host=", + "openstream://connect?host=${"h".repeat(254)}", + "openstream://connect?host=bad%20host", + "openstream://connect?host=bad%2Fhost", + "openstream://connect?host=bad%3Fhost", + "openstream://connect?host=bad%23host", + "openstream://connect?host=bad%00host", + "openstream://connect?host=%5Bgg%3A%3A1%5D", + "openstream://connect?host=%5B2001%3Adb8%3A%3A1", + "openstream://connect?host=999.168.1.20", + "openstream://connect?host=192.168.1.20&name=", + "openstream://connect?host=192.168.1.20&name=${"n".repeat(129)}", + "openstream://connect?host=192.168.1.20&x=${"x".repeat(1025)}", + "openstream://connect?host=192.168.1.20&port=0", + "openstream://connect?host=192.168.1.20&port=65536", + "openstream://connect?host=192.168.1.20&port=invalid", + "openstream://connect?host=192.168.1.20&latency=79", + "openstream://connect?host=192.168.1.20&latency=201", + "openstream://connect?host=192.168.1.20&latency=invalid", + ) + + invalidUris.forEach { raw -> + assertNull(raw, ConnectionTarget.fromPairingUri(TestUri(raw))) + } + } + @Test fun srtCallerUrlLeavesIpv4AndHostnameUnchanged() { assertEquals( @@ -36,3 +106,39 @@ class ConnectionTargetTest { ) } } + +private fun encodeQueryValue(value: String): String = + java.net.URLEncoder.encode(value, StandardCharsets.UTF_8) + +private class TestUri(private val raw: String) : Uri() { + private val parsed = java.net.URI(raw) + private val query = parsed.rawQuery.orEmpty().split('&').filter(String::isNotEmpty).associate { item -> + val (key, value) = item.split('=', limit = 2).let { it[0] to it.getOrElse(1) { "" } } + URLDecoder.decode(key, StandardCharsets.UTF_8) to URLDecoder.decode(value, StandardCharsets.UTF_8) + } + + override fun isHierarchical() = true + override fun isRelative() = false + override fun getScheme() = parsed.scheme + override fun getSchemeSpecificPart() = parsed.rawSchemeSpecificPart + override fun getEncodedSchemeSpecificPart() = parsed.rawSchemeSpecificPart + override fun getAuthority() = parsed.authority + override fun getEncodedAuthority() = parsed.rawAuthority + override fun getUserInfo() = parsed.userInfo + override fun getEncodedUserInfo() = parsed.rawUserInfo + override fun getHost() = parsed.host + override fun getPort() = parsed.port + override fun getPath() = parsed.path + override fun getEncodedPath() = parsed.rawPath + override fun getQuery() = parsed.query + override fun getEncodedQuery() = parsed.rawQuery + override fun getFragment() = parsed.fragment + override fun getEncodedFragment() = parsed.rawFragment + override fun getPathSegments() = parsed.path.orEmpty().split('/').filter(String::isNotEmpty) + override fun getLastPathSegment() = getPathSegments().lastOrNull() + override fun getQueryParameter(key: String) = query[key] + override fun toString() = raw + override fun buildUpon(): Uri.Builder = throw UnsupportedOperationException() + override fun describeContents() = 0 + override fun writeToParcel(dest: Parcel, flags: Int) = throw UnsupportedOperationException() +} diff --git a/tests/test_repo_contract.py b/tests/test_repo_contract.py index bc4bc6eb..97aa24b2 100644 --- a/tests/test_repo_contract.py +++ b/tests/test_repo_contract.py @@ -55,7 +55,14 @@ def test_android_connection_target_builds_srt_caller_url_and_pairing_targets() - assert "DEFAULT_PORT = 9100" in target assert "fromDiscoveredDevice" in target assert "fromPairingUri" in target - assert 'uri.scheme != "shin"' in target + assert 'scheme != "shin" && scheme != "openstream"' in target + assert 'uri.encodedAuthority != "connect"' in target + assert "MAX_PAIRING_QUERY_LENGTH" in target + assert "isValidPairingHost(host)" in target + assert "in 1..65535" in target + assert "MIN_LATENCY_MS..MAX_LATENCY_MS" in target + assert "InetAddress" not in target + assert "getByName" not in target assert "val stats: StreamStats" in stream_client assert "AtomicLong" in stream_client assert "accessUnitsSent.incrementAndGet()" in stream_client