CVE-2026-41640
Description
NocoBase is an AI-powered no-code/low-code platform for building business applications and enterprise solutions. Prior to version 2.0.39, the queryParentSQL() function in the core database package constructs a recursive CTE query by joining nodeIds with string concatenation instead of using parameterized queries. The nodeIds array contains primary key values read from database rows. An attacker who can create a record with a malicious string primary key can inject arbitrary SQL when any subsequent request triggers recursive eager loading on that collection. This issue has been patched in version 2.0.39.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@nocobase/databasenpm | < 2.0.39 | 2.0.39 |
Affected products
1- Range: <=2.0.32
Patches
1202e2b8efe44fix(database): parameterize eager loading tree queries (#9133)
4 files changed · +137 −12
packages/core/database/src/eager-loading/eager-loading-tree.ts+9 −4 modified@@ -71,16 +71,20 @@ const queryParentSQL = (options: { const queryInterface = db.sequelize.getQueryInterface(); const q = queryInterface.quoteIdentifier.bind(queryInterface); - return `WITH RECURSIVE cte AS ( + const placeholders = nodeIds.map((_, index) => `$${index + 1}`).join(', '); + return { + sql: `WITH RECURSIVE cte AS ( SELECT ${q(targetKeyField)}, ${q(foreignKeyField)} FROM ${tableName} - WHERE ${q(targetKeyField)} IN ('${nodeIds.join("','")}') + WHERE ${q(targetKeyField)} IN (${placeholders}) UNION ALL SELECT t.${q(targetKeyField)}, t.${q(foreignKeyField)} FROM ${tableName} AS t INNER JOIN cte ON t.${q(targetKeyField)} = cte.${q(foreignKeyField)} ) - SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`; + SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`, + bind: nodeIds, + }; }; export class EagerLoadingTree { @@ -381,7 +385,7 @@ export class EagerLoadingTree { // load parent instances recursively if (node.includeOption.recursively && instances.length > 0) { const targetKey = association.targetKey; - const sql = queryParentSQL({ + const { sql, bind } = queryParentSQL({ db: this.db, collection, foreignKey, @@ -390,6 +394,7 @@ export class EagerLoadingTree { }); const results = await this.db.sequelize.query(sql, { + bind, type: 'SELECT', transaction, });
packages/core/database/src/__tests__/eager-loading/eager-loading-tree.test.ts+59 −0 modified@@ -620,4 +620,63 @@ describe('Eager loading tree', () => { expect(u1.get('posts')[0].get('tags')[0].get('tagCategory')).toBeDefined(); expect(u1.get('posts')[0].get('tags')[0].get('tagCategory').get('name')).toBe('c1'); }); + + it('should use bind parameters when loading parent recursively with string primary keys', async () => { + const payload = `root') UNION ALL SELECT 'pwned', NULL WHERE ('1'='1`; + const Tree = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'id', primaryKey: true }, + { type: 'string', name: 'name' }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + target: 'categories', + foreignKey: 'parentId', + targetKey: 'id', + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + target: 'categories', + foreignKey: 'parentId', + sourceKey: 'id', + }, + ], + }); + + await db.sync(); + + await Tree.repository.create({ + values: [ + { id: 'root', name: 'root' }, + { id: payload, name: 'payload' }, + { id: 'child', name: 'child', parentId: payload }, + ], + }); + + const querySpy = vi.spyOn(db.sequelize, 'query'); + + const child = await Tree.repository.findOne({ + where: { id: 'child' }, + appends: ['parent(recursively=true)'], + }); + + expect(child.parent.id).toBe(payload); + + const recursiveQueryCall = querySpy.mock.calls.find( + ([sql]) => typeof sql === 'string' && sql.includes('WITH RECURSIVE cte'), + ); + + expect(recursiveQueryCall).toBeDefined(); + expect(recursiveQueryCall[0]).toContain('IN ($1)'); + expect(recursiveQueryCall[0]).not.toContain(payload); + expect(recursiveQueryCall[1]).toMatchObject({ + bind: [payload], + type: 'SELECT', + }); + }); });
packages/plugins/@nocobase/plugin-field-sort/src/server/sort-field.ts+17 −8 modified@@ -120,21 +120,29 @@ export class SortField extends Field { const sortColumnName = queryInterface.quoteIdentifier(this.collection.model.rawAttributes[this.name].field); let sql: string; + let bind: any[] | undefined; const whereClause = scopeKey && scopeValue ? (() => { const filteredScopeValue = scopeValue.filter((v) => v !== null); - if (filteredScopeValue.length === 0) { - return ''; + const clauses: string[] = []; + + if (filteredScopeValue.length > 0) { + bind = filteredScopeValue; + const placeholders = filteredScopeValue.map((_, index) => `$${index + 1}`).join(', '); + clauses.push(`${queryInterface.quoteIdentifier(scopeKey)} IN (${placeholders})`); + } + + if (scopeValue.includes(null)) { + clauses.push(`${queryInterface.quoteIdentifier(scopeKey)} IS NULL`); } - const initialClause = ` - WHERE ${queryInterface.quoteIdentifier(scopeKey)} IN (${filteredScopeValue.map((v) => `'${v}'`).join(', ')})`; - const nullCheck = scopeValue.includes(null) - ? ` OR ${queryInterface.quoteIdentifier(scopeKey)} IS NULL` - : ''; - return initialClause + nullCheck; + if (clauses.length === 0) { + return ''; + } + return ` + WHERE ${clauses.join(' OR ')}`; })() : ''; @@ -180,6 +188,7 @@ export class SortField extends Field { `; } await this.collection.db.sequelize.query(sql, { + bind, transaction, }); };
packages/plugins/@nocobase/plugin-field-sort/src/server/__tests__/sort.test.ts+52 −0 modified@@ -218,6 +218,58 @@ describe('sort field', () => { expect(r5.get('sort')).toBe(1); }); + it('should use bind parameters when initializing scoped sort values', async () => { + const payload = `group') OR 1=1 --`; + const Test = db.collection({ + name: 'tests', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'group', + }, + ], + }); + + await db.sync(); + await Test.repository.create({ + values: [ + { + group: payload, + name: 'r1', + }, + { + group: payload, + name: 'r2', + }, + ], + }); + + const querySpy = vi.spyOn(db.sequelize, 'query'); + + Test.setField('sort', { type: 'sort', scopeKey: 'group' }); + + await db.sync(); + + const sortInitQueryCall = querySpy.mock.calls.find( + ([sql, options]) => + typeof sql === 'string' && + sql.includes('ROW_NUMBER() OVER') && + Array.isArray(options?.bind) && + options.bind.includes(payload), + ); + + expect(sortInitQueryCall).toBeDefined(); + expect(sortInitQueryCall[0]).toContain('IN ($1)'); + expect(sortInitQueryCall[0]).not.toContain(payload); + expect(sortInitQueryCall[1]).toMatchObject({ + bind: [payload], + }); + }); + it('should init sorted value by createdAt when primaryKey not exists', async () => { const Test = db.collection({ autoGenId: false,
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
6- github.com/nocobase/nocobase/commit/202e2b8efe44ba90adbf1087f6f70881ff947604nvdPatchWEB
- github.com/nocobase/nocobase/pull/9133nvdIssue TrackingPatchWEB
- github.com/nocobase/nocobase/releases/tag/v2.0.39nvdPatchRelease NotesWEB
- github.com/nocobase/nocobase/security/advisories/GHSA-4948-f92q-f432nvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-4948-f92q-f432ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41640ghsaADVISORY
News mentions
0No linked articles in our index yet.