diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5bd301d..430aef75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## Fixed +### Changes +- Added validation for Client Version header (x-pm-appversion). + +### New Injection +- ApiManagerFactory now needs an extra `ClientVersionValidator` dependency, which is already included in the Network dagger module. + +```kotlin +@Provides +@Singleton +fun provideApiFactory( + ..., + clientVersionValidator: ClientVersionValidator, + apiConnectionListener: ApiConnectionListener? = null, +): ApiManagerFactory { + return ApiManagerFactory( + ..., + clientVersionValidator = clientVersionValidator, + apiConnectionListener = apiConnectionListener, + ) +} + +``` + +### Fixed - Links, outlined buttons and borderless buttons use text-accent color. @@ -21,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.1.5] -## Fixed +### Fixed - Removed Label dependency from Contact modules. - Fix invalid strings resources for Plans. @@ -29,13 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.1.4] -## New +### New - `ProtonSettings` basic composables to build settings screens. - Enable clients to pass additional filtering function for the plans, according to their needs. - Added Room LabelConverters. Please add `LabelConverters` to your `TypeConverters`. -## Fixed +### Fixed - Stop decoding the base64 when decrypting the HashKey (to keep compatibility with drive web & iOS). - Encode the random bytes in base64 when generating the hash key. @@ -44,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.1.3] -## New +### New - Added functions to encrypt with compression to the crypto module and to the KeyHolder's context: `encryptAndSignTextWithCompression` and `encryptAndSignDataWithCompression` diff --git a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/api/CoreExampleApiClient.kt b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/api/CoreExampleApiClient.kt index d5c033aac..3b1b58c49 100644 --- a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/api/CoreExampleApiClient.kt +++ b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/api/CoreExampleApiClient.kt @@ -38,7 +38,7 @@ class CoreExampleApiClient @Inject constructor() : ApiClient { * Client's value for 'x-pm-appversion' header. */ override val appVersionHeader: String - get() = "Android_1.14.0" + get() = "android-mail@$VERSION_NAME" /** * Client's value for 'User-Agent' header. diff --git a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/di/NetworkModule.kt b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/di/NetworkModule.kt index c7d87ab5f..3ce98beeb 100644 --- a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/di/NetworkModule.kt +++ b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/di/NetworkModule.kt @@ -45,6 +45,7 @@ import me.proton.core.network.domain.ApiClient import me.proton.core.network.domain.NetworkManager import me.proton.core.network.domain.NetworkPrefs import me.proton.core.network.domain.client.ClientIdProvider +import me.proton.core.network.domain.client.ClientVersionValidator import me.proton.core.network.domain.client.ExtraHeaderProvider import me.proton.core.network.domain.humanverification.HumanVerificationListener import me.proton.core.network.domain.humanverification.HumanVerificationProvider @@ -112,7 +113,8 @@ class NetworkModule { humanVerificationListener: HumanVerificationListener, missingScopeListener: MissingScopeListener, extraHeaderProvider: ExtraHeaderProvider, - apiConnectionListener: ApiConnectionListener? = null + clientVersionValidator: ClientVersionValidator, + apiConnectionListener: ApiConnectionListener? = null, ): ApiManagerFactory { val certificatePins = if (BuildConfig.USE_DEFAULT_PINS) { NetWorkDataConstants.DEFAULT_SPKI_PINS @@ -147,7 +149,8 @@ class NetworkModule { ) }, extraHeaderProvider = extraHeaderProvider, - apiConnectionListener = apiConnectionListener + apiConnectionListener = apiConnectionListener, + clientVersionValidator = clientVersionValidator, ) } diff --git a/network/dagger/api/network-dagger.api b/network/dagger/api/network-dagger.api index 95bd76ff0..152a61441 100644 --- a/network/dagger/api/network-dagger.api +++ b/network/dagger/api/network-dagger.api @@ -9,6 +9,14 @@ public final class me/proton/core/network/dagger/BuildConfig { public fun ()V } +public final class me/proton/core/network/dagger/NetworkModule_ProvideClientVersionValidatorFactory : dagger/internal/Factory { + public fun (Lme/proton/core/network/dagger/NetworkModule;)V + public static fun create (Lme/proton/core/network/dagger/NetworkModule;)Lme/proton/core/network/dagger/NetworkModule_ProvideClientVersionValidatorFactory; + public synthetic fun get ()Ljava/lang/Object; + public fun get ()Lme/proton/core/network/domain/client/ClientVersionValidator; + public static fun provideClientVersionValidator (Lme/proton/core/network/dagger/NetworkModule;)Lme/proton/core/network/domain/client/ClientVersionValidator; +} + public final class me/proton/core/network/dagger/NetworkModule_ProvideCookieJarFactory : dagger/internal/Factory { public fun (Lme/proton/core/network/dagger/NetworkModule;Ljavax/inject/Provider;)V public static fun create (Lme/proton/core/network/dagger/NetworkModule;Ljavax/inject/Provider;)Lme/proton/core/network/dagger/NetworkModule_ProvideCookieJarFactory; diff --git a/network/dagger/build.gradle.kts b/network/dagger/build.gradle.kts index ab0e2c580..03bf605f7 100644 --- a/network/dagger/build.gradle.kts +++ b/network/dagger/build.gradle.kts @@ -24,6 +24,9 @@ plugins { publishOption.shouldBePublishedAsLib = true dependencies { + implementation( + project(Module.networkDomain) + ) api( project(Module.networkData) ) diff --git a/network/dagger/src/main/kotlin/me/proton/core/network/dagger/NetworkModule.kt b/network/dagger/src/main/kotlin/me/proton/core/network/dagger/NetworkModule.kt index 0b4eef6b8..a7dd90e3d 100644 --- a/network/dagger/src/main/kotlin/me/proton/core/network/dagger/NetworkModule.kt +++ b/network/dagger/src/main/kotlin/me/proton/core/network/dagger/NetworkModule.kt @@ -24,9 +24,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import me.proton.core.network.data.ProtonCookieStore +import me.proton.core.network.data.client.ClientVersionValidatorImpl import me.proton.core.network.data.cookie.DiskCookieStorage import me.proton.core.network.data.cookie.MemoryCookieStorage -import me.proton.core.network.data.ProtonCookieStore +import me.proton.core.network.domain.client.ClientVersionValidator import javax.inject.Singleton @Module @@ -38,4 +40,7 @@ internal class NetworkModule { persistentStorage = DiskCookieStorage(context, ProtonCookieStore.DISK_COOKIE_STORAGE_NAME), sessionStorage = MemoryCookieStorage() ) + + @Provides + fun provideClientVersionValidator(): ClientVersionValidator = ClientVersionValidatorImpl() } diff --git a/network/data/api/network-data.api b/network/data/api/network-data.api index bb71c1a47..18b8dfd0b 100644 --- a/network/data/api/network-data.api +++ b/network/data/api/network-data.api @@ -1,6 +1,6 @@ public final class me/proton/core/network/data/ApiManagerFactory { - public fun (Ljava/lang/String;Lme/proton/core/network/domain/ApiClient;Lme/proton/core/network/domain/client/ClientIdProvider;Lme/proton/core/network/domain/server/ServerTimeListener;Lme/proton/core/network/domain/NetworkManager;Lme/proton/core/network/domain/NetworkPrefs;Lme/proton/core/network/domain/session/SessionProvider;Lme/proton/core/network/domain/session/SessionListener;Lme/proton/core/network/domain/humanverification/HumanVerificationProvider;Lme/proton/core/network/domain/humanverification/HumanVerificationListener;Lme/proton/core/network/domain/scopes/MissingScopeListener;Lme/proton/core/network/data/ProtonCookieStore;Lkotlinx/coroutines/CoroutineScope;[Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function0;Lme/proton/core/network/domain/client/ExtraHeaderProvider;Lme/proton/core/network/domain/serverconnection/ApiConnectionListener;)V - public synthetic fun (Ljava/lang/String;Lme/proton/core/network/domain/ApiClient;Lme/proton/core/network/domain/client/ClientIdProvider;Lme/proton/core/network/domain/server/ServerTimeListener;Lme/proton/core/network/domain/NetworkManager;Lme/proton/core/network/domain/NetworkPrefs;Lme/proton/core/network/domain/session/SessionProvider;Lme/proton/core/network/domain/session/SessionListener;Lme/proton/core/network/domain/humanverification/HumanVerificationProvider;Lme/proton/core/network/domain/humanverification/HumanVerificationListener;Lme/proton/core/network/domain/scopes/MissingScopeListener;Lme/proton/core/network/data/ProtonCookieStore;Lkotlinx/coroutines/CoroutineScope;[Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function0;Lme/proton/core/network/domain/client/ExtraHeaderProvider;Lme/proton/core/network/domain/serverconnection/ApiConnectionListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lme/proton/core/network/domain/ApiClient;Lme/proton/core/network/domain/client/ClientIdProvider;Lme/proton/core/network/domain/server/ServerTimeListener;Lme/proton/core/network/domain/NetworkManager;Lme/proton/core/network/domain/NetworkPrefs;Lme/proton/core/network/domain/session/SessionProvider;Lme/proton/core/network/domain/session/SessionListener;Lme/proton/core/network/domain/humanverification/HumanVerificationProvider;Lme/proton/core/network/domain/humanverification/HumanVerificationListener;Lme/proton/core/network/domain/scopes/MissingScopeListener;Lme/proton/core/network/data/ProtonCookieStore;Lkotlinx/coroutines/CoroutineScope;[Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function0;Lme/proton/core/network/domain/client/ExtraHeaderProvider;Lme/proton/core/network/domain/serverconnection/ApiConnectionListener;Lme/proton/core/network/domain/client/ClientVersionValidator;)V + public synthetic fun (Ljava/lang/String;Lme/proton/core/network/domain/ApiClient;Lme/proton/core/network/domain/client/ClientIdProvider;Lme/proton/core/network/domain/server/ServerTimeListener;Lme/proton/core/network/domain/NetworkManager;Lme/proton/core/network/domain/NetworkPrefs;Lme/proton/core/network/domain/session/SessionProvider;Lme/proton/core/network/domain/session/SessionListener;Lme/proton/core/network/domain/humanverification/HumanVerificationProvider;Lme/proton/core/network/domain/humanverification/HumanVerificationListener;Lme/proton/core/network/domain/scopes/MissingScopeListener;Lme/proton/core/network/data/ProtonCookieStore;Lkotlinx/coroutines/CoroutineScope;[Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function0;Lme/proton/core/network/domain/client/ExtraHeaderProvider;Lme/proton/core/network/domain/serverconnection/ApiConnectionListener;Lme/proton/core/network/domain/client/ClientVersionValidator;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun create (Lme/proton/core/network/domain/session/SessionId;Lkotlin/reflect/KClass;Ljava/util/List;[Ljava/lang/String;Ljava/util/List;)Lme/proton/core/network/domain/ApiManager; public static synthetic fun create$default (Lme/proton/core/network/data/ApiManagerFactory;Lme/proton/core/network/domain/session/SessionId;Lkotlin/reflect/KClass;Ljava/util/List;[Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lme/proton/core/network/domain/ApiManager; public final fun getBaseOkHttpClient ()Lokhttp3/OkHttpClient; @@ -81,6 +81,11 @@ public final class me/proton/core/network/data/client/ClientIdProviderImpl : me/ public fun getClientId (Lme/proton/core/network/domain/session/SessionId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class me/proton/core/network/data/client/ClientVersionValidatorImpl : me/proton/core/network/domain/client/ClientVersionValidator { + public fun ()V + public fun validate (Ljava/lang/String;)Z +} + public final class me/proton/core/network/data/client/ExtraHeaderProviderImpl : me/proton/core/network/domain/client/ExtraHeaderProvider { public fun ()V public fun addHeaders ([Lkotlin/Pair;)V diff --git a/network/data/src/main/kotlin/me/proton/core/network/data/ApiManagerFactory.kt b/network/data/src/main/kotlin/me/proton/core/network/data/ApiManagerFactory.kt index c00b7918e..ebc8f5fa4 100644 --- a/network/data/src/main/kotlin/me/proton/core/network/data/ApiManagerFactory.kt +++ b/network/data/src/main/kotlin/me/proton/core/network/data/ApiManagerFactory.kt @@ -36,6 +36,7 @@ import me.proton.core.network.domain.DohProvider import me.proton.core.network.domain.NetworkManager import me.proton.core.network.domain.NetworkPrefs import me.proton.core.network.domain.client.ClientIdProvider +import me.proton.core.network.domain.client.ClientVersionValidator import me.proton.core.network.domain.client.ExtraHeaderProvider import me.proton.core.network.domain.handlers.ApiConnectionHandler import me.proton.core.network.domain.handlers.HumanVerificationInvalidHandler @@ -84,7 +85,8 @@ class ApiManagerFactory( private val alternativeApiPins: List = Constants.ALTERNATIVE_API_SPKI_PINS, private val cache: () -> Cache? = { null }, private val extraHeaderProvider: ExtraHeaderProvider? = null, - private val apiConnectionListener: ApiConnectionListener? + private val apiConnectionListener: ApiConnectionListener?, + private val clientVersionValidator: ClientVersionValidator, ) { @OptIn(ObsoleteCoroutinesApi::class) @@ -103,6 +105,9 @@ class ApiManagerFactory( require(apiClient.timeoutSeconds >= ApiClient.MIN_TIMEOUT_SECONDS) { "Minimum timeout for ApiClient is ${ApiClient.MIN_TIMEOUT_SECONDS} seconds." } + require(clientVersionValidator.validate(apiClient.appVersionHeader)) { + "Invalid app version code: ${apiClient.appVersionHeader}." + } OkHttpClient.Builder() .cache(cache()) .connectTimeout(apiClient.timeoutSeconds, TimeUnit.SECONDS) diff --git a/network/data/src/main/kotlin/me/proton/core/network/data/client/ClientVersionValidatorImpl.kt b/network/data/src/main/kotlin/me/proton/core/network/data/client/ClientVersionValidatorImpl.kt new file mode 100644 index 000000000..7129cda33 --- /dev/null +++ b/network/data/src/main/kotlin/me/proton/core/network/data/client/ClientVersionValidatorImpl.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ + +package me.proton.core.network.data.client + +import me.proton.core.network.domain.client.ClientVersionValidator + +class ClientVersionValidatorImpl : ClientVersionValidator { + + override fun validate(versionName: String?): Boolean { + val components = versionName?.split("@").orEmpty() + if (components.count() != 2) return false + val (name, version) = components + return isValidName(name) && isValidVersion(version) + } + + private fun isValidName(name: String) = + Regex("^[a-z_]+-[a-z_]+(?:-[a-z_]+)?\$").matches(name) + + private fun isValidVersion(version: String) = + Regex("^\\d+?\\.\\d+?\\.\\d+?(-((stable|RC|beta|alpha)(\\.\\d+)?|dev)|)?(\\+[0-9A-Za-z\\-]+)?\$") + .matches(version) + +} diff --git a/network/data/src/test/java/me/proton/core/network/data/ApiManagerTests.kt b/network/data/src/test/java/me/proton/core/network/data/ApiManagerTests.kt index f78275b4b..55de0e8ad 100644 --- a/network/data/src/test/java/me/proton/core/network/data/ApiManagerTests.kt +++ b/network/data/src/test/java/me/proton/core/network/data/ApiManagerTests.kt @@ -48,6 +48,7 @@ import me.proton.core.network.domain.NetworkStatus import me.proton.core.network.domain.ResponseCodes import me.proton.core.network.domain.client.ClientId import me.proton.core.network.domain.client.ClientIdProvider +import me.proton.core.network.domain.client.ClientVersionValidator import me.proton.core.network.domain.handlers.RefreshTokenHandler import me.proton.core.network.domain.humanverification.HumanVerificationListener import me.proton.core.network.domain.humanverification.HumanVerificationProvider @@ -105,6 +106,9 @@ internal class ApiManagerTests { @MockK private lateinit var dohService: DohService + @MockK + private lateinit var clientVersionValidator: ClientVersionValidator + private var time = 0L private var wallTime = 0L @@ -144,7 +148,8 @@ internal class ApiManagerTests { mockk(), scope, cache = { null }, - apiConnectionListener = null + apiConnectionListener = null, + clientVersionValidator = clientVersionValidator, ) coEvery { dohService.getAlternativeBaseUrls(any()) } returns listOf(proxy1url) diff --git a/network/data/src/test/java/me/proton/core/network/data/DohProviderTests.kt b/network/data/src/test/java/me/proton/core/network/data/DohProviderTests.kt index c945faa4c..480204c78 100644 --- a/network/data/src/test/java/me/proton/core/network/data/DohProviderTests.kt +++ b/network/data/src/test/java/me/proton/core/network/data/DohProviderTests.kt @@ -24,6 +24,7 @@ import io.mockk.impl.annotations.MockK import kotlinx.coroutines.runBlocking import me.proton.core.network.data.doh.DnsOverHttpsProviderRFC8484 import me.proton.core.network.data.util.MockApiClient +import me.proton.core.network.data.util.takeRequestWithDefaultTimeout import me.proton.core.network.domain.NetworkManager import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse @@ -93,6 +94,7 @@ internal class DohProviderTests { val result = dohProvider.getAlternativeBaseUrls("https://$domain/")!! assertEquals(listOf("https://proxy.com/"), result) - assertEquals("application/dns-message", webServer.takeRequest().headers["Accept"]) + val acceptHeader = webServer.takeRequestWithDefaultTimeout()?.headers?.get("Accept") + assertEquals("application/dns-message", acceptHeader) } } diff --git a/network/data/src/test/java/me/proton/core/network/data/HumanVerificationTests.kt b/network/data/src/test/java/me/proton/core/network/data/HumanVerificationTests.kt index 870d7dd66..1b25f39a8 100644 --- a/network/data/src/test/java/me/proton/core/network/data/HumanVerificationTests.kt +++ b/network/data/src/test/java/me/proton/core/network/data/HumanVerificationTests.kt @@ -37,12 +37,14 @@ import me.proton.core.network.data.util.MockSessionListener import me.proton.core.network.data.util.TestRetrofitApi import me.proton.core.network.data.util.TestTLSHelper import me.proton.core.network.data.util.prepareResponse +import me.proton.core.network.data.util.takeRequestWithDefaultTimeout import me.proton.core.network.domain.ApiManager import me.proton.core.network.domain.ApiResult import me.proton.core.network.domain.NetworkManager import me.proton.core.network.domain.NetworkPrefs import me.proton.core.network.domain.client.ClientId import me.proton.core.network.domain.client.ClientIdProvider +import me.proton.core.network.domain.client.ClientVersionValidator import me.proton.core.network.domain.client.CookieSessionId import me.proton.core.network.domain.humanverification.HumanVerificationDetails import me.proton.core.network.domain.humanverification.HumanVerificationListener @@ -126,6 +128,9 @@ internal class HumanVerificationTests { private val humanVerificationProvider = mockk() private val humanVerificationListener = mockk() private val missingScopeListener = mockk(relaxed = true) + private val clientVersionValidator = mockk { + every { validate(any()) } returns true + } private var sessionListener: SessionListener = MockSessionListener( onTokenRefreshed = { session -> this.session = session } @@ -169,7 +174,8 @@ internal class HumanVerificationTests { cookieJar, scope, cache = { null }, - apiConnectionListener = null + apiConnectionListener = null, + clientVersionValidator = clientVersionValidator, ) every { networkManager.isConnectedToNetwork() } returns isNetworkAvailable @@ -200,7 +206,7 @@ internal class HumanVerificationTests { pinningInit, ::javaWallClockMs, prefs, - cookieJar + cookieJar, ) @After @@ -321,15 +327,15 @@ internal class HumanVerificationTests { coEvery { humanVerificationProvider.getHumanVerificationDetails(clientId) } returns humanVerificationDetails backend(ApiManager.Call(0) { test() }) - val headers = webServer.takeRequest().headers + val headers = webServer.takeRequestWithDefaultTimeout()?.headers verify(exactly = 1) { humanVerificationDetails.tokenCode } verify(exactly = 1) { humanVerificationDetails.tokenType } - assertTrue(headers.contains(Pair("x-pm-human-verification-token-type", "captcha"))) - assertTrue(headers.contains(Pair("x-pm-human-verification-token", "captcha token"))) + assertTrue(headers?.contains(Pair("x-pm-human-verification-token-type", "captcha")) ?: false) + assertTrue(headers?.contains(Pair("x-pm-human-verification-token", "captcha token")) ?: false) } @Test @@ -360,15 +366,15 @@ internal class HumanVerificationTests { coEvery { humanVerificationProvider.getHumanVerificationDetails(clientId) } returns humanVerificationDetails backend(ApiManager.Call(0) { test() }) - val headers = webServer.takeRequest().headers + val headers = webServer.takeRequestWithDefaultTimeout()?.headers verify(exactly = 1) { humanVerificationDetails.tokenCode } verify(exactly = 1) { humanVerificationDetails.tokenType } - assertTrue(headers.contains(Pair("x-pm-human-verification-token-type", "captcha"))) - assertTrue(headers.contains(Pair("x-pm-human-verification-token", "captcha token"))) + assertTrue(headers?.contains(Pair("x-pm-human-verification-token-type", "captcha")) ?: false) + assertTrue(headers?.contains(Pair("x-pm-human-verification-token", "captcha token")) ?: false) } private fun javaWallClockMs(): Long = System.currentTimeMillis() diff --git a/network/data/src/test/java/me/proton/core/network/data/ProtonApiBackendTests.kt b/network/data/src/test/java/me/proton/core/network/data/ProtonApiBackendTests.kt index 2849b6eff..5ffeb6782 100644 --- a/network/data/src/test/java/me/proton/core/network/data/ProtonApiBackendTests.kt +++ b/network/data/src/test/java/me/proton/core/network/data/ProtonApiBackendTests.kt @@ -35,12 +35,14 @@ import me.proton.core.network.data.util.MockSessionListener import me.proton.core.network.data.util.TestRetrofitApi import me.proton.core.network.data.util.TestTLSHelper import me.proton.core.network.data.util.prepareResponse +import me.proton.core.network.data.util.takeRequestWithDefaultTimeout import me.proton.core.network.domain.ApiManager import me.proton.core.network.domain.ApiResult import me.proton.core.network.domain.NetworkManager import me.proton.core.network.domain.NetworkPrefs import me.proton.core.network.domain.client.ClientId import me.proton.core.network.domain.client.ClientIdProvider +import me.proton.core.network.domain.client.ClientVersionValidator import me.proton.core.network.domain.client.ExtraHeaderProvider import me.proton.core.network.domain.humanverification.HumanVerificationDetails import me.proton.core.network.domain.humanverification.HumanVerificationListener @@ -91,6 +93,9 @@ internal class ProtonApiBackendTests { private val humanVerificationProvider = mockk() private val humanVerificationListener = mockk() private val missingScopeListener = mockk(relaxed = true) + private val clientVersionValidator = mockk { + every { validate(any()) } returns true + } private var sessionListener: SessionListener = MockSessionListener( onTokenRefreshed = { session -> this.session = session } @@ -136,7 +141,8 @@ internal class ProtonApiBackendTests { cookieJar, scope, cache = { null }, - apiConnectionListener = null + apiConnectionListener = null, + clientVersionValidator = clientVersionValidator, ) every { networkManager.isConnectedToNetwork() } returns isNetworkAvailable @@ -249,7 +255,7 @@ internal class ProtonApiBackendTests { webServer.prepareResponse(HttpURLConnection.HTTP_OK, "plain") val result = backend(ApiManager.Call(0) { testPlain() }) - assertEquals("text/plain", webServer.takeRequest().headers["Accept"]) + assertEquals("text/plain", webServer.takeRequestWithDefaultTimeout()?.headers?.get("Accept")) assertTrue(result is ApiResult.Success) assertEquals("plain", result.value) @@ -384,8 +390,8 @@ internal class ProtonApiBackendTests { backend(ApiManager.Call(0) { test() }) - val request = webServer.takeRequest() - val headerFound = request.headers.any { it == extraHeader } + val request = webServer.takeRequestWithDefaultTimeout() + val headerFound = request?.headers?.any { it == extraHeader } ?: false Assert.assertTrue(headerFound) } } diff --git a/network/data/src/test/java/me/proton/core/network/data/client/ClientVersionValidatorTests.kt b/network/data/src/test/java/me/proton/core/network/data/client/ClientVersionValidatorTests.kt new file mode 100644 index 000000000..bf21e58de --- /dev/null +++ b/network/data/src/test/java/me/proton/core/network/data/client/ClientVersionValidatorTests.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ + +package me.proton.core.network.data.client + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClientVersionValidatorTests { + + private val validator = ClientVersionValidatorImpl() + + @Test + fun `Null version will fail validation`() { + val version: String? = null + assertFalse { validator.validate(version) } + } + + @Test + fun `Empty version will fail validation`() { + val version = "" + assertFalse { validator.validate(version) } + } + + @Test + fun `Only version number info will fail validation`() { + val version = "1.2.3" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with invalid characters in will fail validation`() { + val version = "ANDROID-Mail@1.2.3" // Should be lowercase + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with invalid separators will fail validation`() { + val version = "android_mail_1.2.3" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with valid section info will be validated`() { + val version = "android-mail-somesection_info@1.2.3" + assertTrue { validator.validate(version) } + } + + @Test + fun `Version code with no section info will be validated`() { + val version = "android-mail@1.2.3" + assertTrue { validator.validate(version) } + } + + @Test + fun `Version code with invalid characters in section info will fail validation`() { + val version = "android-mail-somesection.info@1.2.3" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with empty section info but separator will fail validation`() { + val version = "android-mail-@1.2.3" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with empty version metadata info but separator will fail validation`() { + val version = "android-mail@1.2.3+" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with empty version identifier info but separator will fail validation`() { + val version = "android-mail@1.2.3-" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with invalid characters in version metadata will fail validation`() { + val version = "android-mail@1.2.3+some.metadata" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with versioned dev identifier version will fail validation`() { + val version = "android-mail@1.2.3-dev.1" + assertFalse { validator.validate(version) } + } + + @Test + fun `Version code with only dev identifier version will pass validation`() { + val version = "android-mail@1.2.3-dev" + assertTrue { validator.validate(version) } + } + + @Test + fun `Version code with versioned stable, RC, beta or alpha identifiers will pass validation`() { + val versionWithStableVersion = "android-mail@1.2.3-stable.1" + val versionWithAlphaVersion = "android-mail@1.2.3-alpha.1" + val versionWithRCVersion = "android-mail@1.2.3-RC.1" + val versionWithBetaVersion = "android-mail@1.2.3-beta.1" + assertTrue { validator.validate(versionWithStableVersion) } + assertTrue { validator.validate(versionWithAlphaVersion) } + assertTrue { validator.validate(versionWithRCVersion) } + assertTrue { validator.validate(versionWithBetaVersion) } + } + + @Test + fun `Version code with both version identifier and metadata will pass validation`() { + val version = "android-mail@1.2.3-stable.1+some-metadata" + assertTrue { validator.validate(version) } + } + +} diff --git a/network/data/src/test/java/me/proton/core/network/data/util/MockClient.kt b/network/data/src/test/java/me/proton/core/network/data/util/MockClient.kt index 6ff44ac78..9eb45d49b 100644 --- a/network/data/src/test/java/me/proton/core/network/data/util/MockClient.kt +++ b/network/data/src/test/java/me/proton/core/network/data/util/MockClient.kt @@ -66,7 +66,7 @@ class MockApiClient : ApiClient { var forceUpdated = false override var shouldUseDoh = true - override val appVersionHeader = "TestApp_1.0" + override val appVersionHeader = "android-mail@1.2.3" override val userAgent = "Test/1.0 (Android 10; brand model)" override val enableDebugLogging: Boolean = true diff --git a/network/data/src/test/java/me/proton/core/network/data/util/TestUtils.kt b/network/data/src/test/java/me/proton/core/network/data/util/TestUtils.kt index 860cbf2aa..2ac1a979c 100644 --- a/network/data/src/test/java/me/proton/core/network/data/util/TestUtils.kt +++ b/network/data/src/test/java/me/proton/core/network/data/util/TestUtils.kt @@ -24,6 +24,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import java.io.File import java.security.KeyStore +import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager @@ -37,6 +38,8 @@ fun MockWebServer.prepareResponse(code: Int, body: String = "") { enqueue(response) } +fun MockWebServer.takeRequestWithDefaultTimeout() = takeRequest(10, TimeUnit.MILLISECONDS) + class TestTLSHelper { var trustManagers: Array diff --git a/network/domain/api/network-domain.api b/network/domain/api/network-domain.api index 4666070c5..1fa603670 100644 --- a/network/domain/api/network-domain.api +++ b/network/domain/api/network-domain.api @@ -329,6 +329,10 @@ public final class me/proton/core/network/domain/client/ClientIdType$Companion { public final fun getMap ()Ljava/util/Map; } +public abstract interface class me/proton/core/network/domain/client/ClientVersionValidator { + public abstract fun validate (Ljava/lang/String;)Z +} + public final class me/proton/core/network/domain/client/CookieSessionId { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; diff --git a/network/domain/src/main/kotlin/me/proton/core/network/domain/client/ClientVersionValidator.kt b/network/domain/src/main/kotlin/me/proton/core/network/domain/client/ClientVersionValidator.kt new file mode 100644 index 000000000..c0790de9b --- /dev/null +++ b/network/domain/src/main/kotlin/me/proton/core/network/domain/client/ClientVersionValidator.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ + +package me.proton.core.network.domain.client + +interface ClientVersionValidator { + fun validate(versionName: String?): Boolean +} diff --git a/report/data/src/main/kotlin/me/proton/core/report/data/repository/ReportRepositoryImpl.kt b/report/data/src/main/kotlin/me/proton/core/report/data/repository/ReportRepositoryImpl.kt index a5bc07453..2acaae439 100644 --- a/report/data/src/main/kotlin/me/proton/core/report/data/repository/ReportRepositoryImpl.kt +++ b/report/data/src/main/kotlin/me/proton/core/report/data/repository/ReportRepositoryImpl.kt @@ -30,7 +30,9 @@ import me.proton.core.report.domain.entity.BugReportMeta import me.proton.core.report.domain.repository.ReportRepository import javax.inject.Inject -public class ReportRepositoryImpl @Inject constructor(private val apiProvider: ApiProvider) : ReportRepository { +public class ReportRepositoryImpl @Inject constructor( + private val apiProvider: ApiProvider, +) : ReportRepository { override suspend fun sendReport( bugReport: BugReport, meta: BugReportMeta, diff --git a/report/data/src/test/kotlin/me/proton/core/report/data/repository/ReportRepositoryImplTest.kt b/report/data/src/test/kotlin/me/proton/core/report/data/repository/ReportRepositoryImplTest.kt index c3434c514..1ae586dde 100644 --- a/report/data/src/test/kotlin/me/proton/core/report/data/repository/ReportRepositoryImplTest.kt +++ b/report/data/src/test/kotlin/me/proton/core/report/data/repository/ReportRepositoryImplTest.kt @@ -53,7 +53,7 @@ internal class ReportRepositoryImplTest { email = "email@test" ) private val testBugReportMeta = BugReportMeta( - appVersionName = "1.2.3", + appVersionName = "android-mail@1.2.3", clientName = "TestApp", osName = "Android", osVersion = "12", diff --git a/report/data/src/test/kotlin/me/proton/core/report/data/test_data.kt b/report/data/src/test/kotlin/me/proton/core/report/data/test_data.kt index 1eaca84b4..22ffc74cd 100644 --- a/report/data/src/test/kotlin/me/proton/core/report/data/test_data.kt +++ b/report/data/src/test/kotlin/me/proton/core/report/data/test_data.kt @@ -30,7 +30,7 @@ internal val testBugReport = BugReport( email = "test@email" ) internal val testBugReportMeta = BugReportMeta( - appVersionName = "1.2.3", + appVersionName = "android-mail@1.2.3", clientName = "TestApp", osName = "Android", osVersion = "100",