diff --git a/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt b/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt index ddf5313e..6f9f11bd 100644 --- a/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt +++ b/src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt @@ -18,6 +18,7 @@ package org.jitsi.jibri import com.fasterxml.jackson.annotation.JsonIgnore +import org.jxmpp.jid.EntityBareJid import java.util.Objects /** @@ -26,12 +27,19 @@ import java.util.Objects * url will be created by joining [baseUrl] and [callName] with a "/". If * set, a list of [urlParams] will be concatenated after the call name with * a "#" in between. + * + * [authorizedRoom] is the room JID which the requester is authorized for. If it is set, the call page must verify + * that the conference it joined is this room. It is an [EntityBareJid] so that the comparison uses XMPP string + * preparation, like the server does, and not the spelling of the name. It is never read from a request body. */ data class CallUrlInfo( val baseUrl: String = "", val callName: String = "", val tenant: String = "", - val urlParams: List = listOf() + val urlParams: List = listOf(), + @JsonIgnore + @get:JsonIgnore + val authorizedRoom: EntityBareJid? = null ) { @get:JsonIgnore val callUrl: String diff --git a/src/main/kotlin/org/jitsi/jibri/Main.kt b/src/main/kotlin/org/jitsi/jibri/Main.kt index 9b5cddc7..0713fc59 100644 --- a/src/main/kotlin/org/jitsi/jibri/Main.kt +++ b/src/main/kotlin/org/jitsi/jibri/Main.kt @@ -42,6 +42,7 @@ import org.jitsi.metaconfig.config import org.jitsi.metaconfig.configSupplier import org.jitsi.utils.logging2.Logger import org.jitsi.utils.logging2.LoggerImpl +import org.jitsi.xmpp.Smack import java.io.File import java.util.concurrent.TimeUnit import kotlin.system.exitProcess @@ -51,6 +52,8 @@ val logger: Logger = LoggerImpl("org.jitsi.jibri.Main") fun main(args: Array) { setupMetaconfigLogger() handleCommandLineArgs(args) + // Before anything creates a JID, so that all JIDs use the same stringprep. + Smack.initialize(useJitsiXmppStringprep = MainConfig.useJitsiJidValidation) logger.info("Jibri starting up with id ${MainConfig.jibriId}") @@ -169,6 +172,10 @@ class MainConfig { "JibriConfig::jibriId" { Config.legacyConfigSource.jibriId!! } "jibri.id".from(Config.configSource) } + + val useJitsiJidValidation: Boolean by config { + "jibri.api.xmpp.use-jitsi-jid-validation".from(Config.configSource) + } } } diff --git a/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt b/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt index c8ad6d37..a4984ea9 100644 --- a/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt +++ b/src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt @@ -60,6 +60,12 @@ import org.jitsi.xmpp.extensions.jibri.JibriIq // TODO: this needs to include usageTimeout data class StartServiceParams( val sessionId: String, + /** + * The call to join. The HTTP API is for the operator of this Jibri, so the room name and the tenant in + * [CallParams.callUrlInfo] are taken as given: they are not checked for the form jitsi-meet uses, and + * [org.jitsi.jibri.CallUrlInfo.authorizedRoom] stays unset, so the call page does not verify the room it + * joined. The XMPP API does both, because there the request comes from a conference participant. + */ val callParams: CallParams, /** * XMPP login information to be used if [RecordingSinkType] is diff --git a/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt b/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt index 94a90fce..47dd42f4 100644 --- a/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt +++ b/src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt @@ -362,6 +362,7 @@ class XmppApi( shouldRetry = true } } catch (e: BadRequestException) { + logger.info("Refusing the start request for room ${startJibriIq.room}: ${e.detail}") rejectBadRequest(startJibriIq, serviceStatusHandler, e) } catch (iq: UnsupportedIqMode) { logger.error("Unsupported IQ mode: ${iq.iqMode}") diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt index 46c62ee0..474f83d5 100644 --- a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPage.kt @@ -3,6 +3,9 @@ package org.jitsi.jibri.selenium.pageobjects import org.jitsi.jibri.CallUrlInfo import org.jitsi.jibri.util.Resolution import org.jitsi.utils.logging2.createLogger +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException import org.openqa.selenium.TimeoutException import org.openqa.selenium.remote.RemoteWebDriver import org.openqa.selenium.support.PageFactory @@ -54,11 +57,52 @@ class AppCallPage(driver: RemoteWebDriver) : AbstractPageObject(driver), CallPag } } - if (result) { - logger.info("Waited $totalTime to join the conference") + if (!result) { + return false } + logger.info("Waited $totalTime to join the conference") + + return url.authorizedRoom == null || isInAuthorizedRoom(url.authorizedRoom) + } - return result + private fun isInAuthorizedRoom(authorizedRoom: EntityBareJid): Boolean { + val result = driver.executeScript( + """ + try { + return { roomJid: APP.conference._room.room.roomjid }; + } catch (e) { + return { error: e.message }; + } + """.trimMargin() + ) as? Map<*, *> + val joinedRoomJid = result?.get("roomJid") as? String + // Compare prepared JIDs, not strings: the room the page joined went through the server's string preparation, + // which for example makes the name lowercase. The domain must match too, so a room with the same name in + // another tenant fails. + val joinedRoom = joinedRoomJid?.let { + try { + JidCreate.entityBareFrom(it) + } catch (e: XmppStringprepException) { + logger.error("The joined room JID '$it' is not a valid JID: ${e.message}") + null + } + } + if (joinedRoom == null) { + logger.error("Cannot read the joined room (${result?.get("error")}), so cannot verify it. Leaving.") + } else if (joinedRoom == authorizedRoom) { + return true + } else { + logger.error("Joined room $joinedRoomJid, but the request is for room $authorizedRoom. Leaving.") + } + try { + if (!leave()) { + logger.error("Failed to leave the room") + } + } catch (e: TimeoutException) { + // leave() times out when other participants stay in the room. We refuse the room either way. + logger.warn("Timed out waiting to be alone in the room after leaving") + } + return false } /** diff --git a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt index 6596764c..0ae13559 100644 --- a/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt +++ b/src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPage.kt @@ -3,6 +3,9 @@ package org.jitsi.jibri.selenium.pageobjects import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.jitsi.jibri.CallUrlInfo import org.jitsi.utils.logging2.createLogger +import org.jxmpp.jid.EntityBareJid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException import org.openqa.selenium.TimeoutException import org.openqa.selenium.remote.RemoteWebDriver import org.openqa.selenium.support.PageFactory @@ -70,7 +73,7 @@ class ExternalAPIPage(driver: RemoteWebDriver) : AbstractPageObject(driver), Cal result } logger.info("Recorder page initialized successfully") - true + url.authorizedRoom == null || isInAuthorizedRoom(url.authorizedRoom) } catch (e: TimeoutException) { logger.error("Failed to join conference: timeout waiting for conferenceJoined event") false @@ -80,6 +83,44 @@ class ExternalAPIPage(driver: RemoteWebDriver) : AbstractPageObject(driver), Cal } } + /** + * The main room's "jid" from getRoomsInfo is our occupant JID when there are no breakout rooms, and the room JID + * when there are. Its bare form is the room JID in both cases. + */ + private fun isInAuthorizedRoom(authorizedRoom: EntityBareJid): Boolean { + val joinedRoomJid = callRecorderApiAsync( + "getRoomsInfo", + """ + api.getRoomsInfo().then(roomsData => { + const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom); + cb(mainRoom?.jid || null); + }).catch(() => cb(null)); + """ + ) as? String + // Compare prepared JIDs, not strings: the room the page joined went through the server's string preparation, + // which for example makes the name lowercase. The domain must match too, so a room with the same name in + // another tenant fails. + val joinedRoom = joinedRoomJid?.let { + try { + JidCreate.from(it).asEntityBareJidIfPossible() + } catch (e: XmppStringprepException) { + logger.error("The joined room JID '$it' is not a valid JID: ${e.message}") + null + } + } + if (joinedRoom == null) { + logger.error("Cannot read the joined room, so cannot verify it. Leaving.") + } else if (joinedRoom == authorizedRoom) { + return true + } else { + logger.error("Joined room $joinedRoomJid, but the request is for room $authorizedRoom. Leaving.") + } + if (!leave()) { + logger.error("Failed to leave the room") + } + return false + } + private fun extractRecorderHtml(): File { val tmpFile = File.createTempFile("jibri-recorder", ".html").apply { deleteOnExit() } val resource = javaClass.getResourceAsStream("/recorder.html") diff --git a/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt b/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt index 1a4d9cb3..2d59c1e4 100644 --- a/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt +++ b/src/main/kotlin/org/jitsi/jibri/util/XmppUtils.kt @@ -18,8 +18,12 @@ package org.jitsi.jibri.util import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.error.BadRequestException import org.jitsi.xmpp.extensions.jibri.JibriIq import org.jxmpp.jid.EntityBareJid +import java.net.URLDecoder +import java.text.Normalizer +import java.util.Locale /** * When we get a start [JibriIq] message, the room is given to us an [EntityBareJid] that we need to translate @@ -31,6 +35,10 @@ fun getCallUrlInfoFromJid( xmppDomain: String, baseUrl: String? ): CallUrlInfo { + val callName = roomJid.localpart.toString() + if (!isBackendSafeRoomName(callName)) { + throw BadRequestException("Room name '$callName' is not in the form jitsi-meet uses") + } try { // The url domain is pulled from the xmpp domain of the connection sending the request var domain = roomJid.domain.toString() @@ -40,8 +48,6 @@ fun getCallUrlInfoFromJid( // Now we need to extract a potential call subdomain, which will be anything that's left in the domain // at this point before the configured xmpp domain. val subdomain = domain.subSequence(0, domain.indexOf(xmppDomain, ignoreCase = true)).trim('.') - // Now just grab the call name - val callName = roomJid.localpart.toString() // The call url is constructed from the baseCallUrl (base-url if any, or the xmpp domain), an optional subdomain, and a callname like so: // https://baseCallUrl/subdomain/callName @@ -51,9 +57,17 @@ fun getCallUrlInfoFromJid( } return when { - subdomain.isEmpty() -> CallUrlInfo(baseCallUrl, callName, "") - else -> CallUrlInfo("$baseCallUrl/$subdomain", callName, subdomain.toString()) + subdomain.isEmpty() -> CallUrlInfo(baseCallUrl, callName, "", authorizedRoom = roomJid) + + else -> { + if (!isBackendSafeTenant(subdomain.toString())) { + throw BadRequestException("Tenant '$subdomain' is not in the form jitsi-meet uses") + } + CallUrlInfo("$baseCallUrl/$subdomain", callName, subdomain.toString(), authorizedRoom = roomJid) + } } + } catch (e: BadRequestException) { + throw e } catch (e: Exception) { throw CallUrlInfoFromJidException( "Unable to extract call url info from Jid $roomJid (stripFromRoomDomain = $stripFromRoomDomain, " + @@ -63,3 +77,58 @@ fun getCallUrlInfoFromJid( } class CallUrlInfoFromJidException(message: String) : Exception(message) + +private const val URI_COMPONENT_UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()" +private val ENCODED_URI_COMPONENT = Regex("(?:[A-Za-z0-9\\-_.!~*'()]|%[0-9A-Fa-f]{2})+") + +// jitsi-meet removes these characters from a URL path segment (_ROOM_EXCLUDE_PATTERN in +// react/features/base/util/uri.ts) before it decodes and normalizes the name. "!", "(", ")", "*" and "'" are +// unreserved for encodeURIComponent, so a round trip through decode and encode alone does not detect them. +private val REMOVED_BY_JITSI_MEET = Regex("[:?#\\[\\]@!$&'()*+,;=>( + """{"baseUrl":"baseUrl","callName":"callName","authorizedRoom":"other"}""" + ) + info.callName shouldBe "callName" + info.authorizedRoom shouldBe null + } + } context("a nullable CallUrlInfo instance") { should("not equal null") { val nullableInfo: CallUrlInfo? = CallUrlInfo("baseUrl", "callName") diff --git a/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt b/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt index a84bf974..05f19141 100644 --- a/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt +++ b/src/test/kotlin/org/jitsi/jibri/KotestProjectConfig.kt @@ -19,8 +19,14 @@ package org.jitsi.jibri import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.extensions.junitxml.JunitXmlReporter +import org.jitsi.xmpp.Smack class KotestProjectConfig : AbstractProjectConfig() { + override suspend fun beforeProject() { + // Use the same JID stringprep as Main does with the default config. + Smack.initialize(useJitsiXmppStringprep = true) + } + override fun extensions(): List = listOf( /* * The JunitXmlReporter writes a junit5 compatible unit test output diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPageTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPageTest.kt new file mode 100644 index 00000000..79fb1cee --- /dev/null +++ b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/AppCallPageTest.kt @@ -0,0 +1,83 @@ +package org.jitsi.jibri.selenium.pageobjects + +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.jitsi.jibri.CallUrlInfo +import org.jxmpp.jid.impl.JidCreate +import org.openqa.selenium.remote.RemoteWebDriver + +internal class AppCallPageTest : ShouldSpec() { + override fun isolationMode(): IsolationMode? = IsolationMode.InstancePerLeaf + + init { + val driver = mockk(relaxed = true) + val page = AppCallPage(driver) + val callUrl = CallUrlInfo( + "https://meet.example.com", + "testroom", + "", + authorizedRoom = JidCreate.entityBareFrom("testroom@conference.meet.example.com") + ) + + every { driver.executeScript(match { it.contains("isJoined()") }) } returns true + every { driver.executeScript(match { it.contains("getParticipants()") }) } returns 1L + + should("return true when the joined room is the authorized room") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("roomJid" to "TestRoom@conference.meet.example.com") + + page.visit(callUrl) shouldBe true + verify(exactly = 0) { driver.executeScript(match { it.contains("_room.leave()") }) } + } + + should("leave and return false when the joined room is not the authorized room") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("roomJid" to "otherroom@conference.meet.example.com") + + page.visit(callUrl) shouldBe false + verify { driver.executeScript(match { it.contains("_room.leave()") }) } + } + + should("leave and return false when the joined room is in another domain") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("roomJid" to "testroom@conference.othertenant.meet.example.com") + + page.visit(callUrl) shouldBe false + verify { driver.executeScript(match { it.contains("_room.leave()") }) } + } + + should("leave and return false when the joined room cannot be read") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("error" to "Cannot read properties of undefined") + + page.visit(callUrl) shouldBe false + verify { driver.executeScript(match { it.contains("_room.leave()") }) } + } + + should("leave and return false when the joined room is not a valid JID") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("roomJid" to "test room@conference.meet.example.com") + + page.visit(callUrl) shouldBe false + verify { driver.executeScript(match { it.contains("_room.leave()") }) } + } + + should("return false when other participants stay in the room after leaving") { + every { driver.executeScript(match { it.contains("roomjid") }) } returns + mapOf("roomJid" to "otherroom@conference.meet.example.com") + every { driver.executeScript(match { it.contains("_room.leave()") }) } returns null + every { driver.executeScript(match { it.contains("getParticipants()") }) } returns 2L + + page.visit(callUrl) shouldBe false + } + + should("not check the room when no room is authorized") { + page.visit(callUrl.copy(authorizedRoom = null)) shouldBe true + verify(exactly = 0) { driver.executeScript(match { it.contains("roomjid") }) } + } + } +} diff --git a/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt index 711862fd..fba34464 100644 --- a/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt +++ b/src/test/kotlin/org/jitsi/jibri/selenium/pageobjects/ExternalAPIPageTest.kt @@ -11,7 +11,9 @@ import io.kotest.matchers.string.shouldContain import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import org.jitsi.jibri.CallUrlInfo +import org.jxmpp.jid.impl.JidCreate import org.openqa.selenium.remote.RemoteWebDriver internal class ExternalAPIPageTest : ShouldSpec() { @@ -38,6 +40,67 @@ internal class ExternalAPIPageTest : ShouldSpec() { page.visit(callUrl) shouldBe false } + context("verifying the joined room") { + val authorizedRoom = JidCreate.entityBareFrom("testroom@conference.meet.example.com") + every { driver.get(any()) } returns Unit + every { driver.executeScript("return window.jibriPageState?.apiError;") } returns null + every { driver.executeScript("return window.jibriPageState?.conferenceJoined === true;") } returns true + + fun joinedRoomJidIs(jid: String?) { + every { + driver.executeAsyncScript(match { it.contains("mainRoom?.jid") }, *anyVararg()) + } returns jid + } + + should("return true when the joined room is the authorized room") { + // Without breakout rooms getRoomsInfo gives the occupant JID. + joinedRoomJidIs("testroom@conference.meet.example.com/abcd1234") + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe true + } + + should("return true when getRoomsInfo gives the bare room JID") { + // With breakout rooms getRoomsInfo gives the room JID. + joinedRoomJidIs("testroom@conference.meet.example.com") + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe true + } + + should("ignore case when it compares the joined room with the authorized room") { + joinedRoomJidIs("TestRoom@Conference.Meet.Example.com/abcd1234") + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe true + } + + should("return false when the joined room is not the authorized room") { + joinedRoomJidIs("otherroom@conference.meet.example.com/abcd1234") + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe false + verify { driver.executeScript(match { it.contains("'hangup'") }, *anyVararg()) } + } + + should("return false when the joined room is in another domain") { + joinedRoomJidIs("testroom@conference.othertenant.meet.example.com/abcd1234") + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe false + verify { driver.executeScript(match { it.contains("'hangup'") }, *anyVararg()) } + } + + should("return false when the joined room is not a valid JID") { + joinedRoomJidIs("test { it.contains("'hangup'") }, *anyVararg()) } + } + + should("return false when the joined room cannot be read") { + joinedRoomJidIs(null) + + page.visit(callUrl.copy(authorizedRoom = authorizedRoom)) shouldBe false + verify { driver.executeScript(match { it.contains("'hangup'") }, *anyVararg()) } + } + } + should("extract room name and pass tenant from URL path") { val t = table( headers("input", "room", "tenant"), diff --git a/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt b/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt index 3a5cf72c..2216d5ff 100644 --- a/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt +++ b/src/test/kotlin/org/jitsi/jibri/util/XmppUtilsTest.kt @@ -17,10 +17,15 @@ package org.jitsi.jibri.util +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.ShouldSpec +import io.kotest.data.forAll +import io.kotest.data.row import io.kotest.matchers.shouldBe import org.jitsi.jibri.CallUrlInfo +import org.jitsi.jibri.error.BadRequestException import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.stringprep.XmppStringprepException class XmppUtilsTest : ShouldSpec() { private val baseDomain = "brian.jitsi.net" @@ -71,6 +76,104 @@ class XmppUtilsTest : ShouldSpec() { getCallUrlInfoFromJid(jid, "conference", conferenceBaseDomain, "") shouldBe expected } } + context("a room jid") { + should("set the authorized room to the room jid") { + val jid = JidCreate.entityBareFrom("my%20room@$baseDomain") + getCallUrlInfoFromJid(jid, "", baseDomain, null).authorizedRoom shouldBe jid + } + should("set the authorized room to the room jid with the tenant") { + val jid = JidCreate.entityBareFrom("roomName@mucdomain.tenant.$baseDomain") + getCallUrlInfoFromJid(jid, "mucdomain.", baseDomain, "").authorizedRoom shouldBe + JidCreate.entityBareFrom("roomname@mucdomain.tenant.$baseDomain") + } + } + context("a room name in the form jitsi-meet uses") { + should("be accepted") { + forAll( + row("roomname"), + row("roomName"), + row("my%20room"), + row("caf%c3%a9"), + row("caf%C3%A9"), + row("a%3fb"), + row("__jicofo-health-check-abc") + ) { name -> + val jid = JidCreate.entityBareFrom("$name@$baseDomain") + getCallUrlInfoFromJid(jid, "", baseDomain, null).callName shouldBe name.lowercase() + } + } + } + context("a room name in a form jitsi-meet does not use") { + should("be refused") { + forAll( + row("myroom?x"), + row("myroom#config.startsilent=true"), + row("%61"), + row("a%2"), + row("room+x"), + row("caf%c3%89"), + row("a%ff"), + row("%ef%ac%81"), + row("café"), + row("."), + row(".."), + row("a%2fb"), + row("a%2Fb"), + // jitsi-meet removes these from the URL path before it decodes the name, so "myroom*" joins + // "myroom". encodeURIComponent keeps them, so a round trip alone does not detect them. + row("myroom*"), + row("myroom!"), + row("myroom()"), + row("myroom%2a") + ) { name -> + val jid = JidCreate.entityBareFrom("$name@$baseDomain") + shouldThrow { + getCallUrlInfoFromJid(jid, "", baseDomain, null) + } + } + } + } + context("a tenant in the form jitsi-meet uses") { + should("be accepted") { + forAll( + row("tenant"), + row("vpaas-magic-cookie-0123456789abcdef"), + row("my_tenant"), + row("caf\u00e9") + ) { tenant -> + val jid = JidCreate.entityBareFrom("room@mucdomain.$tenant.$baseDomain") + getCallUrlInfoFromJid(jid, "mucdomain.", baseDomain, "").tenant shouldBe tenant + } + } + } + context("a tenant with a character that JitsiXmppStringprep refuses") { + should("not parse as a JID") { + // Smack drops such a request when it parses the IQ, so it never reaches getCallUrlInfoFromJid. + forAll( + row("tenant?x"), + row("tenant*") + ) { tenant -> + shouldThrow { + JidCreate.entityBareFrom("myroom@mucdomain.$tenant.$baseDomain") + } + } + } + } + context("a tenant in a form jitsi-meet does not use") { + should("be refused") { + forAll( + row("%61"), + row("x.y"), + row("a%2fb"), + row("caf%c3%a9") + ) { tenant -> + val jid = JidCreate.entityBareFrom("myroom@mucdomain.$tenant.$baseDomain") + shouldThrow { + getCallUrlInfoFromJid(jid, "mucdomain.", baseDomain, "") + } + } + } + } } } }