VYPR
High severityNVD Advisory· Published Mar 10, 2026· Updated Mar 10, 2026

StudioCMS: IDOR — Arbitrary API Token Revocation Leading to Denial of Service

CVE-2026-30945

Description

StudioCMS is a server-side-rendered, Astro native, headless content management system. Prior to 0.4.0, the DELETE /studiocms_api/dashboard/api-tokens endpoint allows any authenticated user with editor privileges or above to revoke API tokens belonging to any other user, including admin and owner accounts. The handler accepts tokenID and userID directly from the request payload without verifying token ownership, caller identity, or role hierarchy. This enables targeted denial of service against critical integrations and automations. This vulnerability is fixed in 0.4.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
studiocmsnpm
< 0.4.00.4.0

Affected products

1

Patches

1
9eec9c3b4552

feat(api): add admin API token revocation endpoint and correct permission ranks order (#1440)

https://github.com/withstudiocms/studiocmsAdam MatthiesenMar 7, 2026via ghsa
11 files changed · +169 114
  • .changeset/eighty-forks-attend.md+6 0 added
    @@ -0,0 +1,6 @@
    +---
    +"@withstudiocms/api-spec": minor
    +"studiocms": minor
    +---
    +
    +Introduces new admin level api token revocation endpoint
    
  • .changeset/sweet-jars-remain.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +"@withstudiocms/auth-kit": patch
    +---
    +
    +Corrects permission ranks order
    
  • packages/studiocms/frontend/pages/[dashboard]/user-management/edit.astro+44 54 modified
    @@ -530,61 +530,51 @@ setDataContext(Astro, {
             button.addEventListener('click', async (event) => {
                 event.preventDefault();
     
    -            // TODO: Implement Admin level API token revocation that allows revoking any token, not just tokens owned by the user. This will require additional permission checks to ensure only Admin users can revoke tokens that they do not own.
    -
    -            toast({
    -              title: 'Error',
    -              description: 'Not Currently implemented',
    -              type: 'danger',
    -              duration: 5000,
    -            });
    -            return;
    -
    -            // const tokenID = button.getAttribute('data-token');
    -            // const userID = button.getAttribute('data-user');
    +            const tokenID = button.getAttribute('data-token');
    +            const userID = button.getAttribute('data-user');
                 
    -            // if (!tokenID || !userID) {
    -            //     toast({
    -            //         title: 'Error',
    -            //         description: 'Missing necessary data to revoke API token.',
    -            //         type: 'danger',
    -            //         duration: 5000
    -            //     })
    -            //     return;
    -            // }
    -
    -            // const response = await dashboardClient.pipe(
    -            //   Effect.flatMap((client) =>
    -            //     client.apiTokens.revokeApiToken({
    -            //       payload: {
    -            //         tokenID,
    -            //         userID
    -            //       }
    -            //     })
    -            //   ),
    -            //   Effect.catchTags(dashboardSharedCatchTags),
    -            //   Effect.runPromise
    -            // );
    -
    -            // if ('error' in response) {
    -            //   toast({
    -            //     title: 'Error',
    -            //     description: response.error,
    -            //     type: 'danger',
    -            //     duration: 5000
    -            //   })
    -            //   return;
    -            // } else {
    -            //   toast({
    -            //     title: 'Success',
    -            //     description: response.message || 'API token revoked successfully.',
    -            //     type: 'success',
    -            //     duration: 5000
    -            //   })
    -            //   setTimeout(() => {
    -            //     window.location.reload();
    -            //   }, 1000);
    -            // }
    +            if (!tokenID || !userID) {
    +                toast({
    +                    title: 'Error',
    +                    description: 'Missing necessary data to revoke API token.',
    +                    type: 'danger',
    +                    duration: 5000
    +                })
    +                return;
    +            }
    +
    +            const response = await dashboardClient.pipe(
    +              Effect.flatMap((client) =>
    +                client.apiTokens.adminRevokeUserApiToken({
    +                  payload: {
    +                    tokenID,
    +                    userID
    +                  }
    +                })
    +              ),
    +              Effect.catchTags(dashboardSharedCatchTags),
    +              Effect.runPromise
    +            );
    +
    +            if ('error' in response) {
    +              toast({
    +                title: 'Error',
    +                description: response.error,
    +                type: 'danger',
    +                duration: 5000
    +              })
    +              return;
    +            } else {
    +              toast({
    +                title: 'Success',
    +                description: response.message || 'API token revoked successfully.',
    +                type: 'success',
    +                duration: 5000
    +              })
    +              setTimeout(() => {
    +                window.location.reload();
    +              }, 1000);
    +            }
             });
         });
       </script>
    
  • packages/studiocms/frontend/pages/studiocms_api/_handlers/dashboard/apiTokens.ts+44 2 modified
    @@ -5,11 +5,10 @@ import { HttpApiBuilder } from '@effect/platform';
     import { StudioCMSDashboardApiSpec } from '@withstudiocms/api-spec';
     import { CurrentUser } from '@withstudiocms/api-spec/astro-context';
     import { DashboardAPIError } from '@withstudiocms/api-spec/dashboard';
    +import { availablePermissionRanks } from '@withstudiocms/auth-kit/types';
     import { Effect } from 'effect';
     import { sharedDBErrors } from './_shared.js';
     
    -// TODO: Implement Admin level API token revocation that allows revoking any token, not just tokens owned by the user. This will require additional permission checks to ensure only Admin users can revoke tokens that they do not own.
    -
     /**
      * Check if the Dashboard API is enabled in the route configuration.
      */
    @@ -84,6 +83,49 @@ export const ApiTokensHandler = HttpApiBuilder.group(
     
     					yield* sdk.REST_API.tokens.delete({ tokenId: tokenID, userId: userData.user.id });
     
    +					return {
    +						message: 'Token deleted',
    +					};
    +				}, Effect.catchTags(sharedDBErrors))
    +			)
    +			.handle(
    +				'adminRevokeUserApiToken',
    +				Effect.fn(function* ({ payload: { tokenID, userID } }) {
    +					if (!dashboardAPIEnabled) {
    +						return yield* new DashboardAPIError({ error: 'Dashboard API is disabled' });
    +					}
    +
    +					if (developerConfig.demoMode !== false) {
    +						return yield* new DashboardAPIError({
    +							error: 'Demo mode is enabled, this action is not allowed.',
    +						});
    +					}
    +
    +					const [sdk, userData] = yield* Effect.all([SDKCore, CurrentUser]);
    +
    +					const isAuthorized = userData.userPermissionLevel.isAdmin;
    +
    +					if (!userData.isLoggedIn || !isAuthorized) {
    +						return yield* new DashboardAPIError({ error: 'Unauthorized' });
    +					}
    +
    +					const tokenData = yield* sdk.REST_API.tokens.verify(tokenID);
    +
    +					if (!tokenData) {
    +						return yield* new DashboardAPIError({ error: 'Token not found' });
    +					}
    +
    +					const targetPerms = availablePermissionRanks.indexOf(tokenData.rank);
    +					const userPerms = availablePermissionRanks.indexOf(userData.permissionLevel);
    +
    +					if (targetPerms >= userPerms) {
    +						return yield* new DashboardAPIError({
    +							error: 'Unauthorized - insufficient permissions to revoke this token',
    +						});
    +					}
    +
    +					yield* sdk.REST_API.tokens.delete({ tokenId: tokenID, userId: userID });
    +
     					return {
     						message: 'Token deleted',
     					};
    
  • packages/studiocms/frontend/pages/studiocms_api/_handlers/dashboard/create.ts+10 16 modified
    @@ -9,7 +9,7 @@ import { HttpApiBuilder } from '@effect/platform';
     import { StudioCMSDashboardApiSpec } from '@withstudiocms/api-spec';
     import { AstroAPIContext, CurrentUser } from '@withstudiocms/api-spec/astro-context';
     import { DashboardAPIError } from '@withstudiocms/api-spec/dashboard';
    -import type { AvailablePermissionRanks } from '@withstudiocms/auth-kit/types';
    +import { availablePermissionRanks } from '@withstudiocms/auth-kit/types';
     import { appendSearchParamsToUrl } from '@withstudiocms/effect/effect';
     import type { APIContext } from 'astro';
     import { Effect, pipe } from 'effect';
    @@ -22,17 +22,6 @@ import { sharedDBErrors, sharedNotifierErrors } from './_shared.js';
      */
     const dashboardAPIEnabled = routeConfig.dashboardAPIEnabled;
     
    -/**
    - * Array of available permission levels for the REST API. This is used to check user permissions when accessing secure endpoints, ensuring that only users with the appropriate rank can perform certain actions. The ranks are defined in ascending order of permissions, with 'unknown' having the least permissions and 'owner' having the most.
    - */
    -const permissionLevels: AvailablePermissionRanks[] = [
    -	'unknown',
    -	'visitor',
    -	'editor',
    -	'admin',
    -	'owner',
    -];
    -
     /**
      * Type definition for the token object returned when creating a password reset link. This includes the token ID, the user ID it is associated with, and the token string itself. This type is used to ensure that the correct data structure is returned and handled when generating password reset links for users.
      */
    @@ -188,8 +177,8 @@ export const CreateHandlers = HttpApiBuilder.group(
     							return yield* new DashboardAPIError({ error: 'Invalid rank' });
     						}
     
    -						const callerPerm = permissionLevels.indexOf(userData.permissionLevel);
    -						const targetPerm = permissionLevels.indexOf(rank);
    +						const callerPerm = availablePermissionRanks.indexOf(userData.permissionLevel);
    +						const targetPerm = availablePermissionRanks.indexOf(rank);
     
     						if (targetPerm >= callerPerm) {
     							return yield* new DashboardAPIError({
    @@ -325,9 +314,14 @@ export const CreateHandlers = HttpApiBuilder.group(
     
     						const { username, email, displayname, rank, originalUrl } = payload;
     
    -						const userPerms = userData.userPermissionLevel;
    +						if (!ValidRanks.has(rank) || rank === 'unknown') {
    +							return yield* new DashboardAPIError({ error: 'Invalid rank' });
    +						}
    +
    +						const callerPerm = availablePermissionRanks.indexOf(userData.permissionLevel);
    +						const targetPerm = availablePermissionRanks.indexOf(rank);
     
    -						if (rank === 'owner' && !userPerms?.isOwner) {
    +						if (targetPerm >= callerPerm) {
     							return yield* new DashboardAPIError({
     								error: 'Unauthorized: insufficient permissions to assign target rank',
     							});
    
  • packages/studiocms/frontend/pages/studiocms_api/_handlers/dashboard/users.ts+6 17 modified
    @@ -7,7 +7,7 @@ import { HttpApiBuilder } from '@effect/platform';
     import { StudioCMSDashboardApiSpec } from '@withstudiocms/api-spec';
     import { CurrentUser } from '@withstudiocms/api-spec/astro-context';
     import { DashboardAPIError } from '@withstudiocms/api-spec/dashboard';
    -import type { AvailablePermissionRanks } from '@withstudiocms/auth-kit/types';
    +import { availablePermissionRanks } from '@withstudiocms/auth-kit/types';
     import { Effect } from 'effect';
     import { ValidRanks } from '#consts';
     import { sharedDBErrors, sharedNotifierErrors } from './_shared.js';
    @@ -17,17 +17,6 @@ import { sharedDBErrors, sharedNotifierErrors } from './_shared.js';
      */
     const dashboardAPIEnabled = routeConfig.dashboardAPIEnabled;
     
    -/**
    - * Array of available permission levels for the REST API. This is used to check user permissions when accessing secure endpoints, ensuring that only users with the appropriate rank can perform certain actions. The ranks are defined in ascending order of permissions, with 'unknown' having the least permissions and 'owner' having the most.
    - */
    -const permissionLevels: AvailablePermissionRanks[] = [
    -	'unknown',
    -	'visitor',
    -	'editor',
    -	'admin',
    -	'owner',
    -];
    -
     /**
      * Users Handlers for the Dashboard API
      */
    @@ -80,11 +69,11 @@ export const UsersHandlers = HttpApiBuilder.group(StudioCMSDashboardApiSpec, 'us
     						});
     					}
     
    -					const userPerms = permissionLevels.indexOf(userData.permissionLevel);
    -					const targetCurrentLevel = permissionLevels.indexOf(
    +					const userPerms = availablePermissionRanks.indexOf(userData.permissionLevel);
    +					const targetCurrentLevel = availablePermissionRanks.indexOf(
     						user.permissionsData?.rank || 'unknown'
     					);
    -					const targetNewLevel = permissionLevels.indexOf(rank);
    +					const targetNewLevel = availablePermissionRanks.indexOf(rank);
     
     					if (userPerms === -1 || targetCurrentLevel === -1 || targetNewLevel === -1) {
     						return yield* new DashboardAPIError({
    @@ -203,8 +192,8 @@ export const UsersHandlers = HttpApiBuilder.group(StudioCMSDashboardApiSpec, 'us
     						});
     					}
     
    -					const actorPerms = permissionLevels.indexOf(userData.permissionLevel);
    -					const targetPerms = permissionLevels.indexOf(
    +					const actorPerms = availablePermissionRanks.indexOf(userData.permissionLevel);
    +					const targetPerms = availablePermissionRanks.indexOf(
     						targetUser.permissionsData?.rank || 'unknown'
     					);
     
    
  • packages/studiocms/frontend/pages/studiocms_api/_handlers/rest-api/v1/secure.ts+13 21 modified
    @@ -10,7 +10,10 @@ import {
     	CurrentRestAPIUser,
     	RestAPIError,
     } from '@withstudiocms/api-spec/rest-api';
    -import type { AvailablePermissionRanks } from '@withstudiocms/auth-kit/types';
    +import {
    +	type AvailablePermissionRanks,
    +	availablePermissionRanks,
    +} from '@withstudiocms/auth-kit/types';
     import {
     	StudioCMSPageData,
     	StudioCMSPageDataCategories,
    @@ -38,17 +41,6 @@ const StringArrayCodec = Schema.transform(Schema.String, Schema.Array(Schema.Str
      */
     const encodeStringArray = Schema.encode(StringArrayCodec);
     
    -/**
    - * Array of available permission levels for the REST API. This is used to check user permissions when accessing secure endpoints, ensuring that only users with the appropriate rank can perform certain actions. The ranks are defined in ascending order of permissions, with 'unknown' having the least permissions and 'owner' having the most.
    - */
    -const permissionLevels: AvailablePermissionRanks[] = [
    -	'unknown',
    -	'visitor',
    -	'editor',
    -	'admin',
    -	'owner',
    -];
    -
     /**
      * REST API v1 Secure Handler
      *
    @@ -1488,8 +1480,8 @@ export const RestApiSecureHandler = HttpApiBuilder.group(
     						const { permissionsData } = existingUser;
     
     						const existingUserRank = permissionsData?.rank ?? 'unknown';
    -						const existingUserRankIndex = permissionLevels.indexOf(existingUserRank);
    -						const loggedInUserRankIndex = permissionLevels.indexOf(user.rank);
    +						const existingUserRankIndex = availablePermissionRanks.indexOf(existingUserRank);
    +						const loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);
     
     						if (loggedInUserRankIndex <= existingUserRankIndex) {
     							return yield* new RestAPIError({
    @@ -1556,17 +1548,17 @@ export const RestApiSecureHandler = HttpApiBuilder.group(
     						const { permissionsData } = existingUser;
     
     						const existingUserRank = permissionsData?.rank ?? 'unknown';
    -						const existingUserRankIndex = permissionLevels.indexOf(existingUserRank);
    -						const loggedInUserRankIndex = permissionLevels.indexOf(user.rank);
    +						const existingUserRankIndex = availablePermissionRanks.indexOf(existingUserRank);
    +						const loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);
     
    -						if (loggedInUserRankIndex < existingUserRankIndex) {
    +						if (loggedInUserRankIndex <= existingUserRankIndex) {
     							return yield* new RestAPIError({
     								error: 'Unauthorized to update user with higher rank',
     							});
     						}
     
     						if (payload.rank) {
    -							const payloadRankIndex = permissionLevels.indexOf(payload.rank);
    +							const payloadRankIndex = availablePermissionRanks.indexOf(payload.rank);
     
     							if (loggedInUserRankIndex <= payloadRankIndex) {
     								return yield* new RestAPIError({
    @@ -1712,10 +1704,10 @@ export const RestApiSecureHandler = HttpApiBuilder.group(
     							username,
     						};
     
    -						const existingUserRankIndex = permissionLevels.indexOf(existingUserRank);
    -						const loggedInUserRankIndex = permissionLevels.indexOf(user.rank);
    +						const existingUserRankIndex = availablePermissionRanks.indexOf(existingUserRank);
    +						const loggedInUserRankIndex = availablePermissionRanks.indexOf(user.rank);
     
    -						if (loggedInUserRankIndex < existingUserRankIndex) {
    +						if (loggedInUserRankIndex <= existingUserRankIndex) {
     							return yield* new RestAPIError({
     								error: 'Unauthorized to view user with higher rank',
     							});
    
  • packages/@withstudiocms/api-spec/src/dashboard/index.ts+2 1 modified
    @@ -3,7 +3,7 @@ import { Description, License, Title, Transform, Version } from '@effect/platfor
     import pkg from '../../package.json';
     import { StudioCMSLicenseAnnotation, StudioCMSTransformAnnotation } from '../consts.js';
     import { DashboardAPIError } from './errors.js';
    -import { apiTokensDelete, apiTokensPost } from './routes/api-tokens.js';
    +import { adminApiTokensDelete, apiTokensDelete, apiTokensPost } from './routes/api-tokens.js';
     import { configPost } from './routes/config.js';
     import {
     	contentDiffPost,
    @@ -44,6 +44,7 @@ export class DashboardApiTokensGroup extends HttpApiGroup.make('apiTokens')
     	.annotate(License, StudioCMSLicenseAnnotation)
     	.add(apiTokensPost)
     	.add(apiTokensDelete)
    +	.add(adminApiTokensDelete)
     	.addError(DashboardAPIError, { status: 500 })
     	.prefix('/dashboard') {}
     
    
  • packages/@withstudiocms/api-spec/src/dashboard/routes/api-tokens.ts+26 0 modified
    @@ -3,6 +3,7 @@ import { Description, Summary, Title } from '@effect/platform/OpenApi';
     import { AstroLocalsMiddleware } from '../../astro-context.js';
     import { DashboardAPIError } from '../errors.js';
     import {
    +	AdminDeleteApiTokenPayload,
     	CreateApiTokenPayload,
     	CreateApiTokenResponse,
     	DeleteApiTokenPayload,
    @@ -54,3 +55,28 @@ export const apiTokensDelete = HttpApiEndpoint.del('revokeApiToken', '/api-token
     	.addError(DashboardAPIError, { status: 400 })
     	.addError(DashboardAPIError, { status: 403 })
     	.addError(DashboardAPIError, { status: 500 });
    +
    +/**
    + * Admin Revoke User API Token Endpoint
    + *
    + * This endpoint allows Admin users to revoke any user's API token for the StudioCMS Dashboard API.
    + */
    +export const adminApiTokensDelete = HttpApiEndpoint.del(
    +	'adminRevokeUserApiToken',
    +	'/api-tokens/admin'
    +)
    +	.annotate(Title, 'Admin Revoke User API Token')
    +	.annotate(
    +		Summary,
    +		"Admin level endpoint to revoke any user's API token for the StudioCMS Dashboard API"
    +	)
    +	.annotate(
    +		Description,
    +		"Admin level endpoint that allows revoking any user's API token for the StudioCMS Dashboard API. This endpoint requires additional permission checks to ensure only Admin users can revoke tokens that they do not own.\n\n> [!note]\n> This endpoint verifies User authentication using [Astro Locals Context](https://docs.astro.build/en/guides/middleware/#storing-data-in-contextlocals) and requires users to be logged into the current StudioCMS instance with Admin level permissions."
    +	)
    +	.middleware(AstroLocalsMiddleware)
    +	.setPayload(AdminDeleteApiTokenPayload)
    +	.addSuccess(successResponseSchema)
    +	.addError(DashboardAPIError, { status: 400 })
    +	.addError(DashboardAPIError, { status: 403 })
    +	.addError(DashboardAPIError, { status: 500 });
    
  • packages/@withstudiocms/api-spec/src/dashboard/schemas.ts+12 2 modified
    @@ -7,8 +7,6 @@ import {
     import * as Schema from 'effect/Schema';
     import { StudioCMSDynamicSiteConfigData } from '../rest-api/schemas.js';
     
    -// TODO: Implement Admin level API token revocation that allows revoking any token, not just tokens owned by the user. This will require additional permission checks to ensure only Admin users can revoke tokens that they do not own.
    -
     /**
      * Standard error response schema for the StudioCMS Dashboard API.
      *
    @@ -296,6 +294,18 @@ export const DeleteApiTokenPayload = Schema.Struct({
     	description: 'Payload schema for deleting an existing API token in the StudioCMS Dashboard API.',
     });
     
    +/**
    + * Payload schema for Admin users to delete any user's API token in the StudioCMS Dashboard API.
    + */
    +export const AdminDeleteApiTokenPayload = Schema.Struct({
    +	...DeleteApiTokenPayload.fields,
    +	userID: Schema.String,
    +}).annotations({
    +	title: 'Admin Delete API Token Payload',
    +	description:
    +		"Payload schema for Admin users to delete any user's API token in the StudioCMS Dashboard API. This includes the token ID of the token to be revoked and the user ID of the token owner.",
    +});
    +
     /**
      * Payload schema for updating the dynamic site configuration in the StudioCMS Dashboard API.
      *
    
  • packages/@withstudiocms/auth-kit/src/types.ts+1 1 modified
    @@ -92,7 +92,7 @@ export interface OAuthData {
      * - 'visitor': A low-level permission, for users who can view content but not modify it.
      * - 'unknown': A default or fallback permission rank for users with undefined roles.
      */
    -export const availablePermissionRanks = ['owner', 'admin', 'editor', 'visitor', 'unknown'] as const;
    +export const availablePermissionRanks = ['unknown', 'visitor', 'editor', 'admin', 'owner'] as const;
     
     /**
      * Represents the available permission ranks for a user.
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.