From b9040cb3280e42eb15f96af0f817b95a3b987fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 17 Apr 2026 09:28:02 +0200 Subject: [PATCH] test(security): scoped-caller forwarding preserves read-only proxy (#47) Adds a regression suite asserting that the read-only Prisma proxy is still in effect after a tool's executor forwards ctx.db into a scoped tRPC caller (helpers.ts::createScopedCallerContext). Covers all three attack surfaces: model writes, raw-SQL escape hatches, and interactive $transaction / $runCommandRaw calls. These tests pin the behaviour enforced by 1ff5c33; any future refactor that unwraps the proxy during forwarding will fail this suite. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/read-only-scoped-caller.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/api/src/__tests__/read-only-scoped-caller.test.ts diff --git a/packages/api/src/__tests__/read-only-scoped-caller.test.ts b/packages/api/src/__tests__/read-only-scoped-caller.test.ts new file mode 100644 index 0000000..2586c52 --- /dev/null +++ b/packages/api/src/__tests__/read-only-scoped-caller.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { createReadOnlyProxy } from "../lib/read-only-prisma.js"; + +/** + * Ticket #47 — read-only proxy must survive the scoped-caller indirection. + * + * assistant-tools.ts::executeTool swaps `ctx.db` for a read-only proxy when + * dispatching non-mutation tools. Tool executors then call + * `createScopedCallerContext(ctx)` which forwards `ctx.db` to a tRPC caller. + * If the proxy were not preserved through that forwarding, an LLM-invoked + * "read" tool could smuggle writes via the caller path. + * + * This suite asserts the proxy is not unwrapped on forwarding, and that + * every write-flavoured client method (model writes, raw SQL, interactive + * transactions, runCommandRaw) is still blocked after forwarding. + */ +describe("read-only proxy survives scoped-caller forwarding (#47)", () => { + function makeFakeClient() { + // Minimal shape that passes the Proxy's model detection (has findMany). + const user = { + findUnique: async () => ({ id: "u1" }), + findMany: async () => [], + create: async () => ({ id: "u1" }), + update: async () => ({ id: "u1" }), + }; + return { + user, + $queryRaw: async () => [], + $queryRawUnsafe: async () => [], + $executeRaw: async () => 0, + $executeRawUnsafe: async () => 0, + $transaction: async () => [], + $runCommandRaw: async () => ({ ok: 1 }), + }; + } + + // Simulate what createScopedCallerContext does: construct a NEW object + // whose `db` key is assigned from the incoming ctx.db. This is the exact + // forwarding pattern used by helpers.ts::createScopedCallerContext. + function forwardToCaller(ctx: { db: unknown }): { db: unknown } { + return { db: ctx.db }; + } + + it("ctx.db retains proxy identity after forwarding", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = makeFakeClient() as any; + const proxied = createReadOnlyProxy(client); + const forwarded = forwardToCaller({ db: proxied }); + // Writes through the forwarded db must still throw. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (forwarded.db as any).user.create({ data: {} })).toThrow( + /not permitted on read-only/, + ); + }); + + it("raw/tx escape hatches still blocked after forwarding", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = makeFakeClient() as any; + const proxied = createReadOnlyProxy(client); + const forwarded = forwardToCaller({ db: proxied }) as { db: Record }; + + expect(() => forwarded.db.$executeRaw!`DELETE FROM users`).toThrow( + /Raw\/escape operation "\$executeRaw" not permitted/, + ); + expect(() => forwarded.db.$executeRawUnsafe!("DELETE FROM users")).toThrow( + /Raw\/escape operation "\$executeRawUnsafe" not permitted/, + ); + expect(() => forwarded.db.$queryRawUnsafe!("SELECT 1")).toThrow( + /Raw\/escape operation "\$queryRawUnsafe" not permitted/, + ); + expect(() => forwarded.db.$transaction!([])).toThrow( + /Raw\/escape operation "\$transaction" not permitted/, + ); + expect(() => forwarded.db.$runCommandRaw!({})).toThrow( + /Raw\/escape operation "\$runCommandRaw" not permitted/, + ); + }); + + it("reads still succeed after forwarding (positive control)", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = makeFakeClient() as any; + const proxied = createReadOnlyProxy(client); + const forwarded = forwardToCaller({ db: proxied }) as { + db: { user: { findUnique: (a: unknown) => Promise } }; + }; + + await expect(forwarded.db.user.findUnique({ where: { id: "u1" } })).resolves.toEqual({ + id: "u1", + }); + }); +});