-
Notifications
You must be signed in to change notification settings - Fork 0
fix(olcrtc): validate profile inputs #153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,9 +28,51 @@ package io.nekohasekai.sagernet.fmt.olcrtc | |
| */ | ||
|
|
||
| private const val SCHEME = "olcrtc://" | ||
| private val SUPPORTED_TRANSPORTS = setOf("vp8channel", "datachannel") | ||
| private val SUPPORTED_CARRIERS = setOf("jitsi", "telemost", "wbstream") | ||
| private const val TRANSPORT_VP8 = "vp8channel" | ||
| private const val TRANSPORT_DATA = "datachannel" | ||
| private const val DEFAULT_VP8_FPS = 30 | ||
| private const val DEFAULT_VP8_BATCH = 8 | ||
| private val SUPPORTED_TRANSPORTS_BY_CARRIER = mapOf( | ||
| "jitsi" to setOf(TRANSPORT_VP8, TRANSPORT_DATA), | ||
| "telemost" to setOf(TRANSPORT_VP8), | ||
| "wbstream" to setOf(TRANSPORT_VP8), | ||
| ) | ||
| private val DELIMITERS = setOf('<', '>', '&', '=', '@', '#', '$', '?') | ||
| private val VP8_FPS_RANGE = 1..120 | ||
| private val VP8_BATCH_RANGE = 1..64 | ||
|
|
||
| /** | ||
| * Validates fields shared by URI import/export, the profile editor, and runtime args. | ||
| * Plain upstream URIs do not carry a client id, so callers opt into that requirement. | ||
| */ | ||
| fun OlcrtcBean.validateOlcrtcProfile(requireClientId: Boolean = false) { | ||
| val carrierName = carrier.orEmpty() | ||
| val transportName = transport.orEmpty() | ||
| val room = roomId.orEmpty() | ||
| val identity = clientId.orEmpty() | ||
| val hex = keyHex.orEmpty() | ||
| val fps = vp8Fps ?: 0 | ||
| val batchSize = vp8BatchSize ?: 0 | ||
| val resolver = dnsServer.orEmpty() | ||
|
|
||
| val allowedTransports = SUPPORTED_TRANSPORTS_BY_CARRIER[carrierName] | ||
| require(allowedTransports != null) { "olcRTC: unsupported carrier" } | ||
| require(transportName in allowedTransports) { "olcRTC: transport is not supported by carrier" } | ||
| require(room.isNotBlank()) { "olcRTC: room id / URL is required" } | ||
| if (requireClientId) { | ||
| require(identity.isNotBlank()) { "olcRTC: client id is required" } | ||
| } | ||
| require(hex.length == 64 && hex.all { it.isHexDigit() }) { | ||
| "olcRTC: encryption key must be 64 hex characters" | ||
| } | ||
| if (transportName == TRANSPORT_VP8) { | ||
| require(fps in VP8_FPS_RANGE) { "olcRTC: VP8 FPS must be between 1 and 120" } | ||
| require(batchSize in VP8_BATCH_RANGE) { "olcRTC: VP8 batch size must be between 1 and 64" } | ||
| } | ||
| require(resolver.isBlank() || resolver.isIpPortLiteral()) { | ||
| "olcRTC: DNS resolver must be an IP literal with a valid port" | ||
| } | ||
| } | ||
|
|
||
| /** Parses an `olcrtc://` link into an [OlcrtcBean]. Fails fast on malformed input. */ | ||
| fun parseOlcrtc(url: String): OlcrtcBean { | ||
|
|
@@ -59,10 +101,7 @@ fun parseOlcrtc(url: String): OlcrtcBean { | |
| require(q >= 0) { "invalid olcrtc link: missing '?' before transport" } | ||
| val carrier = body.substring(0, q).trim() | ||
| require(carrier.isNotEmpty()) { "invalid olcrtc link: empty carrier" } | ||
| require(carrier in SUPPORTED_CARRIERS) { | ||
| "olcrtc link unsupported carrier '$carrier' (supported: ${SUPPORTED_CARRIERS.joinToString()})" | ||
| } | ||
| var afterQ = body.substring(q + 1) | ||
| val afterQ = body.substring(q + 1) | ||
|
|
||
| // roomId after the FIRST '@' that is NOT inside the `<...>` payload block. | ||
| val payloadEnd = if (afterQ.startsWith("<") || afterQ.contains('<')) afterQ.indexOf('>') else -1 | ||
|
|
@@ -79,70 +118,84 @@ fun parseOlcrtc(url: String): OlcrtcBean { | |
| if (lt >= 0) { | ||
| val gt = transportPart.indexOf('>', lt) | ||
| require(gt >= 0) { "invalid olcrtc link: unterminated '<...>' transport payload" } | ||
| require(transportPart.substring(gt + 1).isBlank()) { | ||
| "invalid olcrtc link: unexpected text after transport payload" | ||
| } | ||
| payload = transportPart.substring(lt + 1, gt) | ||
| transportPart = transportPart.substring(0, lt) | ||
| } | ||
| val transport = transportPart.trim().ifEmpty { "vp8channel" } | ||
| val transport = transportPart.trim().ifEmpty { TRANSPORT_VP8 } | ||
| val payloadValues = parsePayload(payload) | ||
|
|
||
| return OlcrtcBean().apply { | ||
| // serverAddress/Port are unused by this protocol; keep a stable placeholder. | ||
| serverAddress = "olcrtc" | ||
| this.carrier = carrier | ||
| this.roomId = roomId | ||
| this.keyHex = keyHex | ||
| require(transport in SUPPORTED_TRANSPORTS) { | ||
| "olcrtc link unsupported transport '$transport' (supported: ${SUPPORTED_TRANSPORTS.joinToString()})" | ||
| } | ||
| this.transport = transport | ||
| name = comment | ||
| initializeDefaultValues() | ||
|
|
||
| payloadValues.forEach { (key, value) -> | ||
| when (key) { | ||
| "vp8-fps" -> vp8Fps = value.toIntOrNull() | ||
| ?: throw IllegalArgumentException("olcRTC: VP8 FPS must be an integer") | ||
|
|
||
| "vp8-batch" -> vp8BatchSize = value.toIntOrNull() | ||
| ?: throw IllegalArgumentException("olcRTC: VP8 batch size must be an integer") | ||
|
|
||
| parsePayload(payload).forEach { (k, v) -> | ||
| when (k) { | ||
| "vp8-fps" -> v.toIntOrNull()?.let { vp8Fps = it } | ||
| "vp8-batch" -> v.toIntOrNull()?.let { vp8BatchSize = it } | ||
| // Our non-standard pairing-token carrier. | ||
| "cid", "client-id", "clientid" -> clientId = v | ||
| "cid", "client-id", "clientid" -> { | ||
| require(value.none { it in DELIMITERS }) { | ||
| "olcRTC: client id contains a reserved delimiter" | ||
| } | ||
| clientId = value | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| initializeDefaultValues() | ||
|
|
||
| require(keyHex.isNotBlank()) { "olcrtc link missing encryption key" } | ||
| require(keyHex.length == 64 && keyHex.all { it.isHexDigit() }) { | ||
| "olcrtc link encryption key must be 64 hex characters" | ||
| } | ||
| validateOlcrtcProfile() | ||
| } | ||
| } | ||
|
|
||
| /** Serializes an [OlcrtcBean] to a shareable `olcrtc://` link, carrying clientId as `&cid=`. */ | ||
| fun OlcrtcBean.toUri(): String { | ||
| require(carrier.isNotBlank()) { "olcRTC: cannot build share link without a carrier" } | ||
| require(roomId.isNotBlank()) { "olcRTC: cannot build share link without a room id" } | ||
| validateOlcrtcProfile() | ||
| val carrierName = carrier.orEmpty() | ||
| val transportName = transport.orEmpty() | ||
| val room = roomId.orEmpty() | ||
| val shareClientId = clientId.orEmpty() | ||
| val hex = keyHex.orEmpty() | ||
| val fps = vp8Fps ?: DEFAULT_VP8_FPS | ||
| val batchSize = vp8BatchSize ?: DEFAULT_VP8_BATCH | ||
| val profileName = name.orEmpty() | ||
|
|
||
| // The URI uses bare delimiters with no escaping convention; refuse to emit a link that | ||
| // would not round-trip rather than silently producing a corrupt one. | ||
| require(clientId.none { it in DELIMITERS }) { | ||
| "olcRTC: clientId must not contain any of: ${DELIMITERS.joinToString(" ")}" | ||
| require(shareClientId.none { it in DELIMITERS }) { | ||
| "olcRTC: client id contains a reserved delimiter" | ||
| } | ||
| // roomId is emitted raw before '#'; a '$' in it would be mis-parsed as the comment | ||
| // delimiter on re-import. Refuse rather than emit a link that won't round-trip. | ||
| require(roomId.none { it == '$' }) { "olcRTC: room id must not contain '\$'" } | ||
| require(name.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" } | ||
| require(room.none { it == '$' }) { "olcRTC: room id must not contain '\$'" } | ||
| require(profileName.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" } | ||
|
|
||
| val payloadParts = mutableListOf<String>() | ||
| if (transport == "vp8channel") { | ||
| if (transportName == TRANSPORT_VP8) { | ||
| val defaults = OlcrtcBean().apply { initializeDefaultValues() } | ||
| if (vp8Fps != defaults.vp8Fps) payloadParts += "vp8-fps=$vp8Fps" | ||
| if (vp8BatchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$vp8BatchSize" | ||
| if (fps != defaults.vp8Fps) payloadParts += "vp8-fps=$fps" | ||
| if (batchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$batchSize" | ||
| } | ||
| if (clientId.isNotBlank()) payloadParts += "cid=$clientId" | ||
| if (shareClientId.isNotBlank()) payloadParts += "cid=$shareClientId" | ||
|
|
||
| val payload = if (payloadParts.isEmpty()) "" else "<${payloadParts.joinToString("&")}>" | ||
|
|
||
| val sb = StringBuilder(SCHEME) | ||
| sb.append(carrier).append('?').append(transport).append(payload) | ||
| sb.append('@').append(roomId) | ||
| sb.append('#').append(keyHex) | ||
| if (name.isNotBlank()) sb.append('$').append(name) | ||
| sb.append(carrierName).append('?').append(transportName).append(payload) | ||
| sb.append('@').append(room) | ||
| sb.append('#').append(hex) | ||
| if (profileName.isNotBlank()) sb.append('$').append(profileName) | ||
| return sb.toString() | ||
| } | ||
|
|
||
|
|
@@ -164,23 +217,36 @@ fun OlcrtcBean.buildOlcrtcArgs( | |
| dnsFallback: String, | ||
| readyTimeoutMs: Long, | ||
| ): List<String> { | ||
| require(!carrier.isNullOrBlank()) { "olcRTC: carrier is required" } | ||
| require(!roomId.isNullOrBlank()) { "olcRTC: room id is required" } | ||
| val hex = keyHex ?: "" | ||
| require(hex.length == 64 && hex.all { it.isHexDigit() }) { | ||
| "olcRTC: encryption key must be 64 hex characters" | ||
| validateOlcrtcProfile(requireClientId = true) | ||
| val carrierName = carrier.orEmpty() | ||
| val transportName = transport.orEmpty() | ||
| val room = roomId.orEmpty() | ||
| val identity = clientId.orEmpty() | ||
| val hex = keyHex.orEmpty() | ||
| val fps = if (transportName == TRANSPORT_VP8) { | ||
| vp8Fps ?: DEFAULT_VP8_FPS | ||
| } else { | ||
| DEFAULT_VP8_FPS | ||
| } | ||
| val batchSize = if (transportName == TRANSPORT_VP8) { | ||
| vp8BatchSize ?: DEFAULT_VP8_BATCH | ||
| } else { | ||
| DEFAULT_VP8_BATCH | ||
| } | ||
| val resolver = dnsServer.orEmpty().ifBlank { dnsFallback } | ||
| require(resolver.isIpPortLiteral()) { | ||
| "olcRTC: DNS resolver must be an IP literal with a valid port" | ||
| } | ||
| val transportName = if (transport in SUPPORTED_TRANSPORTS) transport else "vp8channel" | ||
| val args = mutableListOf( | ||
| "-carrier", carrier, | ||
| "-carrier", carrierName, | ||
| "-transport", transportName, | ||
| "-room", roomId, | ||
| "-client-id", clientId ?: "", | ||
| "-room", room, | ||
| "-client-id", identity, | ||
| "-key", hex, | ||
| "-socks-port", port.toString(), | ||
| "-dns", (dnsServer ?: "").ifBlank { dnsFallback }, | ||
| "-vp8-fps", vp8Fps.toString(), | ||
| "-vp8-batch", vp8BatchSize.toString(), | ||
| "-dns", resolver, | ||
| "-vp8-fps", fps.toString(), | ||
| "-vp8-batch", batchSize.toString(), | ||
| "-protect-path", protectPath, | ||
| "-ready-timeout-ms", readyTimeoutMs.toString(), | ||
| ) | ||
|
|
@@ -199,11 +265,20 @@ fun OlcrtcBean.buildOlcrtcArgs( | |
| * may resolve further hosts at runtime; those rely on the sidecar's own protected | ||
| * resolver. ICE candidates are typically raw IPs, so signaling is the common blocker. | ||
| */ | ||
| fun OlcrtcBean.carrierHost(): String? = when (carrier) { | ||
| fun OlcrtcBean.carrierHost(): String? = when (carrier.orEmpty()) { | ||
| "jitsi" -> { | ||
| // roomId is host/room or https://host/room; extract the host. | ||
| val s = roomId.substringAfter("://").trimStart('/') | ||
| s.substringBefore('/').substringBefore(':').ifBlank { null } | ||
| // Mirror upstream's permissive host/room split, but keep bracketed IPv6 intact. | ||
| val room = roomId.orEmpty().trim() | ||
| val authority = room.substringAfter("://", room).trimStart('/').substringBefore('/').trim() | ||
| when { | ||
| authority.isBlank() -> null | ||
| authority.startsWith('[') -> { | ||
| val closingBracket = authority.indexOf(']') | ||
|
Comment on lines
+275
to
+276
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a Jitsi room uses a bracketed IPv6 signaling host, this branch returns the bare literal, such as
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change is needed here. |
||
| if (closingBracket <= 1) null else authority.substring(1, closingBracket) | ||
| } | ||
| authority.count { it == ':' } == 1 -> authority.substringBefore(':').ifBlank { null } | ||
| else -> authority | ||
| } | ||
| } | ||
| "telemost" -> "telemost.yandex.ru" | ||
| "wbstream" -> "stream.wb.ru" | ||
|
|
@@ -212,10 +287,92 @@ fun OlcrtcBean.carrierHost(): String? = when (carrier) { | |
|
|
||
| private fun parsePayload(payload: String): Map<String, String> { | ||
| if (payload.isBlank()) return emptyMap() | ||
| return payload.split('&').mapNotNull { pair -> | ||
| val i = pair.indexOf('=') | ||
| if (i <= 0) null else pair.substring(0, i).trim() to pair.substring(i + 1).trim() | ||
| }.toMap() | ||
| return payload.split('&').associate { pair -> | ||
| val separator = pair.indexOf('=') | ||
| require(separator > 0) { "invalid olcrtc transport payload" } | ||
| val key = pair.substring(0, separator).trim() | ||
| require(key.isNotEmpty()) { "invalid olcrtc transport payload" } | ||
| key to pair.substring(separator + 1).trim() | ||
| } | ||
| } | ||
|
|
||
| private fun String.isIpPortLiteral(): Boolean { | ||
| val (host, port) = if (startsWith('[')) { | ||
| val closingBracket = indexOf(']') | ||
| if (closingBracket <= 1 || closingBracket != lastIndexOf(']')) return false | ||
| if (closingBracket + 1 >= length || this[closingBracket + 1] != ':') return false | ||
| substring(1, closingBracket) to substring(closingBracket + 2) | ||
| } else { | ||
| val separator = indexOf(':') | ||
| if (separator <= 0 || separator != lastIndexOf(':')) return false | ||
| substring(0, separator) to substring(separator + 1) | ||
| } | ||
| if (!port.isValidPort()) return false | ||
| return if (startsWith('[')) host.isIpv6Literal() else host.isIpv4Literal() | ||
| } | ||
|
|
||
| private fun String.isValidPort(): Boolean { | ||
| if (isEmpty() || any { it !in '0'..'9' }) return false | ||
| val value = toIntOrNull() ?: return false | ||
| return value in 1..65535 | ||
| } | ||
|
|
||
| private fun String.isIpv4Literal(): Boolean { | ||
| val octets = split('.') | ||
| return octets.size == 4 && octets.all { octet -> | ||
| if (octet.isEmpty() || octet.any { it !in '0'..'9' }) return@all false | ||
| if (octet.length > 1 && octet.startsWith('0')) return@all false | ||
| val value = octet.toIntOrNull() ?: return@all false | ||
| value in 0..255 | ||
| } | ||
| } | ||
|
|
||
| private fun String.isIpv6Literal(): Boolean { | ||
| val zoneSeparator = indexOf('%') | ||
| val address: String | ||
| if (zoneSeparator >= 0) { | ||
| if (zoneSeparator == 0 || zoneSeparator != lastIndexOf('%')) return false | ||
| val zone = substring(zoneSeparator + 1) | ||
| if (zone.isEmpty() || zone.any { !it.isSafeZoneCharacter() }) return false | ||
| address = substring(0, zoneSeparator) | ||
| } else { | ||
| address = this | ||
| } | ||
| if (':' !in address) return false | ||
|
|
||
| val compression = address.indexOf("::") | ||
| if (compression != address.lastIndexOf("::")) return false | ||
| val left: List<String> | ||
| val right: List<String> | ||
| if (compression >= 0) { | ||
| left = address.substring(0, compression).ipv6Segments() ?: return false | ||
| right = address.substring(compression + 2).ipv6Segments() ?: return false | ||
| if (left.any { '.' in it }) return false | ||
| } else { | ||
| left = address.ipv6Segments() ?: return false | ||
| right = emptyList() | ||
| } | ||
| val segments = left + right | ||
| var groups = 0 | ||
| segments.forEachIndexed { index, segment -> | ||
| if ('.' in segment) { | ||
| if (index != segments.lastIndex || !segment.isIpv4Literal()) return false | ||
| groups += 2 | ||
| } else { | ||
| if (segment.length !in 1..4 || !segment.all { it.isHexDigit() }) return false | ||
| groups += 1 | ||
| } | ||
| } | ||
| return if (compression >= 0) groups < 8 else groups == 8 | ||
| } | ||
|
|
||
| private fun String.ipv6Segments(): List<String>? { | ||
| if (isEmpty()) return emptyList() | ||
| if (startsWith(':') || endsWith(':')) return null | ||
| return split(':').takeIf { segments -> segments.none { it.isEmpty() } } | ||
| } | ||
|
|
||
| private fun Char.isSafeZoneCharacter(): Boolean = | ||
| this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.' | ||
|
|
||
| private fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' | ||
Uh oh!
There was an error while loading. Please reload this page.