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