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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, Function> };
|
||||
|
||||
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<unknown> } };
|
||||
};
|
||||
|
||||
await expect(forwarded.db.user.findUnique({ where: { id: "u1" } })).resolves.toEqual({
|
||||
id: "u1",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user