High severityOSV Advisory· Published Oct 23, 2025· Updated Apr 15, 2026
CVE-2025-62713
CVE-2025-62713
Description
Kottster is a self hosted Node.js admin panel. From versions 3.2.0 to before 3.3.2, Kottster contains a pre-authentication remote code execution (RCE) vulnerability when running in development mode. This affects development mode only, production deployments were never affected. This issue has been fixed in version 3.3.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@kottster/servernpm | >= 3.2.0, < 3.3.2 | 3.3.2 |
Affected products
1Patches
10a7d24922a23Add table views, replace roleIds with role names, add additional checkings for dev actions (#107)
33 files changed · +234 −90
docs/table/configuration/api.md+6 −6 modified@@ -142,11 +142,11 @@ If you only need to change the way columns and fields are rendered, you can use Allows users to insert new records into the table. If not specified, the default value is `true`. -- ### allowedRoleIdsToInsert +- ### allowedRolesToInsert `string[]`, optional - Specifies the role IDs that are allowed to insert records into the table. If not specified, all users can insert records unless `allowInsert` is set to `false`. + Specifies the role names that are allowed to insert records into the table. If not specified, all users can insert records unless `allowInsert` is set to `false`. - ### validateRecordBeforeInsert @@ -205,11 +205,11 @@ If you only need to change the way columns and fields are rendered, you can use Allows users to update records in the table. If not specified, the default value is `true`. -- ### allowedRoleIdsToUpdate +- ### allowedRolesToUpdate `string[]`, optional - Specifies the role IDs that are allowed to update records in the table. If not specified, all users can update records unless `allowUpdate` is set to `false`. + Specifies the role names that are allowed to update records in the table. If not specified, all users can update records unless `allowUpdate` is set to `false`. - ### validateRecordBeforeUpdate @@ -262,11 +262,11 @@ If you only need to change the way columns and fields are rendered, you can use Allows users to delete records from the table. If not specified, the default value is `true`. -- ### allowedRoleIdsToDelete +- ### allowedRolesToDelete `string[]`, optional - Specifies the role IDs that are allowed to delete records from the table. If not specified, all users can delete records unless `allowDelete` is set to `false`. + Specifies the role names that are allowed to delete records from the table. If not specified, all users can delete records unless `allowDelete` is set to `false`. - ### validateRecordBeforeDelete
packages/cli/cli/actions/newProject.action.ts+1 −0 modified@@ -37,6 +37,7 @@ export async function newProject (projectName: string | undefined, options: Opti }) fileCreator.createProject({ projectName, + packageManager: projectSetupData.packageManager, }) if (options.skipInstall || projectSetupData.skipPackageInstallation) {
packages/cli/cli/services/fileCreator.service.ts+17 −8 modified@@ -3,6 +3,7 @@ import fs from 'fs' import { dataSourcesTypeData, DataSourceType } from '@kottster/common' import { FileTemplateManager } from './fileTemplateManager.service' import { VERSION } from '../version' +import { PackageManager } from '../models/packageManager' interface FileCreatorOptions { projectDir?: string @@ -11,14 +12,18 @@ interface FileCreatorOptions { interface CreateProjectOptions { projectName: string; + packageManager: PackageManager; } interface PackageJsonOptions { - name: string - type?: 'module' - version?: string - dependencies?: Record<string, string> - devDependencies?: Record<string, string> + name: string; + type?: 'module'; + version?: string; + dependencies?: Record<string, string>; + devDependencies?: Record<string, string>; + pnpm?: { + onlyBuiltDependencies?: string[]; + } } type EnvOptions = { @@ -70,8 +75,11 @@ export class FileCreator { this.createPackageJson({ name: options.projectName, dependencies: {}, - devDependencies: this.usingTsc ? this.getTypescriptDependencies() : {} - }) + devDependencies: this.usingTsc ? this.getTypescriptDependencies() : {}, + pnpm: options.packageManager === 'pnpm' ? { + onlyBuiltDependencies: ['better-sqlite3'], + } : undefined, + }); this.createGitIgnore() // Create files @@ -137,7 +145,7 @@ export class FileCreator { * Create a package.json file * @param options The package.json content */ - private createPackageJson (options: PackageJsonOptions) { + private createPackageJson(options: PackageJsonOptions) { const packageJsonPath = path.join(this.projectDir, 'package.json') const { @@ -187,6 +195,7 @@ export class FileCreator { engines: { node: '>=20', }, + pnpm: options.pnpm || undefined, } const packageJsonContent = JSON.stringify(packageJson, null, 2)
packages/cli/cli/version.ts+1 −1 modified@@ -1 +1 @@ -export const VERSION = '3.3.1'; +export const VERSION = '3.3.2';
packages/cli/package.json+2 −2 modified@@ -1,6 +1,6 @@ { "name": "@kottster/cli", - "version": "3.3.1", + "version": "3.3.2", "description": "CLI for Kottster", "main": "dist/index.js", "license": "Apache-2.0", @@ -33,7 +33,7 @@ "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0", "@eslint/js": "^9.2.0", - "@kottster/common": "^3.3.1", + "@kottster/common": "^3.3.2", "@types/babel__traverse": "^7.20.5", "@types/cross-spawn": "^6.0.6", "@types/dotenv": "^8.2.0",
packages/cli/package-lock.json+4 −4 modified@@ -1,6 +1,6 @@ { "name": "@kottster/cli", - "version": "3.3.1", + "version": "3.3.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1110,9 +1110,9 @@ "dev": true }, "@kottster/common": { - "version": "3.3.1", - "resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.1.tgz", - "integrity": "sha1-hYPzKmug8SsCCKfCYmbjD5K8mu4= sha512-K6GRIdIh/OfR0sKbBGpyig0Cajk3c2zBA+EdoPs12BTykHIpuwZRZt2CEx884C2HMvHebjZIPJgWl+kgFFI4Vw==", + "version": "3.3.2", + "resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.2.tgz", + "integrity": "sha1-TaIUpEQAfQ9yLONEG+3FsDUR30M= sha512-I0v2Ve80TbKX2osdcdfg8cJ+jrVk0nsRtJh1dtuiUEvH+U1dcdbrpZfJozZZX2V8OMoElUP88A6LOyUpwthVOg==", "dev": true }, "@nodelib/fs.scandir": {
packages/cli/yarn.lock+4 −4 modified@@ -899,10 +899,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@kottster/common@^3.3.1": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.1.tgz#8583f32a6ba0f12b0208a7c26266e30f92bc9aee" - integrity sha512-K6GRIdIh/OfR0sKbBGpyig0Cajk3c2zBA+EdoPs12BTykHIpuwZRZt2CEx884C2HMvHebjZIPJgWl+kgFFI4Vw== +"@kottster/common@^3.3.2": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.2.tgz#4da214a444007d0f722ce3441bedc5b03511df43" + integrity sha512-I0v2Ve80TbKX2osdcdfg8cJ+jrVk0nsRtJh1dtuiUEvH+U1dcdbrpZfJozZZX2V8OMoElUP88A6LOyUpwthVOg== "@nodelib/fs.scandir@2.1.5": version "2.1.5"
packages/common/lib/models/dto.model.ts+2 −2 modified@@ -5,7 +5,7 @@ import { DataSourceType, PublicDataSource } from "./dataSource.model"; import { Page, PageFileStructure } from "./page.model"; import { TablePageConfig } from "./tablePage.model"; import { Template } from "./template.model"; -import { ClientIdentityProviderRole, ClientIdentityProviderUser, IdentityProviderUserPermission, User } from "./idp.model"; +import { ClientIdentityProviderRole, ClientIdentityProviderUser, ClientIdentityProviderUserWithRoles, IdentityProviderUserPermission, User } from "./idp.model"; export interface InternalApiSchema { getUsers: { @@ -75,7 +75,7 @@ export interface InternalApiSchema { schema: ClientAppSchema; // Pass only if user is authenticated - user?: ClientIdentityProviderUser; + user?: ClientIdentityProviderUserWithRoles; roles?: ClientIdentityProviderRole[]; userPermissions?: (keyof typeof IdentityProviderUserPermission | string)[]; };
packages/common/lib/models/idp.model.ts+9 −1 modified@@ -22,14 +22,22 @@ export interface IdentityProviderUser { updatedAt?: Date | string; } +export interface IdentityProviderUserWithRoles extends IdentityProviderUser { + roles: IdentityProviderRole[]; +} + export interface ClientIdentityProviderUser extends Omit< IdentityProviderUser, 'passwordHash' | 'passwordResetToken' | 'twoFactorSecret' | 'jwtTokenSecret' > {} +export interface ClientIdentityProviderUserWithRoles extends ClientIdentityProviderUser { + roles: IdentityProviderRole[]; +} + export interface IdentityProviderRole { id: number | string; - name?: string; + name: string; permissions?: (keyof typeof IdentityProviderUserPermission)[]; createdAt?: Date | string; updatedAt?: Date | string;
packages/common/lib/models/page.model.ts+10 −4 modified@@ -12,8 +12,14 @@ interface BasePage { key: string; title?: string; icon?: string; - allowedRoleIds?: string[]; hideInSidebar?: boolean; + + allowedRoles?: string[]; + + /** + * @deprecated Legacy - to be removed in v4. Use `allowedRoles` instead. + */ + allowedRoleIds?: string[]; } interface TablePage extends BasePage { @@ -34,15 +40,15 @@ interface CustomPage extends BasePage { export type Page = TablePage | DashboardPage | CustomPage; -interface PublicTablePage extends Pick<TablePage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> { +interface PublicTablePage extends Pick<TablePage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoles' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> { config: TablePage['config']; } -interface PublicDashboardPage extends Pick<DashboardPage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> { +interface PublicDashboardPage extends Pick<DashboardPage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoles' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> { config: DashboardPage['config']; } -interface PublicCustomPage extends Pick<CustomPage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> {} +interface PublicCustomPage extends Pick<CustomPage, 'key' | 'title' | 'icon' | 'type' | 'allowedRoles' | 'allowedRoleIds' | 'version' | 'hideInSidebar'> {} export type PublicPage = PublicTablePage | PublicDashboardPage | PublicCustomPage;
packages/common/lib/models/tablePage.model.ts+35 −0 modified@@ -17,6 +17,8 @@ export interface TablePageGetRecordsInput extends TablePageInputBase { }; filters?: FilterItem[]; + viewKey?: string; + getByForeignRecord?: { relationship: OneToManyRelationship; recordPrimaryKeyValue: string | number; @@ -168,6 +170,19 @@ export enum TableFetchStrategy { customFetch = 'customFetch', } +export enum TablePageConfigViewFilteringStrategy { + filter = 'filter', + sqlWhereExpression = 'sqlWhereExpression', +} + +export interface TablePageConfigView { + key: string; + label: string; + filteringStrategy: TablePageConfigViewFilteringStrategy; + filterItems?: FilterItem[]; + sqlWhereExpression?: string; +} + export interface TablePageConfig { /** * Set up using no-code @@ -191,13 +206,33 @@ export interface TablePageConfig { allowUpdate?: boolean; allowDelete?: boolean; + allowedRolesToInsert?: string[]; + allowedRolesToUpdate?: string[]; + allowedRolesToDelete?: string[]; + + /** + * @deprecated Legacy - to be removed in v4. Use `allowedRolesToInsert` instead + */ allowedRoleIdsToInsert?: string[]; + + /** + * @deprecated Legacy - to be removed in v4. Use `allowedRolesToUpdate` instead + */ allowedRoleIdsToUpdate?: string[]; + + /** + * @deprecated Legacy - to be removed in v4. Use `allowedRolesToDelete` instead + */ allowedRoleIdsToDelete?: string[]; customSqlQuery?: string; customSqlCountQuery?: string; + /** + * Views for pre-defined filters or SQL WHERE clauses + */ + views?: TablePageConfigView[]; + /** * Set up using manual configuration */
packages/common/lib/utils/checkUserForRoles.ts+31 −6 modified@@ -1,16 +1,41 @@ import { ROOT_USER_ID } from "../constants/idp"; -import { ClientIdentityProviderUser, IdentityProviderUser, User } from "../models/idp.model"; +import { ClientIdentityProviderRole, ClientIdentityProviderUser, IdentityProviderRole, IdentityProviderUser, User } from "../models/idp.model"; -export function checkUserForRoles(user: IdentityProviderUser | ClientIdentityProviderUser | User | ClientIdentityProviderUser, roleIds: string[]) { - if (!user) { +type UnifiedUser = IdentityProviderUser | ClientIdentityProviderUser | User | ClientIdentityProviderUser; + +type UnifiedRole = IdentityProviderRole | ClientIdentityProviderRole; + +/** + * Checks if the user has at least one of the specified roles. + * @description Role IDs properties have been replaced with role names. This function supports both for backward compatibility. + */ +export function checkUserForRoles( + userId: UnifiedUser['id'], + userRoles: UnifiedRole[], + roles?: string[], + roleIds?: string[] +) { + // If no user is provided, deny access + if (!userId) { return false; } // It's assumed that the root user has all roles - if (user.id === ROOT_USER_ID) { + if (userId === ROOT_USER_ID) { return true; } + + // If no roles or roleIds are specified, allow access + if ((!roles || roles.length === 0) && (!roleIds || roleIds.length === 0)) { + return true; + } + + // TODO: Remove in v4 - only keep role names + // Get user role IDs (for backward compatibility) + const userRoleIds = userRoles.map(r => r.id).map(v => v.toString()); - const userRoleIds = (('roles' in user ? user.roles?.map(r => r.id) : user.roleIds) || []).map(v => v.toString()); - return roleIds.some(rid => userRoleIds.includes(rid.toString())); + // Get user role names + const userRoleNames = userRoles.map(r => r.name).filter(n => n) as string[]; + + return roles?.some(n => userRoleNames.includes(n)) || roleIds?.some(rid => userRoleIds.includes(rid.toString())); } \ No newline at end of file
packages/common/lib/utils/getTableData.ts+10 −3 modified@@ -192,16 +192,23 @@ export function getTableData(params: { hiddenRelationships, allowInsert, - allowedRoleIdsToInsert: tablePageConfig?.allowedRoleIdsToInsert, + allowedRolesToInsert: tablePageConfig.allowedRolesToInsert, allowUpdate, - allowedRoleIdsToUpdate: tablePageConfig?.allowedRoleIdsToUpdate, + allowedRolesToUpdate: tablePageConfig.allowedRolesToUpdate, allowDelete, - allowedRoleIdsToDelete: tablePageConfig?.allowedRoleIdsToDelete, + allowedRolesToDelete: tablePageConfig.allowedRolesToDelete, pageSize: tablePageConfig?.pageSize ?? defaultTablePageSize, defaultSortColumn: tablePageConfig?.defaultSortColumn ?? primaryKeyColumn, defaultSortDirection: tablePageConfig?.defaultSortDirection ?? 'desc', + + views: tablePageConfig?.views || [], + + // Deprecated values replaced by allowedRoles fields + allowedRoleIdsToInsert: tablePageConfig?.allowedRoleIdsToInsert, + allowedRoleIdsToUpdate: tablePageConfig?.allowedRoleIdsToUpdate, + allowedRoleIdsToDelete: tablePageConfig?.allowedRoleIdsToDelete, }, }; }
packages/common/package.json+1 −1 modified@@ -1,7 +1,7 @@ { "name": "@kottster/common", "description": "Common types and utilities for Kottster", - "version": "3.3.1", + "version": "3.3.2", "main": "dist/index.js", "license": "Apache-2.0", "scripts": {
packages/common/package-lock.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@kottster/common", - "version": "3.3.1", + "version": "3.3.2", "lockfileVersion": 1, "requires": true, "dependencies": {
packages/server/lib/actions/changePassword.action.ts+2 −2 modified@@ -1,11 +1,11 @@ -import { IdentityProviderUser, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult } from "@kottster/common"; import { Action } from "../models/action.model"; /** * Change the password of a current user */ export class ChangePassword extends Action { - public async execute({ password, newPassword }: InternalApiBody<'changePassword'>, user: IdentityProviderUser): Promise<InternalApiResult<'changePassword'>> { + public async execute({ password, newPassword }: InternalApiBody<'changePassword'>, user: IdentityProviderUserWithRoles): Promise<InternalApiResult<'changePassword'>> { const isPasswordVerified = user.passwordHash && await this.app.identityProvider.verifyPassword(password, user.passwordHash); if (!isPasswordVerified) { throw new Error('Password is incorrect');
packages/server/lib/actions/deleteRole.action.ts+2 −0 modified@@ -8,6 +8,8 @@ export class DeleteRole extends Action { protected requiredPermissions = [IdentityProviderUserPermission.manage_users]; public async execute({ roleId }: InternalApiBody<'deleteRole'>): Promise<InternalApiResult<'deleteRole'>> { + // TODO: Update all references in the app configuration + await this.app.identityProvider.deleteRole(roleId); } } \ No newline at end of file
packages/server/lib/actions/getApp.action.ts+3 −4 modified@@ -1,4 +1,4 @@ -import { IdentityProviderUser, InternalApiBody, InternalApiResult, Page, Stage } from "@kottster/common"; +import { IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult, Page, Stage } from "@kottster/common"; import { Action } from "../models/action.model"; import { FileReader } from "../services/fileReader.service"; @@ -8,7 +8,7 @@ import { FileReader } from "../services/fileReader.service"; export class GetApp extends Action { private cachedPages: Page[] | null = null; - public async execute(_: InternalApiBody<'getApp'>, user?: IdentityProviderUser): Promise<InternalApiResult<'getApp'>> { + public async execute(_: InternalApiBody<'getApp'>, user?: IdentityProviderUserWithRoles): Promise<InternalApiResult<'getApp'>> { const roles = user ? await this.app.identityProvider.getRoles() : []; const userPermissions = user ? await this.app.identityProvider.getUserPermissions(user.id) : []; @@ -38,8 +38,7 @@ export class GetApp extends Action { }), enterpriseHub: appSchema.enterpriseHub, }, - - user, + user: user ? this.app.identityProvider.prepareUserForClient(user) : undefined, roles, userPermissions, };
packages/server/lib/actions/getKottsterContext.action.ts+2 −2 modified@@ -1,4 +1,4 @@ -import { IdentityProviderUser, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult } from "@kottster/common"; import { Action } from "../models/action.model"; import { KottsterApi } from "../services/kottsterApi.service"; @@ -11,7 +11,7 @@ const emptyResult: InternalApiResult<'getKottsterContext'> = { * and what limits are in place for the kottster api token in use */ export class GetKottsterContext extends Action { - public async execute(_: InternalApiBody<'getKottsterContext'>, user: IdentityProviderUser): Promise<InternalApiResult<'getKottsterContext'>> { + public async execute(_: InternalApiBody<'getKottsterContext'>, user: IdentityProviderUserWithRoles): Promise<InternalApiResult<'getKottsterContext'>> { const kottsterApi = new KottsterApi(); const kottsterApiToken = this.app.getKottsterApiToken(); if (!kottsterApiToken) {
packages/server/lib/actions/initApp.action.ts+6 −2 modified@@ -1,14 +1,18 @@ -import { generateRandomString, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { generateRandomString, InternalApiBody, InternalApiResult, isSchemaEmpty } from "@kottster/common"; import { DevAction } from "../models/action.model"; import { FileWriter } from "../services/fileWriter.service"; import { randomUUID } from "crypto"; import { KottsterApi } from "../services/kottsterApi.service"; /** - * Get the data source info + * Initialize the Kottster app */ export class InitApp extends DevAction { public async execute({ name, rootUsername, rootPassword }: InternalApiBody<'initApp'>): Promise<InternalApiResult<'initApp'>> { + if (!isSchemaEmpty(this.app.schema)) { + throw new Error('The app has already been initialized.'); + } + const fileWrtier = new FileWriter({ usingTsc: this.app.usingTsc }); const id = randomUUID();
packages/server/lib/actions/installPackagesForDataSource.action.ts+4 −0 modified@@ -10,6 +10,10 @@ export class InstallPackagesForDataSource extends DevAction { public async execute(data: InternalApiBody<'installPackagesForDataSource'>): Promise<InternalApiResult<'installPackagesForDataSource'>> { return new Promise((resolve, reject) => { const { type } = data; + if (!Object.values(DataSourceType).includes(type)) { + reject(new Error(`Unsupported data source type: ${type}`)); + return; + } const command = this.getCommand(type); exec(command, { cwd: PROJECT_DIR }, (error) => {
packages/server/lib/actions/login.action.ts+2 −2 modified@@ -1,12 +1,12 @@ -import { IdentityProviderUser, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { IdentityProviderUser, IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult } from "@kottster/common"; import { Action } from "../models/action.model"; import { Request } from "express"; /** * Login into an account */ export class Login extends Action { - public async execute({ usernameOrEmail, password, newPassword }: InternalApiBody<'login'>, _: IdentityProviderUser, req?: Request): Promise<InternalApiResult<'login'>> { + public async execute({ usernameOrEmail, password, newPassword }: InternalApiBody<'login'>, _: IdentityProviderUserWithRoles, req?: Request): Promise<InternalApiResult<'login'>> { const rootUser = this.app.identityProvider.getRootUserByUsername(usernameOrEmail); const ipAddress = this.getClientIp(req);
packages/server/lib/actions/logOutAllSessions.action.ts+2 −2 modified@@ -1,11 +1,11 @@ -import { IdentityProviderUser, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult } from "@kottster/common"; import { Action } from "../models/action.model"; /** * Log out all sessions of a current user */ export class LogOutAllSessions extends Action { - public async execute({ password }: InternalApiBody<'logOutAllSessions'>, user: IdentityProviderUser): Promise<InternalApiResult<'logOutAllSessions'>> { + public async execute({ password }: InternalApiBody<'logOutAllSessions'>, user: IdentityProviderUserWithRoles): Promise<InternalApiResult<'logOutAllSessions'>> { const isPasswordVerified = user.passwordHash && await this.app.identityProvider.verifyPassword(password, user.passwordHash); if (!isPasswordVerified) { throw new Error('Current password is incorrect');
packages/server/lib/actions/updateRole.action.ts+2 −0 modified@@ -10,6 +10,8 @@ export class UpdateRole extends Action { public async execute({ roleId, role }: InternalApiBody<'updateRole'>): Promise<InternalApiResult<'updateRole'>> { const updatedRole = await this.app.identityProvider.updateRole(roleId, role); + // TODO: When name is changed, update all references in the app configuration + return { role: this.app.identityProvider.prepareRoleForClient(updatedRole), }
packages/server/lib/actions/updateUser.action.ts+2 −2 modified@@ -1,11 +1,11 @@ -import { IdentityProviderUser, IdentityProviderUserPermission, InternalApiBody, InternalApiResult } from "@kottster/common"; +import { IdentityProviderUserPermission, IdentityProviderUserWithRoles, InternalApiBody, InternalApiResult } from "@kottster/common"; import { Action } from "../models/action.model"; /** * Update the user */ export class UpdateUser extends Action { - public async execute({ userId, user, newPassword }: InternalApiBody<'updateUser'>, currentUser: IdentityProviderUser): Promise<InternalApiResult<'updateUser'>> { + public async execute({ userId, user, newPassword }: InternalApiBody<'updateUser'>, currentUser: IdentityProviderUserWithRoles): Promise<InternalApiResult<'updateUser'>> { const hasPermission = await this.app.identityProvider.userHasPermissions(currentUser.id, [IdentityProviderUserPermission.manage_users]); if (!hasPermission && currentUser.id !== userId) { throw new Error("You don't have permission to update this user");
packages/server/lib/core/app.ts+16 −11 modified@@ -1,6 +1,6 @@ import { ExtendAppContextFunction } from '../models/appContext.model'; import { PROJECT_DIR } from '../constants/projectDir'; -import { AppSchema, checkTsUsage, DataSource, Stage, RpcActionBody, TablePageGetRecordsInput, TablePageDeleteRecordInput, TablePageUpdateRecordInput, TablePageCreateRecordInput, isSchemaEmpty, schemaPlaceholder, TablePageGetRecordInput, Page, TablePageConfig, TablePageCustomDataFetcherInput, TablePageGetRecordsResult, DashboardPageConfig, DashboardPageGetStatDataInput, DashboardPageGetCardDataInput, DashboardPageGetStatDataResult, DashboardPageGetCardDataResult, checkUserForRoles, IdentityProviderUser, InternalApiSchema, PartialTablePageConfig, transformStringToTablePageNestedTableKey, PartialDashboardPageConfig, DashboardPageConfigStat, DashboardPageConfigCard, TablePageInitiateRecordsExportInput, TablePageInitiateRecordsExportResult, Procedure, ProcedureContext, normalizeAppBasePath } from '@kottster/common'; +import { AppSchema, checkTsUsage, DataSource, Stage, RpcActionBody, TablePageGetRecordsInput, TablePageDeleteRecordInput, TablePageUpdateRecordInput, TablePageCreateRecordInput, isSchemaEmpty, schemaPlaceholder, TablePageGetRecordInput, Page, TablePageConfig, TablePageCustomDataFetcherInput, TablePageGetRecordsResult, DashboardPageConfig, DashboardPageGetStatDataInput, DashboardPageGetCardDataInput, DashboardPageGetStatDataResult, DashboardPageGetCardDataResult, checkUserForRoles, InternalApiSchema, PartialTablePageConfig, transformStringToTablePageNestedTableKey, PartialDashboardPageConfig, DashboardPageConfigStat, DashboardPageConfigCard, TablePageInitiateRecordsExportInput, TablePageInitiateRecordsExportResult, Procedure, ProcedureContext, normalizeAppBasePath, IdentityProviderUserWithRoles } from '@kottster/common'; import { DataSourceRegistry } from './dataSourceRegistry'; import { ActionService } from '../services/action.service'; import { DataSourceAdapter } from '../models/dataSourceAdapter.model'; @@ -15,7 +15,7 @@ import dayjs from 'dayjs'; type RequestHandler = (req: Request, res: Response, next: NextFunction) => void; -type PostAuthMiddleware = (user: IdentityProviderUser, request: Request) => void | Promise<void>; +type PostAuthMiddleware = (user: IdentityProviderUserWithRoles, request: Request) => void | Promise<void>; export interface KottsterAppOptions { schema: AppSchema | Record<string, never>; @@ -72,7 +72,7 @@ export interface KottsterAppOptions { interface EnsureValidTokenResponse { isTokenValid: boolean; - user: IdentityProviderUser | null; + user: IdentityProviderUserWithRoles | null; invalidTokenErrorMessage?: string; } @@ -179,7 +179,7 @@ export class KottsterApp { this.extendContext = fn; } - public async executeAction(action: string, data: any, user?: IdentityProviderUser, req?: Request): Promise<any> { + public async executeAction(action: string, data: any, user?: IdentityProviderUserWithRoles, req?: Request): Promise<any> { return await ActionService.getAction(this, action).executeWithCheckings(data, user, req); } @@ -434,7 +434,7 @@ export class KottsterApp { let result: any; try { - if (page.allowedRoleIds?.length && !checkUserForRoles(user, page.allowedRoleIds) && this.stage === Stage.production) { + if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, page.allowedRoles, page.allowedRoleIds)) { throw new Error('You do not have access to this page'); } @@ -603,7 +603,7 @@ export class KottsterApp { const dataSourceAdapter = dataSource?.adapter as DataSourceAdapter | undefined; const databaseSchema = dataSourceAdapter ? await dataSourceAdapter.getDatabaseSchema() : undefined; - if (page.allowedRoleIds?.length && !checkUserForRoles(user, page.allowedRoleIds) && this.stage === Stage.production) { + if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, page.allowedRoles, page.allowedRoleIds)) { throw new Error('You do not have access to this page'); } @@ -639,19 +639,19 @@ export class KottsterApp { } else if (body.action === 'table_getRecord') { result = await dataSourceAdapter.getOneTableRecord(tablePageConfig, body.input as TablePageGetRecordInput, databaseSchema); } else if (body.action === 'table_createRecord') { - if (tablePageConfig.allowedRoleIdsToInsert?.length && this.stage === Stage.production && !checkUserForRoles(user, tablePageConfig.allowedRoleIdsToInsert)) { + if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToInsert, tablePageConfig.allowedRoleIdsToInsert)) { throw new Error('You do not have permission to create records in this table'); } result = await dataSourceAdapter.insertTableRecord(tablePageConfig, body.input as TablePageCreateRecordInput, databaseSchema); } else if (body.action === 'table_updateRecord') { - if (tablePageConfig.allowedRoleIdsToUpdate?.length && this.stage === Stage.production && !checkUserForRoles(user, tablePageConfig.allowedRoleIdsToUpdate)) { + if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToUpdate, tablePageConfig.allowedRoleIdsToUpdate)) { throw new Error('You do not have permission to update records in this table'); } result = await dataSourceAdapter.updateTableRecords(tablePageConfig, body.input as TablePageUpdateRecordInput, databaseSchema); } else if (body.action === 'table_deleteRecord') { - if (tablePageConfig.allowedRoleIdsToDelete?.length && this.stage === Stage.production && !checkUserForRoles(user, tablePageConfig.allowedRoleIdsToDelete)) { + if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToDelete, tablePageConfig.allowedRoleIdsToDelete)) { throw new Error('You do not have permission to delete records in this table'); } @@ -718,15 +718,20 @@ export class KottsterApp { } const user = await this.identityProvider.verifyTokenAndGetUser(token); + const userRoles = await this.identityProvider.getUserRoles(user.id); + const extendedUser: IdentityProviderUserWithRoles = { + ...user, + roles: userRoles, + }; // If a post-auth middleware is provided, call it if (this.postAuthMiddleware) { - await this.postAuthMiddleware(user, request); + await this.postAuthMiddleware(extendedUser, request); } return { isTokenValid: true, - user, + user: extendedUser, }; } catch (error) { return {
packages/server/lib/core/identityProvider.ts+20 −6 modified@@ -1,4 +1,4 @@ -import { IdentityProviderUser, IdentityProviderRole, IdentityProviderUserPermission, ROOT_USER_ID, ClientIdentityProviderUser, IdentityProviderLoginAttempt, generateRandomString, Stage } from "@kottster/common"; +import { IdentityProviderUser, IdentityProviderRole, IdentityProviderUserPermission, ROOT_USER_ID, ClientIdentityProviderUser, IdentityProviderLoginAttempt, generateRandomString, Stage, IdentityProviderUserWithRoles, ClientIdentityProviderUserWithRoles } from "@kottster/common"; import crypto from 'crypto'; import bcrypt from 'bcryptjs'; import { SignJWT, jwtVerify } from 'jose'; @@ -79,12 +79,22 @@ export class IdentityProvider { * @param user - The user object to prepare * @returns The prepared user object */ - public prepareUserForClient(user: IdentityProviderUser): ClientIdentityProviderUser { - user.passwordHash = ''; - user.twoFactorSecret = undefined; - user.jwtTokenCheck = undefined; + public prepareUserForClient<T extends IdentityProviderUser | IdentityProviderUserWithRoles>( + user: T + ): T extends IdentityProviderUserWithRoles + ? ClientIdentityProviderUserWithRoles + : ClientIdentityProviderUser { + const sanitized = { ...user }; + + sanitized.passwordHash = ''; + sanitized.twoFactorSecret = undefined; + sanitized.jwtTokenCheck = undefined; + + if ('roles' in sanitized && Array.isArray(sanitized.roles)) { + sanitized.roles = sanitized.roles.map(role => this.app.identityProvider.prepareRoleForClient(role)); + } - return user as ClientIdentityProviderUser; + return sanitized as any; } /** @@ -740,6 +750,10 @@ export class IdentityProvider { // Role CRUD methods async createRole(role: Omit<IdentityProviderRole, 'id'>): Promise<IdentityProviderRole> { + if (role.name && await this.getRoleBy('name', role.name)) { + throw new Error(`Name "${role.name}" is already taken`); + } + const roleData: any = { name: role.name, permissions: role.permissions ? JSON.stringify(role.permissions) : null,
packages/server/lib/models/action.model.ts+3 −3 modified@@ -1,4 +1,4 @@ -import { IdentityProviderUser, IdentityProviderUserPermission, Stage } from "@kottster/common"; +import { IdentityProviderUserPermission, IdentityProviderUserWithRoles, Stage } from "@kottster/common"; import { KottsterApp } from "../core/app"; import { Request } from "express"; @@ -11,7 +11,7 @@ export abstract class Action { protected requiredPermissions: (keyof typeof IdentityProviderUserPermission)[] = []; - public async executeWithCheckings(data: unknown, user?: IdentityProviderUser, req?: Request): Promise<unknown> { + public async executeWithCheckings(data: unknown, user?: IdentityProviderUserWithRoles, req?: Request): Promise<unknown> { // Ensure that user has the required permissions if (this.requiredPermissions.length > 0) { if (!user) { @@ -30,7 +30,7 @@ export abstract class Action { return this.execute(data, user, req); }; - protected abstract execute(data: unknown, user?: IdentityProviderUser, req?: Request): Promise<unknown>; + protected abstract execute(data: unknown, user?: IdentityProviderUserWithRoles, req?: Request): Promise<unknown>; } /**
packages/server/lib/models/dataSourceAdapter.model.ts+23 −0 modified@@ -296,6 +296,29 @@ export abstract class DataSourceAdapter { let query = this.client(table).from({ [tableAlias]: table }); let countQuery = options.includeCount ? this.client(table).from({ [tableAlias]: table }) : null; + // Filter by view + if (input.viewKey) { + const view = tablePageConfig.views?.find(v => v.key === input.viewKey); + if (view) { + if (view.filteringStrategy === 'filter') { + if (!view.filterItems || view.filterItems.length === 0) { + throw new Error('Filter items not provided for the selected view'); + } + this.applyFilters(query, view.filterItems, tableSchema.name, databaseSchema); + if (countQuery) { + this.applyFilters(countQuery, view.filterItems, tableSchema.name, databaseSchema); + } + } + if (view.filteringStrategy === 'sqlWhereExpression') { + if (!view.sqlWhereExpression) { + throw new Error('SQL expression WHERE clause not provided for the selected view'); + } + query.whereRaw(`(${view.sqlWhereExpression})`); + countQuery?.whereRaw(`(${view.sqlWhereExpression})`); + } + } + } + // Foreign record filter if (input.getByForeignRecord) { const { relationship, recordPrimaryKeyValue } = input.getByForeignRecord;
packages/server/lib/version.ts+1 −1 modified@@ -1 +1 @@ -export const VERSION = '3.3.1'; +export const VERSION = '3.3.2';
packages/server/package.json+2 −2 modified@@ -1,6 +1,6 @@ { "name": "@kottster/server", - "version": "3.3.1", + "version": "3.3.2", "description": "Instant admin panel for your project", "keywords": [ "admin", @@ -39,7 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.2.0", - "@kottster/common": "^3.3.1", + "@kottster/common": "^3.3.2", "@trpc/server": "^10.45.2", "@types/dotenv": "^8.2.0", "@types/express": "^5.0.2",
packages/server/package-lock.json+4 −4 modified@@ -1,6 +1,6 @@ { "name": "@kottster/server", - "version": "3.3.1", + "version": "3.3.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1002,9 +1002,9 @@ "dev": true }, "@kottster/common": { - "version": "3.3.1", - "resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.1.tgz", - "integrity": "sha1-hYPzKmug8SsCCKfCYmbjD5K8mu4= sha512-K6GRIdIh/OfR0sKbBGpyig0Cajk3c2zBA+EdoPs12BTykHIpuwZRZt2CEx884C2HMvHebjZIPJgWl+kgFFI4Vw==", + "version": "3.3.2", + "resolved": "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.2.tgz", + "integrity": "sha1-TaIUpEQAfQ9yLONEG+3FsDUR30M= sha512-I0v2Ve80TbKX2osdcdfg8cJ+jrVk0nsRtJh1dtuiUEvH+U1dcdbrpZfJozZZX2V8OMoElUP88A6LOyUpwthVOg==", "dev": true }, "@nodelib/fs.scandir": {
packages/server/yarn.lock+4 −4 modified@@ -826,10 +826,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@kottster/common@^3.3.1": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.1.tgz#8583f32a6ba0f12b0208a7c26266e30f92bc9aee" - integrity sha512-K6GRIdIh/OfR0sKbBGpyig0Cajk3c2zBA+EdoPs12BTykHIpuwZRZt2CEx884C2HMvHebjZIPJgWl+kgFFI4Vw== +"@kottster/common@^3.3.2": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@kottster/common/-/common-3.3.2.tgz#4da214a444007d0f722ce3441bedc5b03511df43" + integrity sha512-I0v2Ve80TbKX2osdcdfg8cJ+jrVk0nsRtJh1dtuiUEvH+U1dcdbrpZfJozZZX2V8OMoElUP88A6LOyUpwthVOg== "@nodelib/fs.scandir@2.1.5": version "2.1.5"
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
4News mentions
0No linked articles in our index yet.