VYPR
High severity7.5NVD Advisory· Published May 7, 2026· Updated May 12, 2026

CVE-2026-41640

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.

PackageAffected versionsPatched versions
@nocobase/databasenpm
< 2.0.392.0.39

Affected products

1

Patches

1
202e2b8efe44

fix(database): parameterize eager loading tree queries (#9133)

https://github.com/nocobase/nocobaseYANG QIAApr 17, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.