Security Review (ChatbotX)
A focused, repo-specific checklist. For OWASP-depth analysis dispatch the global security-reviewer agent; this skill is the ChatbotX-specific surface map and the things that bite here.
1. Tenant isolation (the #1 risk in a multi-workspace product)
- Every query that returns workspace data MUST be scoped by
workspaceId, OR by an id that is provably workspace-bound by construction (trace it).
- Builder APIs go through
workspaceAuthorizedMidddleware / workspaceTokenAuthMidddleware (triple-d — preserved typo). Confirm new oRPC routes use the authorized stack, not an unauthenticated handler.
- RAG/embedding queries: see the
rag-eval agent. A sourceId-only filter with no workspaceId predicate is a defense-in-depth gap.
- Repositories/services are the boundary — app/integration code must not reach
db directly (.agents/rules/data-access.md).
1b. Workspace-member permission enforcement
The per-member permission jsonb (WorkspaceMemberPermissions: superAdmin, analytics, flows, contacts, onlyAssignedContacts, emailAndPhone, broadcast, ecommerce) is enforced server-side across three layers that MUST stay in sync — a client-only check is bypassable:
- Route guard:
requireWorkspacePermission(workspaceId, key) / requireContactsAccess / resolveGuardedWorkspaceId in apps/builder/src/lib/auth/require-workspace-permission.ts — call notFound() on failure. Gate every new page/layout in a mapped segment (PERMISSION_NAV in lib/auth/permission-routes.ts). Note route groups split URL-equivalent routes (products/(e-commerce)/products), so both layouts need the guard.
- Nav filter:
app-sidebar.tsx hides items via hasWorkspacePermission (and canAccessContactsSection for the compound contacts gate).
- Data scope: the builder resolves scope once, in the app layer —
requireContactPermissionScope (or resolveContactPermissionScope) turns member permissions into plain scope/accessScope params (restrictToAssignedUserId, canViewEmailAndPhone), then passes them into contactService.list/count/findDetailOrFail. The service applies restrictToAssignedUserId as a conversation.assignedUserId row filter and masks email/phone when denied — it never re-derives scope itself, and never knows whether the caller was a member or a token. The CSV export mirrors this in the worker; canExportEmailAndPhone is a required job field so it can never fail open.
Invariants: hasWorkspacePermission treats missing jsonb keys as denied (fail-closed) and superAdmin bypasses every gate. isCommunity() normalizes stored permissions to full getSuperAdminPermissions() (no granular control in CE). invite/update/delete member actions require caller superAdmin. The workspace-token contacts surface (contactService.list called with no scope) is intentionally unscoped by member permissions — verify new token surfaces don't leak member-scoped data by calling the same service method the private path uses, with scope simply omitted.
1c. Workspace API tokens (docs/developer/workspace-api-tokens.md)
- Tokens are stored hash-only (SHA-256
tokenHash in WorkspaceApiToken); the sole plaintext-recoverable row is the isDefault token backing {{api_key}} (AES-GCM encryptedToken, AAD-bound to its workspace). Never persist, cache, or log a raw token or its hash — handler context only ever sees the projected RequestApiToken.
- Every workspace-token endpoint MUST use
workspaceTokenAuthAPIForScope("<scope>") — there is no unscoped stack export. permission: "read_only" tokens are limited to GET/HEAD in the middleware; mutations additionally pass the owner-quota gate.
- All bearer-credential material comes from
@chatbotx.io/business/workspace-api-token/credentials (CSPRNG). Flag any token/secret minted from Math.random()-backed helpers.
- Minting/revoking tokens requires workspace
superAdmin (requireWorkspaceTokenSuperAdmin) — a granular member must not escalate via a full token.
2. Prompt injection (untrusted channel content → agent context)
- Customer messages (WhatsApp/Messenger/webchat), uploaded documents, and fetched URLs are untrusted. When their content reaches an AI prompt or RAG context, it must be framed as data, not instructions (clear delimiters, "the following is user-provided content").
- Flag raw
content: row.content passthrough from a context-source adapter into a model prompt (apps/worker/src/integration/handlers/automated-response/system-tools/context-sources/, packages/ai/).
3. Tool / permission allowlist (.claude/settings.local.json)
- This file is git-ignored and local — but it is live in every agent session.
- Never put credential literals (
PGPASSWORD, DATABASE_URL with a real password) inside Bash(...) allow-patterns. Use env indirection.
- Never grant wildcard exec:
Bash(pnpm *), Bash(node *), Bash(python3 *), Bash(git *). Grant the specific commands you need (Bash(pnpm lint), Bash(pnpm --filter <x> test)).
- Never auto-approve
cp of any .env, or reads of ~//etc. Scope filesystem grants to the repo.
- Paths must be relative/repo-local, not machine-absolute, and must match this repo (
ChatbotX01).
4. Secret handling
- No secrets in tracked files.
.env* is never committed (.agents/rules/git.md, /commit guardrails).
- If a real credential is found in any file (even gitignored), rotate it out-of-band and remove it — being un-committed is not the same as being safe when it's live in agent context.
- MCP server: restrict CORS origins; accept tokens via
Authorization header, never URL query params (they get logged).
Stop condition
Produce a findings list (file:line | risk | severity | fix) ranked by severity. If a CRITICAL (secret-in-grant, wildcard exec, cross-tenant access, auth bypass) is found, state it first and recommend blocking the commit until fixed. If nothing security-relevant changed, say SECURITY: not applicable to this diff and stop — do not invent risks.
1---2name: security-review3description: Use before committing changes to auth, workspace scoping, channel webhooks, AI tools/MCP, permission settings, or anything handling untrusted channel content in ChatbotX. A repo-specific security checklist covering tenant isolation, prompt injection via channel content, the Bash permission allowlist, and secret handling. Read before security-sensitive work; pair with the global security-reviewer agent for deep dives.4---56# Security Review (ChatbotX)78A focused, repo-specific checklist. For OWASP-depth analysis dispatch the global `security-reviewer` agent; this skill is the ChatbotX-specific surface map and the things that bite here.910## 1. Tenant isolation (the #1 risk in a multi-workspace product)1112- Every query that returns workspace data MUST be scoped by `workspaceId`, OR by an id that is provably workspace-bound by construction (trace it).13- Builder APIs go through `workspaceAuthorizedMidddleware` / `workspaceTokenAuthMidddleware` (triple-d — preserved typo). Confirm new oRPC routes use the authorized stack, not an unauthenticated handler.14- RAG/embedding queries: see the `rag-eval` agent. A `sourceId`-only filter with no `workspaceId` predicate is a defense-in-depth gap.15- Repositories/services are the boundary — app/integration code must not reach `db` directly (`.agents/rules/data-access.md`).1617## 1b. Workspace-member permission enforcement1819The per-member permission jsonb (`WorkspaceMemberPermissions`: `superAdmin`, `analytics`, `flows`, `contacts`, `onlyAssignedContacts`, `emailAndPhone`, `broadcast`, `ecommerce`) is enforced server-side across three layers that MUST stay in sync — a client-only check is bypassable:2021- **Route guard:** `requireWorkspacePermission(workspaceId, key)` / `requireContactsAccess` / `resolveGuardedWorkspaceId` in `apps/builder/src/lib/auth/require-workspace-permission.ts` — call `notFound()` on failure. Gate every new page/layout in a mapped segment (`PERMISSION_NAV` in `lib/auth/permission-routes.ts`). Note route groups split URL-equivalent routes (`products`/`(e-commerce)/products`), so both layouts need the guard.22- **Nav filter:** `app-sidebar.tsx` hides items via `hasWorkspacePermission` (and `canAccessContactsSection` for the compound contacts gate).23- **Data scope:** the builder resolves scope once, in the app layer — `requireContactPermissionScope` (or `resolveContactPermissionScope`) turns member permissions into plain `scope`/`accessScope` params (`restrictToAssignedUserId`, `canViewEmailAndPhone`), then passes them into `contactService.list`/`count`/`findDetailOrFail`. The service applies `restrictToAssignedUserId` as a `conversation.assignedUserId` row filter and masks email/phone when denied — it never re-derives scope itself, and never knows whether the caller was a member or a token. The CSV export mirrors this in the worker; `canExportEmailAndPhone` is a **required** job field so it can never fail open.2425Invariants: `hasWorkspacePermission` treats missing jsonb keys as **denied** (fail-closed) and `superAdmin` bypasses every gate. `isCommunity()` normalizes stored permissions to full `getSuperAdminPermissions()` (no granular control in CE). `invite`/`update`/`delete` member actions require caller `superAdmin`. The workspace-token contacts surface (`contactService.list` called with no `scope`) is intentionally **unscoped** by member permissions — verify new token surfaces don't leak member-scoped data by calling the same service method the private path uses, with `scope` simply omitted.2627## 1c. Workspace API tokens (`docs/developer/workspace-api-tokens.md`)2829- Tokens are stored **hash-only** (SHA-256 `tokenHash` in `WorkspaceApiToken`); the sole plaintext-recoverable row is the `isDefault` token backing `{{api_key}}` (AES-GCM `encryptedToken`, AAD-bound to its workspace). Never persist, cache, or log a raw token or its hash — handler context only ever sees the projected `RequestApiToken`.30- Every workspace-token endpoint MUST use `workspaceTokenAuthAPIForScope("<scope>")` — there is no unscoped stack export. `permission: "read_only"` tokens are limited to GET/HEAD in the middleware; mutations additionally pass the owner-quota gate.31- All bearer-credential material comes from `@chatbotx.io/business/workspace-api-token/credentials` (CSPRNG). Flag any token/secret minted from `Math.random()`-backed helpers.32- Minting/revoking tokens requires workspace `superAdmin` (`requireWorkspaceTokenSuperAdmin`) — a granular member must not escalate via a `full` token.3334## 2. Prompt injection (untrusted channel content → agent context)3536- Customer messages (WhatsApp/Messenger/webchat), uploaded documents, and fetched URLs are **untrusted**. When their content reaches an AI prompt or RAG context, it must be framed as data, not instructions (clear delimiters, "the following is user-provided content").37- Flag raw `content: row.content` passthrough from a context-source adapter into a model prompt (`apps/worker/src/integration/handlers/automated-response/system-tools/context-sources/`, `packages/ai/`).3839## 3. Tool / permission allowlist (`.claude/settings.local.json`)4041- This file is git-ignored and local — but it is live in every agent session.42- **Never** put credential literals (`PGPASSWORD`, `DATABASE_URL` with a real password) inside `Bash(...)` allow-patterns. Use env indirection.43- **Never** grant wildcard exec: `Bash(pnpm *)`, `Bash(node *)`, `Bash(python3 *)`, `Bash(git *)`. Grant the specific commands you need (`Bash(pnpm lint)`, `Bash(pnpm --filter <x> test)`).44- **Never** auto-approve `cp` of any `.env`, or reads of `~`/`/etc`. Scope filesystem grants to the repo.45- Paths must be relative/repo-local, not machine-absolute, and must match this repo (`ChatbotX01`).4647## 4. Secret handling4849- No secrets in tracked files. `.env*` is never committed (`.agents/rules/git.md`, `/commit` guardrails).50- If a real credential is found in any file (even gitignored), **rotate it out-of-band** and remove it — being un-committed is not the same as being safe when it's live in agent context.51- MCP server: restrict CORS origins; accept tokens via `Authorization` header, never URL query params (they get logged).5253## Stop condition5455Produce a findings list (`file:line | risk | severity | fix`) ranked by severity. If a CRITICAL (secret-in-grant, wildcard exec, cross-tenant access, auth bypass) is found, state it first and recommend blocking the commit until fixed. If nothing security-relevant changed, say `SECURITY: not applicable to this diff` and stop — do not invent risks.