CVE-2023-50713: Insufficient Granularity of Access Control (CWE-1220)
value: Speckle Server provides server, frontend, 3D viewer, and other JavaScript utilities for the Speckle 3D data platform. A vulnerability in versions prior to 2.17.6 affects users who: authorized an application which requested a 'token write' scope or, using frontend-2, created a Personal Access Token (PAT) with `token write` scope. When creating a new token an agent needs to authorise the request with an existing token (the 'requesting token').  The requesting token is required to have token write scope in order to generate new tokens. However, Speckle server was not verifying that other privileges granted to the new token were not in excess of the privileges of the requesting token. A malicious actor could use a token with only token write scope to subsequently generate further tokens with additional privileges. These privileges would only grant privileges up to the existing privileges of the user. This vulnerability cannot be used to escalate a user's privileges or grant privileges on behalf of other users.

This has been patched as of version 2.17.6. All operators of Speckle servers should upgrade their server to version 2.17.6 or higher. Any users who authorized an application with 'token write' scope, or created a token in frontend-2 with `token write` scope should review existing tokens and permanently revoke any they do not recognize, revoke existing tokens and create new tokens, and review usage of their account for suspicious activity. No known workarounds for this issue exist.
CWE description: The product implements access controls via a policy or other feature with the intention to disable or restrict accesses (reads and/or writes) to assets in a system from untrusted agents. However, implemented access controls lack required granularity, which renders the control policy too broad because it allows accesses from unauthorized agents to the security-sensitive assets.
CVSS v3 base score: 5
Published: 2023-12-14T19:15Z
Repository: https://github.com/specklesystems/speckle-server
Language: TypeScript
Files changed: packages/frontend-2/components/developer-settings/CreateEditApplicationDialog.vue, packages/frontend-2/components/developer-settings/CreateTokenDialog.vue, packages/server/modules/core/errors/user.ts, packages/server/modules/core/graph/resolvers/apitoken.js, packages/server/modules/core/helpers/token.ts, packages/server/modules/core/tests/apitokens.spec.ts
Commit date: 2023-12-11 11:23:03 +0200
Commit message: fix: preventing PATs from creating PATs

Diff: diff --git a/packages/frontend-2/components/developer-settings/CreateEditApplicationDialog.vue b/packages/frontend-2/components/developer-settings/CreateEditApplicationDialog.vue
index 021cc819..bc33b018 100644
--- a/packages/frontend-2/components/developer-settings/CreateEditApplicationDialog.vue
+++ b/packages/frontend-2/components/developer-settings/CreateEditApplicationDialog.vue
@@ -99,13 +99,13 @@ const name = ref('')
 const scopes = ref<typeof applicationScopes.value>([])
 const redirectUrl = ref('')
 const description = ref('')
 
 const applicationScopes = computed(() => {
-  return Object.values(AllScopes).map((value) => ({
-    id: value,
-    text: value
+  return Object.values(allScopes.value).map((value) => ({
+    id: value.name,
+    text: value.name
   }))
 })
 
 const onSubmit = handleSubmit(async (applicationFormValues) => {
   const applicationId = props.application?.id
diff --git a/packages/frontend-2/components/developer-settings/CreateTokenDialog.vue b/packages/frontend-2/components/developer-settings/CreateTokenDialog.vue
index 7b3da183..fcbdeca6 100644
--- a/packages/frontend-2/components/developer-settings/CreateTokenDialog.vue
+++ b/packages/frontend-2/components/developer-settings/CreateTokenDialog.vue
@@ -37,39 +37,40 @@
   </LayoutDialog>
 </template>
 
 <script setup lang="ts">
 import { useMutation } from '@vue/apollo-composable'
-import { AllScopes } from '@speckle/shared'
 import { LayoutDialog, FormSelectBadges } from '@speckle/ui-components'
 import type { TokenFormValues } from '~~/lib/developer-settings/helpers/types'
 import { createAccessTokenMutation } from '~~/lib/developer-settings/graphql/mutations'
 import { isItemSelected, isRequired } from '~~/lib/common/helpers/validation'
 import { useForm } from 'vee-validate'
 import {
   convertThrowIntoFetchResult,
   getFirstErrorMessage
 } from '~~/lib/common/helpers/graphql'
 import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
+import { useServerInfoScopes } from '~/lib/common/composables/serverInfo'
 
 const emit = defineEmits<{
   (e: 'token-created', tokenId: string): void
 }>()
 
+const { scopes: allScopes } = useServerInfoScopes()
 const { mutate: createToken } = useMutation(createAccessTokenMutation)
 const { triggerNotification } = useGlobalToast()
 const { handleSubmit } = useForm<TokenFormValues>()
 
 const isOpen = defineModel<boolean>('open', { required: true })
 
 const name = ref('')
 const scopes = ref<typeof apiTokenScopes.value>([])
 
 const apiTokenScopes = computed(() => {
-  return Object.values(AllScopes).map((value) => ({
-    id: value,
-    text: value
+  return Object.values(allScopes.value).map((value) => ({
+    id: value.name,
+    text: value.name
   }))
 })
 
 const onSubmit = handleSubmit(async (tokenFormValues) => {
   const result = await createToken({
diff --git a/packages/server/modules/core/errors/user.ts b/packages/server/modules/core/errors/user.ts
index 0f674e5a..dac98a93 100644
--- a/packages/server/modules/core/errors/user.ts
+++ b/packages/server/modules/core/errors/user.ts
@@ -7,5 +7,10 @@ export class UserUpdateError extends BaseError {
 
 export class UserValidationError extends BaseError {
   static defaultMessage = 'The user input data is invalid'
   static code = 'USER_VALIDATION_ERROR'
 }
+
+export class TokenCreateError extends BaseError {
+  static code = 'TOKEN_CREATE_ERROR'
+  static defaultMessage = 'An error occurred while creating a token'
+}
diff --git a/packages/server/modules/core/graph/resolvers/apitoken.js b/packages/server/modules/core/graph/resolvers/apitoken.js
index 177fef2d..960bb1af 100644
--- a/packages/server/modules/core/graph/resolvers/apitoken.js
+++ b/packages/server/modules/core/graph/resolvers/apitoken.js
@@ -4,11 +4,11 @@ const { ForbiddenError } = require('apollo-server-express')
 const {
   createPersonalAccessToken,
   revokeToken,
   getUserTokens
 } = require('../../services/tokens')
-const { canCreateToken } = require('@/modules/core/helpers/token')
+const { canCreatePAT } = require('@/modules/core/helpers/token')
 
 /** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */
 const resolvers = {
   Query: {},
   User: {
@@ -21,15 +21,15 @@ const resolvers = {
       return tokens
     }
   },
   Mutation: {
     async apiTokenCreate(parent, args, context) {
-      if (!canCreateToken(context.scopes || [], args.token.scopes)) {
-        throw new ForbiddenError(
-          "You can't create a token with scopes that you don't have"
-        )
-      }
+      canCreatePAT({
+        userScopes: context.scopes || [],
+        tokenScopes: args.token.scopes,
+        strict: true
+      })
 
       return await createPersonalAccessToken(
         context.userId,
         args.token.name,
         args.token.scopes,
diff --git a/packages/server/modules/core/helpers/token.ts b/packages/server/modules/core/helpers/token.ts
index 15aacd02..f6834b73 100644
--- a/packages/server/modules/core/helpers/token.ts
+++ b/packages/server/modules/core/helpers/token.ts
@@ -1,3 +1,35 @@
-export const canCreateToken = (userScopes: string[], tokenScopes: string[]) => {
-  return tokenScopes.every((scope) => userScopes.includes(scope))
+import { TokenCreateError } from '@/modules/core/errors/user'
+import { Scopes } from '@speckle/shared'
+
+export const canCreateToken = (params: {
+  userScopes: string[]
+  tokenScopes: string[]
+  strict?: boolean
+}) => {
+  const { userScopes, tokenScopes, strict } = params
+  const hasAllScopes = tokenScopes.every((scope) => userScopes.includes(scope))
+  if (!hasAllScopes) {
+    if (!strict) return false
+    throw new TokenCreateError(
+      "You can't create a token with scopes that you don't have"
+    )
+  }
+
+  return true
+}
+
+export const canCreatePAT = (params: {
+  userScopes: string[]
+  tokenScopes: string[]
+  strict?: boolean
+}) => {
+  const { tokenScopes, strict } = params
+  if (tokenScopes.includes(Scopes.Tokens.Write)) {
+    if (!strict) return false
+    throw new TokenCreateError(
+      "You can't create a personal access token with the tokens:write scope"
+    )
+  }
+
+  return canCreateToken(params)
 }
diff --git a/packages/server/modules/core/tests/apitokens.spec.ts b/packages/server/modules/core/tests/apitokens.spec.ts
index 14b97829..c388e595 100644
--- a/packages/server/modules/core/tests/apitokens.spec.ts
+++ b/packages/server/modules/core/tests/apitokens.spec.ts
@@ -64,10 +64,38 @@ describe('API Tokens', () => {
         e.message.includes("You can't create a token with scopes that you don't have")
       )
     ).to.be.ok
   })
 
+  it("can't create PAT with tokens:write scope", async () => {
+    const scopes = [Scopes.Profile.Read, Scopes.Tokens.Write]
+    const { data, errors } = await apollo.execute(
+      CreateTokenDocument,
+      {
+        token: {
+          name: 'sometoken',
+          scopes
+        }
+      },
+      {
+        context: {
+          scopes
+        }
+      }
+    )
+
+    expect(data?.apiTokenCreate).to.not.be.ok
+    expect(errors).to.be.ok
+    expect(
+      errors!.find((e) =>
+        e.message.includes(
+          "You can't create a personal access token with the tokens:write scope"
+        )
+      )
+    ).to.be.ok
+  })
+
   describe('without the tokens:write scope', () => {
     const limitedTokenScopes = difference(AllScopes, [Scopes.Tokens.Write])
     let limitedToken: string
 
     before(async () => {

Vulnerable code:   return Object.values(AllScopes).map((value) => ({
    id: value,
    text: value
import { AllScopes } from '@speckle/shared'
  return Object.values(AllScopes).map((value) => ({
    id: value,
    text: value
const { canCreateToken } = require('@/modules/core/helpers/token')
      if (!canCreateToken(context.scopes || [], args.token.scopes)) {
        throw new ForbiddenError(
          "You can't create a token with scopes that you don't have"
        )
      }
export const canCreateToken = (userScopes: string[], tokenScopes: string[]) => {
  return tokenScopes.every((scope) => userScopes.includes(scope))
Fixed code:   return Object.values(allScopes.value).map((value) => ({
    id: value.name,
    text: value.name
import { useServerInfoScopes } from '~/lib/common/composables/serverInfo'
const { scopes: allScopes } = useServerInfoScopes()
  return Object.values(allScopes.value).map((value) => ({
    id: value.name,
    text: value.name

export class TokenCreateError extends BaseError {
  static code = 'TOKEN_CREATE_ERROR'
  static defaultMessage = 'An error occurred while creating a token'
}
const { canCreatePAT } = require('@/modules/core/helpers/token')
      canCreatePAT({
        userScopes: context.scopes || [],
        tokenScopes: args.token.scopes,
        strict: true
      })
import { TokenCreateError } from '@/modules/core/errors/user'
import { Scopes } from '@speckle/shared'

export const canCreateToken = (params: {
  userScopes: string[]
  tokenScopes: string[]
  strict?: boolean
}) => {
  const { userScopes, tokenScopes, strict } = params
  const hasAllScopes = tokenScopes.every((scope) => userScopes.includes(scope))
  if (!hasAllScopes) {
    if (!strict) return false
    throw new TokenCreateError(
      "You can't create a token with scopes that you don't have"
    )
  }

  return true
}

export const canCreatePAT = (params: {
  userScopes: string[]
  tokenScopes: string[]
  strict?: boolean
}) => {
  const { tokenScopes, strict } = params
  if (tokenScopes.includes(Scopes.Tokens.Write)) {
    if (!strict) return false
    throw new TokenCreateError(
      "You can't create a personal access token with the tokens:write scope"
    )
  }

  return canCreateToken(params)
  it("can't create PAT with tokens:write scope", async () => {
    const scopes = [Scopes.Profile.Read, Scopes.Tokens.Write]
    const { data, errors } = await apollo.execute(
      CreateTokenDocument,
      {
        token: {
          name: 'sometoken',
          scopes
        }
      },
      {
        context: {
          scopes
        }
      }
    )

    expect(data?.apiTokenCreate).to.not.be.ok
    expect(errors).to.be.ok
    expect(
      errors!.find((e) =>
        e.message.includes(
          "You can't create a personal access token with the tokens:write scope"
        )
      )
    ).to.be.ok
  })