CVE-2023-38510 (published 2023-07-27T19:15Z)

Tolgee is an open-source localization platform. Starting in version 3.14.0 and prior to version 3.23.1, when a request is made using an API key, the backend fails to verify the permission scopes associated with the key, effectively bypassing permission checks entirely for some endpoints. It's important to note that this vulnerability only affects projects that have inadvertently exposed their API keys on the internet. Projects that have kept their API keys secure are not impacted. This issue is fixed in version 3.23.1.

CVSS v3 base score: 8.1, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-862
cwe_name: Missing Authorization
description: The product does not perform an authorization check when an actor attempts to access a resource or perform an action.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are not applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/tolgee/tolgee-platform
commit_details: repo_url: https://github.com/tolgee/tolgee-platform
author: Jan Cizmar
author_date: 2023-07-21 16:30:34+02:00
msg: fix: Disable importing with any project permission (#1818)
repository: repo_url: https://github.com/tolgee/tolgee-platform
repo_name: tolgee/tolgee-platform
description: Developer & translator friendly web-based localization platform
repo_language: Kotlin
owner: tolgee
file_changes: filename: V2ImportController.kt
change_type: ModificationType.MODIFY
diff: @@ -25,6 +25,7 @@ import io.tolgee.model.Language
 import io.tolgee.model.dataImport.ImportFile
 import io.tolgee.model.dataImport.ImportLanguage
 import io.tolgee.model.dataImport.ImportTranslation
+import io.tolgee.model.enums.Scope
 import io.tolgee.model.views.ImportFileIssueView
 import io.tolgee.model.views.ImportLanguageView
 import io.tolgee.model.views.ImportTranslationView
@@ -36,6 +37,7 @@ import io.tolgee.service.LanguageService
 import io.tolgee.service.dataImport.ForceMode
 import io.tolgee.service.dataImport.ImportService
 import io.tolgee.service.key.NamespaceService
+import io.tolgee.service.security.SecurityService
 import org.springdoc.api.annotations.ParameterObject
 import org.springframework.data.domain.PageRequest
 import org.springframework.data.domain.Pageable
@@ -64,7 +66,10 @@ import javax.servlet.http.HttpServletRequest
 @RestController
 @CrossOrigin(origins = ["*"])
 @RequestMapping(value = ["/v2/projects/{projectId:\\d+}/import", "/v2/projects/import"])
-@Tag(name = "Import")
+@Tag(
+  name = "Import",
+  description = "These endpoints handle multi-step data import"
+)
 class V2ImportController(
   private val importService: ImportService,
   private val authenticationFacade: AuthenticationFacade,
@@ -81,6 +86,7 @@ class V2ImportController(
   private val projectHolder: ProjectHolder,
   private val languageService: LanguageService,
   private val namespaceService: NamespaceService,
+  private val securityService: SecurityService
 ) {
   @PostMapping("", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
   @AccessWithAnyProjectPermission()
@@ -90,6 +96,7 @@ class V2ImportController(
     @RequestPart("files") files: Array<MultipartFile>,
     @ParameterObject params: ImportAddFilesParams
   ): ImportAddFilesResultModel {
+    checkBaseImportPermissions()
     val fileDtos = files.map { ImportFileDto(it.originalFilename ?: "", it.inputStream) }
     val errors = importService.addFiles(
       files = fileDtos,
@@ -122,9 +129,14 @@ class V2ImportController(
     forceMode: ForceMode,
   ) {
     val projectId = projectHolder.project.id
+    checkBaseImportPermissions()
     this.importService.import(projectId, authenticationFacade.userAccount.id, forceMode)
   }
 
+  private fun checkBaseImportPermissions() {
+    securityService.checkProjectPermission(projectHolder.project.id, Scope.TRANSLATIONS_VIEW)
+  }
+
   @GetMapping("/result")
   @AccessWithAnyProjectPermission()
   @AccessWithApiKey()
@@ -132,6 +144,7 @@ class V2ImportController(
   fun getImportResult(
     @ParameterObject pageable: Pageable
   ): PagedModel<ImportLanguageModel> {
+    checkBaseImportPermissions()
     val projectId = projectHolder.project.id
     val userId = authenticationFacade.userAccount.id
     val languages = importService.getResult(projectId, userId, pageable)
@@ -145,6 +158,7 @@ class V2ImportController(
   fun getImportLanguage(
     @PathVariable("languageId") languageId: Long,
   ): ImportLanguageModel {
+    checkBaseImportPermissions()
     checkImportLanguageInProject(languageId)
     val language = importService.findLanguageView(languageId) ?: throw NotFoundException()
     return importLanguageModelAssembler.toModel(language)
@@ -171,6 +185,7 @@ class V2ImportController(
     @RequestParam("search") search: String? = null,
     @ParameterObject @SortDefault("keyName") pageable: Pageable
   ): PagedModel<ImportTranslationModel> {
+    checkBaseImportPermissions()
     checkImportLanguageInProject(languageId)
     val translations = importService.getTranslationsView(languageId, pageable, onlyConflicts, onlyUnresolved, search)
     return pagedTranslationsResourcesAssembler.toModel(translations, importTranslationModelAssembler)
@@ -181,6 +196,7 @@ class V2ImportController(
   @AccessWithApiKey()
   @Operation(description = "Deletes prepared import data.", summary = "Delete")
   fun cancelImport() {
+    checkBaseImportPermissions()
     this.importService.deleteImport(projectHolder.project.id, authenticationFacade.userAccount.id)
   }
 
@@ -189,6 +205,7 @@ class V2ImportController(
   @AccessWithApiKey()
   @Operation(description = "Deletes language prepared to import.", summary = "Delete language")
   fun deleteLanguage(@PathVariable("languageId") languageId: Long) {
+    checkBaseImportPermissions()
     val language = checkImportLanguageInProject(languageId)
     this.importService.deleteLanguage(language)
   }
@@ -204,6 +221,7 @@ class V2ImportController(
     @PathVariable("languageId") languageId: Long,
     @PathVariable("translationId") translationId: Long
   ) {
+    checkBaseImportPermissions()
     resolveTranslation(languageId, translationId, true)
   }
 
@@ -218,6 +236,7 @@ class V2ImportController(
     @PathVariable("languageId") languageId: Long,
     @PathVariable("translationId") translationId: Long
   ) {
+    checkBaseImportPermissions()
     resolveTranslation(languageId, translationId, false)
   }
 
@@ -231,6 +250,7 @@ class V2ImportController(
   fun resolveTranslationSetOverride(
     @PathVariable("languageId") languageId: Long
   ) {
+    checkBaseImportPermissions()
     resolveAllOfLanguage(languageId, true)
   }
 
@@ -244,6 +264,7 @@ class V2ImportController(
   fun resolveTranslationSetKeepExisting(
     @PathVariable("languageId") languageId: Long,
   ) {
+    checkBaseImportPermissions()
     resolveAllOfLanguage(languageId, false)
   }
 
@@ -259,6 +280,7 @@ class V2ImportController(
     @RequestBody req: SetFileNamespaceRequest,
     request: HttpServletRequest
   ) {
+    checkBaseImportPermissions()
     val file = checkFileFromProject(fileId)
     this.importService.selectNamespace(file, req.namespace)
   }
@@ -275,6 +297,7 @@ class V2ImportController(
     @PathVariable("importLanguageId") importLanguageId: Long,
     @PathVariable("existingLanguageId") existingLanguageId: Long,
   ) {
+    checkBaseImportPermissions()
     val existingLanguage = checkLanguageFromProject(existingLanguageId)
     val importLanguage = checkImportLanguageInProject(importLanguageId)
     this.importService.selectExistingLanguage(importLanguage, existingLanguage)
@@ -290,6 +313,7 @@ class V2ImportController(
   fun resetExistingLanguage(
     @PathVariable("importLanguageId") importLanguageId: Long,
   ) {
+    checkBaseImportPermissions()
     val importLanguage = checkImportLanguageInProject(importLanguageId)
     this.importService.selectExistingLanguage(importLanguage, null)
   }
@@ -318,6 +342,7 @@ class V2ImportController(
     summary = "Get namespaces"
   )
   fun getAllNamespaces(): CollectionModel<ImportNamespaceModel> {
+    checkBaseImportPermissions()
     val import = importService.get(
       projectId = projectHolder.project.id,
       authorId = authenticationFacade.userAccount.id

programming_language: Scala
filename: WebSocketConfig.kt
change_type: ModificationType.MODIFY
diff: @@ -52,7 +52,7 @@ class WebSocketConfig(
           if (projectId != null) {
             try {
               val user = (accessor.user as? UsernamePasswordAuthenticationToken)?.principal as UserAccountDto
-              securityService.checkProjectPermission(projectId = projectId, Scope.TRANSLATIONS_VIEW, user)
+              securityService.checkProjectPermissionNoApiKey(projectId = projectId, Scope.TRANSLATIONS_VIEW, user)
             } catch (e: Exception) {
               throw MessagingException("Forbidden")
             }

programming_language: Scala
filename: V2ImportControllerApplicationTest.kt
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,6 @@
 package io.tolgee.api.v2.controllers.v2ImportController
 
+import io.tolgee.ProjectAuthControllerTest
 import io.tolgee.constants.MtServiceType
 import io.tolgee.development.testDataBuilder.data.dataImport.ImportTestData
 import io.tolgee.fixtures.andAssertThatJson
@@ -7,12 +8,13 @@ import io.tolgee.fixtures.andIsForbidden
 import io.tolgee.fixtures.andIsOk
 import io.tolgee.fixtures.isValidId
 import io.tolgee.fixtures.node
-import io.tolgee.testing.AuthorizedControllerTest
+import io.tolgee.model.enums.Scope
+import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod
 import io.tolgee.testing.assert
 import io.tolgee.testing.assertions.Assertions.assertThat
 import org.junit.jupiter.api.Test
 
-class V2ImportControllerApplicationTest : AuthorizedControllerTest() {
+class V2ImportControllerApplicationTest : ProjectAuthControllerTest("/v2/projects/") {
   @Test
   fun `it applies the import`() {
     val testData = ImportTestData()
@@ -83,7 +85,7 @@ class V2ImportControllerApplicationTest : AuthorizedControllerTest() {
     val path = "/v2/projects/$projectId/import/apply?forceMode=OVERRIDE"
     performAuthPut(path, null).andIsForbidden.andAssertThatJson {
       node("params") {
-        node("[0]").isEqualTo("""["keys.edit"]""")
+        node("[0]").isEqualTo(""""keys.create"""")
       }
     }
   }
@@ -128,6 +130,22 @@ class V2ImportControllerApplicationTest : AuthorizedControllerTest() {
     performAuthPut(path, null).andIsForbidden
   }
 
+  @Test
+  @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW])
+  fun `it checks permissions with API key (view only)`() {
+    val testData = ImportTestData()
+    testData.importBuilder.data.importFiles[0].data.importKeys.removeIf { it.self == testData.newLongKey }
+    val resolveFrench = testData.addFrenchTranslations()
+    resolveFrench()
+
+    testDataService.saveTestData(testData.root)
+    projectSupplier = { testData.project }
+    userAccount = testData.userAccount
+
+    val path = "import/apply?forceMode=OVERRIDE"
+    performProjectAuthPut(path, null).andIsForbidden
+  }
+
   @Test
   fun `it sets outdated on update`() {
     val testData = ImportTestData()

programming_language: Scala
filename: KeyControllerCreationTest.kt
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ import io.tolgee.dtos.request.key.KeyScreenshotDto
 import io.tolgee.exceptions.FileStoreException
 import io.tolgee.fixtures.andAssertThatJson
 import io.tolgee.fixtures.andIsCreated
+import io.tolgee.fixtures.andIsForbidden
 import io.tolgee.fixtures.andPrettyPrint
 import io.tolgee.fixtures.isValidId
 import io.tolgee.fixtures.node
@@ -63,7 +64,7 @@ class KeyControllerCreationTest : ProjectAuthControllerTest("/v2/projects/") {
       }
   }
 
-  @ProjectApiKeyAuthTestMethod(scopes = [Scope.KEYS_CREATE])
+  @ProjectApiKeyAuthTestMethod(scopes = [Scope.KEYS_CREATE, Scope.TRANSLATIONS_EDIT])
   @Test
   fun `creates key with keys create scope`() {
     performProjectAuthPost("keys", CreateKeyDto(name = "super_key", translations = mapOf("en" to "", "de" to "")))
@@ -73,6 +74,13 @@ class KeyControllerCreationTest : ProjectAuthControllerTest("/v2/projects/") {
       }
   }
 
+  @ProjectApiKeyAuthTestMethod(scopes = [Scope.KEYS_CREATE])
+  @Test
+  fun `create key with translations require translate permissions`() {
+    performProjectAuthPost("keys", CreateKeyDto(name = "super_key", translations = mapOf("en" to "", "de" to "")))
+      .andIsForbidden
+  }
+
   @ProjectJWTAuthTestMethod
   @Test
   fun `creates key with translations and tags and screenshots`() {

programming_language: Scala
filename: TelemetryServiceTest.kt
change_type: ModificationType.MODIFY
diff: @@ -25,7 +25,7 @@ import org.springframework.web.client.RestTemplate
 @SpringBootTest(
   properties = [
     "tolgee.telemetry.report-period-ms=200",
-    "tolgee.telemetry.enabled=true"
+    "tolgee.telemetry.enabled=false"
   ]
 )
 class TelemetryServiceTest : AbstractSpringTest() {
@@ -39,7 +39,7 @@ class TelemetryServiceTest : AbstractSpringTest() {
 
   @AfterEach
   fun clean() {
-    telemetryProperties.enabled = true
+    telemetryProperties.enabled = false
   }
 
   @Test
@@ -61,6 +61,7 @@ class TelemetryServiceTest : AbstractSpringTest() {
 
   @Test
   fun `reports when enabled`() {
+    telemetryProperties.enabled = true
     val testData = BaseTestData().apply {
       this.root.addProject { name = "bbbb" }.build {
         val en = addEnglish()

programming_language: Scala
filename: application.yaml
change_type: ModificationType.MODIFY
diff: @@ -66,6 +66,7 @@ tolgee:
     enabled: false
   telemetry:
     enabled: false
+    server: http://localhost:8080
 logging:
   level:
     io.tolgee.billing.api.v2.OrganizationInvoicesController: DEBUG

programming_language: CoffeeScript
filename: ImportLanguageRepository.kt
change_type: ModificationType.MODIFY
diff: @@ -68,4 +68,15 @@ interface ImportLanguageRepository : JpaRepository<ImportLanguage, Long> {
             """
   )
   fun findViewById(languageId: Long): Optional<ImportLanguageView>
+
+  @Query(
+    """
+      select distinct il.existingLanguage.id 
+        from ImportLanguage il 
+        join il.file if 
+        where if.import.id = :importId 
+          and il.existingLanguage.id is not null
+    """
+  )
+  fun findAssignedExistingLanguageIds(importId: Long): List<Long>
 }

programming_language: Scala
filename: StoredDataImporter.kt
change_type: ModificationType.MODIFY
diff: @@ -124,7 +124,7 @@ class StoredDataImporter(
   private fun checkKeyPermissions() {
     val isCreatingKey = keysToSave.values.any { it.id == 0L }
     if (isCreatingKey) {
-      securityService.checkProjectPermission(import.project.id, Scope.KEYS_EDIT)
+      securityService.checkProjectPermission(import.project.id, Scope.KEYS_CREATE)
     }
   }
 

programming_language: Scala
filename: SecurityService.kt
change_type: ModificationType.MODIFY
diff: @@ -40,21 +40,17 @@ class SecurityService @Autowired constructor(
       throw PermissionException()
   }
 
-  fun checkProjectPermission(projectId: Long, requiredScopes: Scope, userAccountDto: UserAccountDto) {
-    checkProjectPermissionOr(projectId, listOf(requiredScopes), userAccountDto)
-  }
-
   fun checkProjectPermission(projectId: Long, requiredScopes: Scope, apiKey: ApiKey) {
-    checkProjectPermissionOr(listOf(requiredScopes), apiKey)
+    checkProjectPermission(listOf(requiredScopes), apiKey)
   }
 
-  private fun checkProjectPermissionOr(requiredScopes: List<Scope>, apiKey: ApiKey) {
-    this.checkApiKeyScopesOr(requiredScopes, apiKey)
+  private fun checkProjectPermission(requiredScopes: List<Scope>, apiKey: ApiKey) {
+    this.checkApiKeyScopes(requiredScopes, apiKey)
   }
 
-  fun checkProjectPermissionOr(
+  fun checkProjectPermissionNoApiKey(
     projectId: Long,
-    requiredScopes: Collection<Scope>,
+    requiredScope: Scope,
     userAccountDto: UserAccountDto
   ) {
     if (isUserAdmin(userAccountDto)) {
@@ -64,32 +60,27 @@ class SecurityService @Autowired constructor(
     val allowedScopes = getProjectPermissionScopes(projectId, userAccountDto.id)
       ?: throw PermissionException(Message.USER_HAS_NO_PROJECT_ACCESS)
 
-    checkProjectPermissionOr(projectId, requiredScopes, allowedScopes)
+    checkPermission(requiredScope, allowedScopes)
   }
 
-  fun checkProjectPermissionOr(
-    projectId: Long,
-    requiredScopes: Collection<Scope>,
+  private fun checkPermission(
+    requiredScope: Scope,
     allowedScopes: Array<Scope>
   ) {
-    if (!allowedScopes.any { requiredScopes.contains(it) }) {
+    if (!allowedScopes.contains(requiredScope)) {
       @Suppress("UNCHECKED_CAST")
       throw PermissionException(
         Message.OPERATION_NOT_PERMITTED,
-        listOf(requiredScopes.map { it.value }) as List<Serializable>
+        listOf(requiredScope.value) as List<Serializable>
       )
     }
   }
 
   fun checkProjectPermission(projectId: Long, requiredPermission: Scope) {
-    val apiKey = activeApiKey ?: return checkProjectPermission(projectId, requiredPermission, activeUser)
+    val apiKey = activeApiKey ?: return checkProjectPermissionNoApiKey(projectId, requiredPermission, activeUser)
     return checkProjectPermission(projectId, requiredPermission, apiKey)
   }
 
-  fun checkProjectPermissionOr(projectId: Long, requiredPermissions: Collection<Scope>) {
-    checkProjectPermissionOr(projectId, requiredPermissions, activeUser)
-  }
-
   fun checkLanguageViewPermissionByTag(projectId: Long, languageTags: Collection<String>) {
     checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW)
     checkLanguagePermissionByTag(
@@ -226,9 +217,10 @@ class SecurityService @Autowired constructor(
     }
   }
 
-  fun checkApiKeyScopesOr(scopes: Collection<Scope>, apiKey: ApiKey) {
+  fun checkApiKeyScopes(scopes: Collection<Scope>, apiKey: ApiKey) {
     checkApiKeyScopes(apiKey) { expandedScopes ->
-      if (!expandedScopes.any { it in apiKey.scopesEnum }) {
+      val hasRequiredPermission = scopes.all { expandedScopes.contains(it) }
+      if (!hasRequiredPermission) {
         throw PermissionException()
       }
     }

programming_language: Scala
filename: application.yaml
change_type: ModificationType.MODIFY
diff: @@ -66,6 +66,7 @@ tolgee:
     enabled: false
   telemetry:
     enabled: false
+    server: http://localhost:8080
 
 management:
   endpoint:

programming_language: CoffeeScript