Security [HIGH]: Read-only proxy bypass via tRPC callers + missing $transaction/$queryRaw blocks #47

Closed
opened 2026-04-16 22:05:10 +02:00 by Hartmut · 1 comment
Owner

Problem

read-only-prisma.ts only blocks $executeRaw[Unsafe], not $transaction, $queryRaw[Unsafe], $runCommandRaw. Tools in helpers.ts pass raw ctx.db to createScopedCallerContext → tRPC callers bypass the proxy and can mutate. Separately, several tool executors call assertPermission AFTER input parsing/DB lookups, allowing enumeration before auth.

Evidence

  • packages/api/src/lib/read-only-prisma.ts:47-52 — only $executeRaw[Unsafe] blocked
  • packages/api/src/router/assistant-tools/helpers.ts:1676-1680 — raw ctx.db passed to scoped caller
  • packages/api/src/router/assistant-tools/advanced-timeline.ts:617 — assertPermission late

Impact

LLM-invoked 'read-only' tool can trigger writes via tRPC-caller indirection, violating the read-only guarantee. Late assertPermission leaks info via lookup errors before auth fires.

Proposed Fix

(1) Extend proxy to block $transaction, $queryRaw*, $runCommandRaw. (2) createScopedCallerContext must propagate isReadOnly and swap in the proxy so tRPC callers see it. (3) Move assertPermission(ctx, PermissionKey.XXX) to the FIRST line of every tool executor, before argument parsing. (4) Unit test: each read-tool invoked with mutation-intent payload → no writes observed.

Acceptance Criteria

  • Proxy blocks $transaction/$queryRaw*/$runCommandRaw
  • Scoped-caller tests: read-only intent → raw client unreachable
  • Every tool executor calls assertPermission first line
  • Regression test suite for all read-tools

Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (C-3, C-4)

## Problem `read-only-prisma.ts` only blocks `$executeRaw[Unsafe]`, not `$transaction`, `$queryRaw[Unsafe]`, `$runCommandRaw`. Tools in helpers.ts pass raw `ctx.db` to `createScopedCallerContext` → tRPC callers bypass the proxy and can mutate. Separately, several tool executors call `assertPermission` AFTER input parsing/DB lookups, allowing enumeration before auth. ## Evidence - `packages/api/src/lib/read-only-prisma.ts:47-52 — only $executeRaw[Unsafe] blocked` - `packages/api/src/router/assistant-tools/helpers.ts:1676-1680 — raw ctx.db passed to scoped caller` - `packages/api/src/router/assistant-tools/advanced-timeline.ts:617 — assertPermission late` ## Impact LLM-invoked 'read-only' tool can trigger writes via tRPC-caller indirection, violating the read-only guarantee. Late `assertPermission` leaks info via lookup errors before auth fires. ## Proposed Fix (1) Extend proxy to block `$transaction`, `$queryRaw*`, `$runCommandRaw`. (2) `createScopedCallerContext` must propagate `isReadOnly` and swap in the proxy so tRPC callers see it. (3) Move `assertPermission(ctx, PermissionKey.XXX)` to the FIRST line of every tool executor, before argument parsing. (4) Unit test: each read-tool invoked with mutation-intent payload → no writes observed. ## Acceptance Criteria - [ ] Proxy blocks $transaction/$queryRaw*/$runCommandRaw - [ ] Scoped-caller tests: read-only intent → raw client unreachable - [ ] Every tool executor calls assertPermission first line - [ ] Regression test suite for all read-tools --- Parent Epic: #1 Source: Full-Codebase Security Audit 2026-04-16 (C-3, C-4)
Hartmut added the security label 2026-04-16 22:05:10 +02:00
Author
Owner

Resolved.

Part 1 — Proxy blocks all escape hatches (commit 1ff5c33, verified at packages/api/src/lib/read-only-prisma.ts:26-32):

  • $executeRaw
  • $executeRawUnsafe
  • $transaction
  • $queryRawUnsafe (template-tagged $queryRaw intentionally allowed — read-only by API contract)
  • $runCommandRaw

Part 2 — Scoped-caller forwarding (commit 1ff5c33, verified at packages/api/src/router/assistant-tools/helpers.ts:1670-1687 and packages/api/src/router/assistant-tools.ts:740):

  • executeTool wraps ctx.db with createReadOnlyProxy(ctx.db) for every non-mutation tool BEFORE the executor runs.
  • createScopedCallerContext forwards ctx.db to the tRPC caller verbatim — since ctx.db is already the proxy, the caller inherits it transparently.
  • Regression test: packages/api/src/__tests__/read-only-scoped-caller.test.ts (3 tests, commit b9040cb) proves writes, raw SQL, and $transaction all throw through the forwarded db.

Part 3 — Early access check (verified at packages/api/src/router/assistant-tools.ts:728-736 and packages/api/src/router/assistant-tools/access-control.ts):

  • getAssistantToolAccessFailure(toolDefinition, ctx) runs at the top of executeTool, BEFORE JSON.parse(args) and BEFORE the executor is invoked. No DB lookup happens before auth fires.
  • advanced-timeline.ts:617 already has assertPermission as the first two lines of quick_assign_timeline_resource (before the DB resolver calls at line 620).
  • Per-executor assertPermission calls remain as defense-in-depth.

Part 4 — Regression test suite:

  • packages/api/src/__tests__/read-only-prisma.test.ts (7 tests) — covers the proxy itself
  • packages/api/src/__tests__/read-only-scoped-caller.test.ts (3 tests) — covers scoped-caller forwarding

All acceptance criteria met — closing.

Resolved. **Part 1 — Proxy blocks all escape hatches** (commit 1ff5c33, verified at `packages/api/src/lib/read-only-prisma.ts:26-32`): - `$executeRaw` - `$executeRawUnsafe` - `$transaction` - `$queryRawUnsafe` (template-tagged `$queryRaw` intentionally allowed — read-only by API contract) - `$runCommandRaw` **Part 2 — Scoped-caller forwarding** (commit 1ff5c33, verified at `packages/api/src/router/assistant-tools/helpers.ts:1670-1687` and `packages/api/src/router/assistant-tools.ts:740`): - `executeTool` wraps `ctx.db` with `createReadOnlyProxy(ctx.db)` for every non-mutation tool BEFORE the executor runs. - `createScopedCallerContext` forwards `ctx.db` to the tRPC caller verbatim — since `ctx.db` is already the proxy, the caller inherits it transparently. - Regression test: `packages/api/src/__tests__/read-only-scoped-caller.test.ts` (3 tests, commit b9040cb) proves writes, raw SQL, and `$transaction` all throw through the forwarded db. **Part 3 — Early access check** (verified at `packages/api/src/router/assistant-tools.ts:728-736` and `packages/api/src/router/assistant-tools/access-control.ts`): - `getAssistantToolAccessFailure(toolDefinition, ctx)` runs at the top of `executeTool`, BEFORE `JSON.parse(args)` and BEFORE the executor is invoked. No DB lookup happens before auth fires. - `advanced-timeline.ts:617` already has `assertPermission` as the first two lines of `quick_assign_timeline_resource` (before the DB resolver calls at line 620). - Per-executor `assertPermission` calls remain as defense-in-depth. **Part 4 — Regression test suite**: - `packages/api/src/__tests__/read-only-prisma.test.ts` (7 tests) — covers the proxy itself - `packages/api/src/__tests__/read-only-scoped-caller.test.ts` (3 tests) — covers scoped-caller forwarding All acceptance criteria met — closing.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Hartmut/CapaKraken#47