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,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { RPC_ERROR_CODES, success } from "../src/errors.ts";
|
||||
import { inProcessTransport } from "../src/transport.ts";
|
||||
|
||||
describe("inProcessTransport", () => {
|
||||
test("routes to the right handler and passes identity through", async () => {
|
||||
let seenIdentity: string | undefined;
|
||||
const transport = inProcessTransport({
|
||||
"billing/createInvoice": (payload, identity) => {
|
||||
seenIdentity = identity;
|
||||
return success({ echoed: payload });
|
||||
},
|
||||
});
|
||||
const result = await transport.call(
|
||||
{ app: "billing", service: "billing", procedure: "createInvoice" },
|
||||
{ amountCents: 1 },
|
||||
{ identity: "tok-123" },
|
||||
);
|
||||
expect(result).toEqual({ ok: true, value: { echoed: { amountCents: 1 } } });
|
||||
expect(seenIdentity).toBe("tok-123");
|
||||
});
|
||||
|
||||
test("an unregistered procedure yields non-retryable RPC_UNKNOWN", async () => {
|
||||
const transport = inProcessTransport({});
|
||||
const result = await transport.call(
|
||||
{ app: "billing", service: "billing", procedure: "nope" },
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: RPC_ERROR_CODES.unknown,
|
||||
message: "Unknown procedure",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("an already-aborted signal fails without invoking the handler", async () => {
|
||||
let called = false;
|
||||
const transport = inProcessTransport({
|
||||
"billing/createInvoice": () => {
|
||||
called = true;
|
||||
return success({});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await transport.call(
|
||||
{ app: "billing", service: "billing", procedure: "createInvoice" },
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result).toMatchObject({ code: RPC_ERROR_CODES.transport });
|
||||
});
|
||||
|
||||
test("a handler that throws becomes an opaque failure with no leaked message text", async () => {
|
||||
const secret = "sk-super-secret-database-password-xyz";
|
||||
const transport = inProcessTransport({
|
||||
"billing/createInvoice": () => {
|
||||
throw new Error(`connection failed with credential ${secret}`);
|
||||
},
|
||||
});
|
||||
const result = await transport.call(
|
||||
{ app: "billing", service: "billing", procedure: "createInvoice" },
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(RPC_ERROR_CODES.handler);
|
||||
expect(result.message).not.toContain(secret);
|
||||
expect(result.message).not.toContain("connection failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user