fix(rpc): close prototype-chain permission bypass, add server/client/transport tests
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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { v } from "@wrnexus/validation";
|
||||
import { serviceClient } from "../src/client.ts";
|
||||
import { defineService, procedure } from "../src/contract.ts";
|
||||
import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts";
|
||||
import type { RpcTarget } from "../src/transport.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 billing = defineService({
|
||||
name: "billing",
|
||||
procedures: {
|
||||
createInvoice: procedure
|
||||
.input(v.object({ amountCents: v.number() }))
|
||||
.output<{ invoiceId: string }>()
|
||||
.build(),
|
||||
},
|
||||
});
|
||||
|
||||
describe("serviceClient", () => {
|
||||
test("returns the handler's value unwrapped", async () => {
|
||||
const client = serviceClient(billing, {
|
||||
transport: { call: async () => success({ invoiceId: "inv_1" }) },
|
||||
});
|
||||
expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" });
|
||||
});
|
||||
|
||||
test("throws a ServiceError carrying code and retryable on failure", async () => {
|
||||
const client = serviceClient(billing, {
|
||||
transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") },
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.createInvoice({ amountCents: 1 });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ServiceError);
|
||||
expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true });
|
||||
});
|
||||
|
||||
test("attaches an identity token when given a context and omits it for an anonymous context", async () => {
|
||||
configure("web");
|
||||
let seenIdentity: string | undefined = "unset";
|
||||
const client = serviceClient(billing, {
|
||||
as: { user: { id: "u1" }, locals: {} } as unknown as Context,
|
||||
transport: {
|
||||
call: async (_target, _payload, options) => {
|
||||
seenIdentity = options.identity;
|
||||
return success({ invoiceId: "inv_1" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.createInvoice({ amountCents: 1 });
|
||||
expect(seenIdentity).toBeTypeOf("string");
|
||||
|
||||
let seenAnon: unknown = "unset";
|
||||
const anonClient = serviceClient(billing, {
|
||||
transport: {
|
||||
call: async (_target, _payload, options) => {
|
||||
seenAnon = options.identity;
|
||||
return success({ invoiceId: "inv_1" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await anonClient.createInvoice({ amountCents: 1 });
|
||||
expect(seenAnon).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calling an undeclared procedure throws rather than issuing a call", async () => {
|
||||
let called = false;
|
||||
const client = serviceClient(billing, {
|
||||
transport: {
|
||||
call: async () => {
|
||||
called = true;
|
||||
return success({});
|
||||
},
|
||||
},
|
||||
});
|
||||
const proxy = client as unknown as Record<string, (input: unknown) => Promise<unknown>>;
|
||||
await expect(proxy.deleteEverything!({})).rejects.toMatchObject({
|
||||
code: RPC_ERROR_CODES.unknown,
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test("the app defaults to the service name", async () => {
|
||||
let seenTarget: RpcTarget | undefined;
|
||||
const client = serviceClient(billing, {
|
||||
transport: {
|
||||
call: async (target) => {
|
||||
seenTarget = target;
|
||||
return success({ invoiceId: "inv_1" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.createInvoice({ amountCents: 1 });
|
||||
expect(seenTarget?.app).toBe("billing");
|
||||
expect(seenTarget?.service).toBe("billing");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user