C1 CRITICAL: implement() looked up procedures/handlers with plain property indexing, so any Object.prototype member name (constructor, toString, etc.) resolved truthy and skipped the permission gate entirely. Fixed with Object.hasOwn checks in packages/rpc/src/server.ts. Defense-in-depth guard added in packages/dev-server/src/rpc-dispatch.ts constraining URL path segments to a safe charset before they reach service/procedure lookups. Added missing direct test coverage for packages/rpc/src/transport.ts, server.ts and client.ts (previously untested), including a prototype-name sweep in both server.test.ts and dev-server's rpc-endpoint.test.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
7.2 KiB
TypeScript
244 lines
7.2 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { v } from "@wrnexus/validation";
|
|
import { defineService, procedure } from "../src/contract.ts";
|
|
import { RPC_ERROR_CODES } from "../src/errors.ts";
|
|
import { exportSubjectContext } from "../src/identity.ts";
|
|
import { implement } from "../src/server.ts";
|
|
|
|
const original = { ...process.env };
|
|
afterEach(() => {
|
|
process.env = { ...original };
|
|
});
|
|
|
|
function configure(appName = "web") {
|
|
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
|
|
process.env.WRNEXUS_APP_NAME = appName;
|
|
}
|
|
|
|
const openContract = defineService({
|
|
name: "demo",
|
|
procedures: {
|
|
add: procedure
|
|
.input(v.object({ a: v.number() }))
|
|
.output<{ a: number }>()
|
|
.build(),
|
|
},
|
|
});
|
|
|
|
const guardedContract = defineService({
|
|
name: "billing",
|
|
procedures: {
|
|
createInvoice: procedure
|
|
.input(v.object({ amountCents: v.number() }))
|
|
.output<{ invoiceId: string }>()
|
|
.permission("invoice:create")
|
|
.build(),
|
|
},
|
|
});
|
|
|
|
describe("implement()", () => {
|
|
test("invokes with validated input", async () => {
|
|
const service = implement(
|
|
openContract,
|
|
{ add: async ({ a }) => ({ a: a + 1 }) },
|
|
{ selfApp: "demo" },
|
|
);
|
|
const result = await service.invoke("add", { a: 1 });
|
|
expect(result).toEqual({ ok: true, value: { a: 2 } });
|
|
});
|
|
|
|
test("coerces through the schema", async () => {
|
|
let received: unknown;
|
|
const coercing = defineService({
|
|
name: "demo2",
|
|
procedures: {
|
|
add: procedure
|
|
.input(v.object({ a: v.number() }))
|
|
.output<{ a: number }>()
|
|
.build(),
|
|
},
|
|
});
|
|
const service = implement(
|
|
coercing,
|
|
{
|
|
add: async (input) => {
|
|
received = input;
|
|
return { a: (input as { a: number }).a };
|
|
},
|
|
},
|
|
{ selfApp: "demo2" },
|
|
);
|
|
await service.invoke("add", { a: "3" });
|
|
expect(received).toEqual({ a: 3 });
|
|
});
|
|
|
|
test("rejects schema-invalid input without invoking the handler", async () => {
|
|
let called = false;
|
|
const service = implement(
|
|
openContract,
|
|
{
|
|
add: async ({ a }) => {
|
|
called = true;
|
|
return { a };
|
|
},
|
|
},
|
|
{ selfApp: "demo" },
|
|
);
|
|
const result = await service.invoke("add", { a: "not-a-number" });
|
|
expect(called).toBe(false);
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.invalid });
|
|
});
|
|
|
|
test("unknown procedure refused", async () => {
|
|
const service = implement(openContract, { add: async ({ a }) => ({ a }) }, { selfApp: "demo" });
|
|
const result = await service.invoke("subtract", {});
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown });
|
|
});
|
|
|
|
test("handler throw becomes opaque; a secret in the message does not survive", async () => {
|
|
const secret = "sk-super-secret-db-password";
|
|
const service = implement(
|
|
openContract,
|
|
{
|
|
add: async () => {
|
|
throw new Error(`db error using ${secret}`);
|
|
},
|
|
},
|
|
{ selfApp: "demo" },
|
|
);
|
|
const result = await service.invoke("add", { a: 1 });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.code).toBe(RPC_ERROR_CODES.handler);
|
|
expect(result.message).not.toContain(secret);
|
|
}
|
|
});
|
|
|
|
test("a declared permission is enforced before the handler runs", async () => {
|
|
let called = false;
|
|
const service = implement(
|
|
guardedContract,
|
|
{
|
|
createInvoice: async ({ amountCents }) => {
|
|
called = true;
|
|
return { invoiceId: `inv_${amountCents}` };
|
|
},
|
|
},
|
|
{ selfApp: "billing", checkPermission: async () => false },
|
|
);
|
|
const result = await service.invoke("createInvoice", { amountCents: 5 });
|
|
expect(called).toBe(false);
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied });
|
|
});
|
|
|
|
test("a permission check that throws denies", async () => {
|
|
let called = false;
|
|
const service = implement(
|
|
guardedContract,
|
|
{
|
|
createInvoice: async ({ amountCents }) => {
|
|
called = true;
|
|
return { invoiceId: `inv_${amountCents}` };
|
|
},
|
|
},
|
|
{
|
|
selfApp: "billing",
|
|
checkPermission: async () => {
|
|
throw new Error("permission store unavailable");
|
|
},
|
|
},
|
|
);
|
|
const result = await service.invoke("createInvoice", { amountCents: 5 });
|
|
expect(called).toBe(false);
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied });
|
|
});
|
|
|
|
test("a declared permission with no checkPermission configured denies (fail closed)", async () => {
|
|
let called = false;
|
|
const service = implement(
|
|
guardedContract,
|
|
{
|
|
createInvoice: async ({ amountCents }) => {
|
|
called = true;
|
|
return { invoiceId: `inv_${amountCents}` };
|
|
},
|
|
},
|
|
{ selfApp: "billing" },
|
|
);
|
|
const result = await service.invoke("createInvoice", { amountCents: 5 });
|
|
expect(called).toBe(false);
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.denied });
|
|
});
|
|
|
|
test("a valid identity token reaches the handler as a subject", async () => {
|
|
configure("web");
|
|
const token = await exportSubjectContext(
|
|
{ user: { id: "u1" }, locals: {} } as never,
|
|
"billing",
|
|
);
|
|
const service = implement(
|
|
guardedContract,
|
|
{
|
|
createInvoice: async ({ amountCents }, ctx) => ({
|
|
invoiceId: `${ctx.subject?.subjectId}_${amountCents}`,
|
|
}),
|
|
},
|
|
{ selfApp: "billing", checkPermission: async () => true },
|
|
);
|
|
const result = await service.invoke("createInvoice", { amountCents: 5 }, token);
|
|
expect(result).toEqual({ ok: true, value: { invoiceId: "u1_5" } });
|
|
});
|
|
|
|
test("a bad identity token is refused rather than downgraded to anonymous", async () => {
|
|
configure("web");
|
|
let called = false;
|
|
const service = implement(
|
|
openContract,
|
|
{
|
|
add: async ({ a }, ctx) => {
|
|
called = true;
|
|
expect(ctx.subject).toBeUndefined();
|
|
return { a };
|
|
},
|
|
},
|
|
{ selfApp: "demo" },
|
|
);
|
|
const result = await service.invoke("add", { a: 1 }, "garbage-token");
|
|
expect(called).toBe(false);
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.identity });
|
|
});
|
|
|
|
describe("C1: prototype-chain procedure names cannot bypass the permission gate", () => {
|
|
const PROTO_NAMES = [
|
|
"constructor",
|
|
"toString",
|
|
"valueOf",
|
|
"hasOwnProperty",
|
|
"__proto__",
|
|
"isPrototypeOf",
|
|
];
|
|
|
|
for (const name of PROTO_NAMES) {
|
|
test(`"${name}" resolves as unknown, not as a handler`, async () => {
|
|
let permissionChecked = false;
|
|
const service = implement(
|
|
guardedContract,
|
|
{
|
|
createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }),
|
|
},
|
|
{
|
|
selfApp: "billing",
|
|
checkPermission: async () => {
|
|
permissionChecked = true;
|
|
return false;
|
|
},
|
|
},
|
|
);
|
|
const result = await service.invoke(name, { amountCents: 1 });
|
|
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.unknown });
|
|
expect(permissionChecked).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
});
|