CVE-2024-32466 (Kotlin, published 2024-04-18T15:15Z)
Description: value: Tolgee is an open-source localization platform. For the `/v2/projects/translations` and `/v2/projects/{projectId}/translations` endpoints, translation data was returned even when API key was missing `translation.view` scope. However, it was impossible to fetch the data when user was missing this scope. So this is only relevant for API keys generated by users permitted to `translation.view`. This vulnerability is fixed in v3.57.2

Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/tolgee/tolgee-platform
Files changed:
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2InvitationControllerTest.kt
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerViewTest.kt
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerPermissionsTest.kt
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerTest.kt
backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt
backend/data/src/main/kotlin/io/tolgee/service/LanguageService.kt
backend/data/src/main/kotlin/io/tolgee/service/security/ApiKeyService.kt
backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt
backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt
backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt
backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt
backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectApiKeyAuthRequestPerformer.kt
e2e/cypress/e2e/projects/export/export.cy.ts
Commit message: fix: Translation endpoints permission check (#2254)



Vulnerable code:
    val scopes =
      when {
        apiKeyAuthentication -> authenticationFacade.projectApiKey.scopes.toTypedArray()
        else -> computed.scopes
      }
      scopes = scopes,
import io.tolgee.model.Screenshot
  @UseDefaultPermissions // Security: check done internally
    val keysWithScreenshots = getScreenshots(data.map { it.keyId }.toList())

    if (keysWithScreenshots != null) {
      data.content.forEach { it.screenshots = keysWithScreenshots[it.keyId] ?: listOf() }
    }
  private fun getScreenshots(keyIds: Collection<Long>): Map<Long, List<Screenshot>>? {
    if (
      !authenticationFacade.isProjectApiKeyAuth ||
      authenticationFacade.projectApiKey.scopes.contains(Scope.SCREENSHOTS_VIEW)
    ) {
      return screenshotService.getScreenshotsForKeys(keyIds)
    }
    return null
  }

  private fun checkKeyEditScope() {
    securityService.checkProjectPermission(
      projectHolder.project.id,
      Scope.KEYS_EDIT,
    )
  }

    assertThat(permissionService.getProjectPermissionScopes(project.id, newUser)).isNotNull
    val type = permissionService.getProjectPermissionScopes(project.id, newUser)!!
      permissionService.getProjectPermissionScopes(project.id, user)
open class ProjectsControllerTest : ProjectAuthControllerTest("/v2/projects/") {
    permissionService.getProjectPermissionScopes(repo.id, user)
      return hierarchyItems.flatMap { expand(it) }.toTypedArray()
    fun expand(permittedScopes: Array<Scope>): Array<Scope> {
    val canViewTranslations =
      permissionService.getProjectPermissionScopes(projectId, userId)?.contains(Scope.TRANSLATIONS_VIEW) == true
      permissionService.getProjectPermissionScopes(project.id, userAccountId)
  fun getProjectPermissionScopes(
  ) = getProjectPermissionScopes(projectId, userAccount.id)
  fun getProjectPermissionScopes(
import io.tolgee.model.ApiKey
      getProjectPermissionScopes(projectId).isNullOrEmpty() &&
      getProjectPermissionScopes(projectId, userAccountDto.id)
  fun checkProjectPermission(
    projectId: Long,
    requiredPermission: Scope,
  ) {
    // Always check for the current user even if we're using an API key for security reasons.
    // This prevents improper preservation of permissions.
    checkProjectPermissionNoApiKey(projectId, requiredPermission, activeUser)

    val apiKey = activeApiKey ?: return
    checkProjectPermission(projectId, requiredPermission, apiKey)
  }

  fun fixInvalidApiKeyWhenRequired(apiKey: ApiKey) {
    val oldSize = apiKey.scopesEnum.size
    apiKey.scopesEnum.removeIf {
      getProjectPermissionScopes(
        apiKey.project.id,
        apiKey.userAccount.id,
      )?.contains(it) != true
    }
    if (oldSize != apiKey.scopesEnum.size) {
      apiKeyService.save(apiKey)
    }
  }

  fun getProjectPermissionScopes(
    return permissionService.getProjectPermissionScopes(projectId, userId)
    val scopes =
      if (authenticationFacade.isProjectApiKeyAuth) {
        authenticationFacade.projectApiKey.scopes.toTypedArray()
      } else {
        securityService.getProjectPermissionScopes(project.id, userId) ?: emptyArray()
      }
    requiredScopes?.forEach {
      if (!scopes.contains(it)) {
        if (!isAdmin) {
          logger.debug(
            "Rejecting access to proj#{} for user#{} - Insufficient permissions",
            project.id,
            userId,
          )

          throw PermissionException(
            Message.OPERATION_NOT_PERMITTED,
            requiredScopes.map { s -> s.value },
          )
        }

        bypassed = true
        throw PermissionException()
      }

      // Validate scopes set on the key
      requiredScopes?.forEach {
        if (!pak.scopes.contains(it)) {
          logger.debug(
            "Rejecting access to proj#{} for user#{} via pak#{} - Insufficient permissions granted to PAK",
            project.id,
            userId,
            pak.id,
          )

          throw PermissionException(
            Message.OPERATION_NOT_PERMITTED,
            requiredScopes.map { s -> s.value },
          )
        }
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(null)
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_VIEW))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_VIEW))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.MEMBERS_EDIT))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_CREATE, Scope.MEMBERS_EDIT))
  fun `it restricts scopes to the ones set to the API key`() {
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_CREATE, Scope.MEMBERS_EDIT))
    mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/requires-multiple-scopes")).andIsOk
    Mockito.`when`(authenticationFacade.isApiAuthentication).thenReturn(true)
    Mockito.`when`(authenticationFacade.isProjectApiKeyAuth).thenReturn(true)

    mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/requires-multiple-scopes")).andIsForbidden
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getProjectPermissionScopes(1337L, 1337L))
      .thenReturn(arrayOf(Scope.KEYS_VIEW))
    io.tolgee.dtos.response.ApiKeyDTO.fromEntity(
  it('the support arrays switch works', () => {

Fixed code:
      scopes = securityService.getCurrentPermittedScopes(projectIdNotNull).toTypedArray(),
import org.springframework.data.domain.Page
  @RequiresProjectPermissions(scopes = [Scope.KEYS_VIEW]) // Security: check done internally
    addScreenshotsToResponse(data)
  private fun addScreenshotsToResponse(data: Page<KeyWithTranslationsView>) {
    val canViewScreenshots = securityService.currentPermittedScopesContain(Scope.SCREENSHOTS_VIEW)

    if (!canViewScreenshots) {
      return
    }

    val keysWithScreenshots = screenshotService.getScreenshotsForKeys(data.map { it.keyId }.content)

    data.content.forEach { it.screenshots = keysWithScreenshots[it.keyId] ?: listOf() }
  }

    assertThat(permissionService.getProjectPermissionScopesNoApiKey(project.id, newUser)).isNotNull
    val type = permissionService.getProjectPermissionScopesNoApiKey(project.id, newUser)!!
import io.tolgee.fixtures.andIsForbidden
  @Test
  @ProjectApiKeyAuthTestMethod(scopes = [Scope.KEYS_VIEW])
  fun `returns empty translations when api key is missing the translations-view scope`() {
    testData.addKeysViewOnlyUser()
    testDataService.saveTestData(testData.root)
    userAccount = testData.user
    performProjectAuthGet("/translations?sort=id").andIsOk.andAssertThatJson {
      node("_embedded.keys[2].translations.de").isAbsent()
    }
  }

  @ProjectApiKeyAuthTestMethod(scopes = [Scope.BATCH_JOBS_VIEW])
  @Test
  fun `checks keys view scope (missing scope)`() {
    testDataService.saveTestData(testData.root)
    userAccount = testData.user
    performProjectAuthGet("/translations").andIsForbidden
  }

  @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW])
  @Test
  fun `returns no screenshots when screenshot view scope is missing`() {
    testData.addKeysWithScreenshots()
    testDataService.saveTestData(testData.root)
    userAccount = testData.user
    performProjectAuthGet("/translations").andPrettyPrint.andAssertThatJson {
      node("_embedded.keys[3].screenshots").isNull()
      node("_embedded.keys[3].screenshotCount").isEqualTo(2)
    }
  }

      permissionService.getProjectPermissionScopesNoApiKey(project.id, user)
class ProjectsControllerTest : ProjectAuthControllerTest("/v2/projects/") {
    permissionService.getProjectPermissionScopesNoApiKey(repo.id, user)
  PAK_CREATED_FOR_DIFFERENT_PROJECT,
      return hierarchyItems.flatMap { expand(it) }.toSet().toTypedArray()
    fun expand(permittedScopes: Array<Scope>?): Array<Scope> {
      permittedScopes ?: return arrayOf()
    val canViewTranslations = securityService.currentPermittedScopesContain(Scope.TRANSLATIONS_VIEW)
      permissionService.getProjectPermissionScopesNoApiKey(project.id, userAccountId)
  fun getProjectPermissionScopesNoApiKey(
  ) = getProjectPermissionScopesNoApiKey(projectId, userAccount.id)
  fun getProjectPermissionScopesNoApiKey(
import io.tolgee.security.ProjectHolder
  private val projectHolder: ProjectHolder,
      getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() &&
  fun currentPermittedScopesContain(scope: Scope): Boolean {
    return currentPermittedScopesContain(listOf(scope))
  }

  fun currentPermittedScopesContain(scopes: Collection<Scope>?): Boolean {
    scopes ?: return true
    return getCurrentPermittedScopes(projectHolder.project.id).containsAll(scopes)
  }

  /**
   * Returns current permitted scopes, expanded
   */
  fun getCurrentPermittedScopes(projectId: Long): Set<Scope> {
    val projectScopes =
      Scope.expand(
        getProjectPermissionScopesNoApiKey(projectId, authenticationFacade.authenticatedUser.id),
      ).toSet()
    val apiKey = activeApiKey ?: return projectScopes

    return Scope.expand(apiKey.scopes).toSet().intersect(projectScopes.toSet())
  }

  fun checkProjectPermission(
    projectId: Long,
    requiredPermission: Scope,
  ) {
    // Always check for the current user even if we're using an API key for security reasons.
    // This prevents improper preservation of permissions.
    checkProjectPermissionNoApiKey(projectId, requiredPermission, activeUser)

    val apiKey = activeApiKey ?: return
    checkProjectPermission(projectId, requiredPermission, apiKey)
  }

      getProjectPermissionScopesNoApiKey(projectId, userAccountDto.id)
  fun getProjectPermissionScopesNoApiKey(
    return permissionService.getProjectPermissionScopesNoApiKey(projectId, userId)
    val scopes = securityService.getCurrentPermittedScopes(project.id)
    val missingScopes = getMissingScopes(requiredScopes, scopes.toSet())

    if (missingScopes.isNotEmpty()) {
      if (!isAdmin) {
        logger.debug(
          "Rejecting access to proj#{} for user#{} - Insufficient permissions",
          project.id,
          userId,
        )

        throw PermissionException(
          Message.OPERATION_NOT_PERMITTED,
          missingScopes.map { it.value },
        )

      bypassed = true
        throw PermissionException(Message.PAK_CREATED_FOR_DIFFERENT_PROJECT)
  private fun getMissingScopes(
    requiredScopes: Array<Scope>?,
    permittedScopes: Collection<Scope>?,
  ): Set<Scope> {
    val permitted = permittedScopes?.toSet() ?: setOf()
    val required = requiredScopes?.toSet() ?: setOf()
    return required - permitted
  }

    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(mutableSetOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(emptySet())
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_VIEW))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_VIEW))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.MEMBERS_EDIT))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_CREATE, Scope.MEMBERS_EDIT))
  fun `it restricts scopes (multiple scopes)`() {
    mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/requires-multiple-scopes")).andIsForbidden
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_CREATE, Scope.MEMBERS_EDIT))
    mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/requires-multiple-scopes")).andIsOk
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(setOf(Scope.KEYS_CREATE))
    Mockito.`when`(securityService.getCurrentPermittedScopes(1337L))
      .thenReturn(setOf(Scope.KEYS_VIEW))
    ApiKeyDTO.fromEntity(
  it('the support arrays switch works', { retries: { runMode: 5 } }, () => {