RPC Docs.

Prim+RPC is prerelease software. It may be unstable and functionality may change prior to full release.

Security

Prim+RPC does not have a stable release and should not yet be used in production applications. Please report any security issues that you find according to the security policy.

Prim+RPC's goal is simple: write a function on the server and call it from the client. Once Prim+RPC is set up, it is incredibly easy to add new functions to the backend. However caution should be taken since input given to the server cannot be trusted. Tasks such as validation, sanitation, authentication, and more are outside of Prim+RPC's responsibilities and are left to the developer (and the libraries that you choose).

Table of Contents

Set Allowed Functions as RPC

When you pass a function to the .module option of Prim+RPC, it is intended to become RPC. By default however you will be denied access to execute the function remotely on the client until you explicitly mark it as an RPC. This is done in one of two ways: by setting the .rpc property on the function to true or by adding your function to the .allowList option of the Prim+RPC server.


import { createPrimServer } from "@doseofted/prim-rpc"
// This can be called from the client because we set the `.rpc` property to `true`
function myPublicFunction() {
return "I'm allowed to be called from the client"
}
myPublicFunction.rpc = true
// This cannot be called from the client even though we passed it to the client
// (note: type definitions are still shared because is is given to server below)
function myPrivateFunction() {
return "I'm only allowed to be called on the server"
}
// While we cannot add an `.rpc` property directly, we can add it to the allow list below
// We could alternatively create a wrapper function with an `.rpc` property that calls `myFrozenFunction()`
function myFrozenFunction() {
return "I'm allowed to be called from the client"
}
Object.freeze(myFrozenFunction)
createPrimServer({
module: { myPublicFunction, myPrivateFunction, myFrozenFunction },
allowList: { myFrozenFunction: true },
})

Don't Trust Arguments

Prim+RPC does not validate or sanitize arguments passed to the server. It is up to the developer to ensure that the arguments provided are of the expected type and shape. You may choose a validation library of your choice to accomplish this. Without a validation library, it should be expected that types given from the client could not match your type definitions. Consider the following:

server.ts

import { subscribeToNewsLetter } from "./my-newsletter-service"
interface FormInputs {
email: string
subscribe: boolean
}
// this is only a simple demonstration
function submitForm(form: FormInputs) {
return form.subscribe ? subscribeToNewsLetter(form.email) : false
}
submitForm.rpc = true
createPrimServer({ module: { submitForm } })

client.ts

import { backend } from "./created-prim-client"
// backend was given truthy value so the email will be subscribed without their consent
await backend.submitForm({ email: "ted@example.com", subscribe: "no" })

In this example a user is subscribed without their consent because the string no is a truthy value. This can be avoided with validation of given arguments. You may choose any library that you'd like to validate arguments. We'll use Zod in this example but you could also use libraries like TypeBox, ArkType, or many others. Below is a safer example:


import { subscribeToNewsLetter } from "./my-newsletter-service"
import { z } from "zod"
const formInputsSchema = z.object({
email: z.string().trim().email(),
subscribe: z.boolean().default(false),
})
// this is only a simple demonstration
export function submitForm(givenForm: z.infer<typeof formInputsSchema>) {
const form = formInputsSchema.parse(givenForm)
return form.subscribe ? subscribeToNewsLetter(form.email) : false
}
submitForm.rpc = true
createPrimServer({ module: { submitForm } })

Now when we submit this form, we will be presented an error because the client did not provide the boolean value that is expected. If you're coming from tRPC, you may consider using Zod's syntax for defining a function (which bears some resemblance to defining a tRPC router):


import { subscribeToNewsLetter } from "./my-newsletter-service"
import { z } from "zod"
// this is only a simple demonstration
const submitForm = z
.function()
.args(
z.object({
email: z.string().trim().email(),
subscribe: z.boolean().default(false),
})
)
.returns(z.boolean())
.implements(form => {
return form.subscribe ? subscribeToNewsLetter(form.email) : false
})
submitForm.rpc = true
createPrimServer({ module: { submitForm } })

Watch Your Imports

Prim+RPC will not allow functions to be called unless they have a property .rpc set to true or the function names are given in an .allowList. This means that if you accidentally pass a function to the server that wasn't intended, it still can't be called. However you should be cautious with the type definitions of the given module, especially if your type exports use the typeof operator on your module. Consider the following:

module.ts

import { mailingClient } from "./my-mailing-client"
export const settings = {
/** Some example API key */
mailingApiKey: process.env.MAILING_API_KEY,
}
export function sendEmail(to: string, subject: string, body: string) {
// for example only, of course: validation, sanitation, rate-limiting, bot-checks, etc. excluded
return mailingClient.send({ to, subject, body })
}
sendEmail.rpc = true

server.ts

import { createPrimServer } from "@doseofted/prim-rpc"
import * as module from "./module"
createPrimServer({ module })
export type { module }

In this example, the server is given the module object which includes the settings that were exported. While this is definitely a problem (settings and environment variables should not be exported like this, regardless of whether you are using Prim+RPC), at least the values are not shared directly with the Prim+RPC client.

client.ts

import { createPrimClient } from "@doseofted/prim-rpc"
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser"
import type { module } from "../my-server-code/server"
const client = createPrimClient<typeof module>({
endpoint: "http://website.localhost:3000",
methodPlugin: createMethodPlugin(),
})
// this is allowed because `.rpc` is set to `true`
client.sendEmail("ted@example.com", "Hello!", "How are you?")
// this will fail
const mailingKey = client.settings.mailingApiKey()

The email will send in this example but the mailingApiKey will fail as it's not a function and not marked as RPC. However, we've also exported the types of the module including client.settings.mailingApiKey. While we can't see the value of the secret, we now know where the secret is located. Security through obscurity is not security but we certainly don't need to tell anyone where our secrets are located.

While exporting secrets is not recommended, it's more likely that you may need to export an internal function for usage in your server and it could be collocated with a function intended as RPC. Extra caution should be taken with exports when using that module with Prim+RPC so that TypeScript types of those functions aren't shared (even if the functions themselves are not shared).

There are a few ways to tackle this. The easiest is not to export secret information or internal logic but this may not always be possible. Another possible solution: you could use a file naming scheme to make clear what is internal and what is external (marked RPC). For example, you may export functions as usual and use another file public.ts that only re-exports functions marked as RPC.

server/private.ts

const settings = {
/** Some example API key */
mailingApiKey: process.env.MAILING_API_KEY,
}
export function sendEmailPrivately() {
// ... sends email directly, for usage on server and not subject to rate limiting
return { success: true }
}
sendEmail.rpc = true

server/public.ts

import { sendEmailPrivately } from "./server/private"
export function sendEmail() {
// ... sends email with with rate limiting, bot-checks, etc.
const { success } = sendEmailPrivately()
return success
}
sendEmail.rpc = true

As long as we use the server/public.ts module/types with the Prim+RPC server, we can avoid exposing the sendEmailPrivately types to the client.

Consider the JSON Handler

By default, Prim+RPC will use the environment's default JSON handler for serialization and unjs/destr for deserialization (which provides the benefit of protection from prototype pollution while behaving predictably).

You may override the JSON handler with your own as you'd like. This may provide support for additional types or may even be used to serialize to a format other than JSON. However be aware that a new handler could introduce new security issues and this should be considered, especially if RPC is intended to be shared over public channels.


import { createPrimClient, createPrimServer } from "@doseofted/prim-rpc"
import jsonHandler from "superjson"
// superjson could be used, for example: it also guards against attacks such as prototype pollution.
createPrimClient({ jsonHandler })
createPrimServer({ jsonHandler })

Secure the Transport

Prim+RPC keeps a narrow scope: it handles RPC but it utilizes separate plugins for transport. This means that you can choose any transport that you'd like by using an available plugin or creating your own but it also means that the security of the transport falls outside of Prim+RPC's scope. Securing the transport may mean something different depending on the environment. For instance, on a web client, you may consider the possibility of XSS. If you're using Prim+RPC on a web server then you may consider using TLS, CORS headers, CSRF tokens, rate limiting, authentication, and other means. These are good practices in general for an application and are not specific to Prim+RPC.

Prim+RPC: a project by Ted Klingenberg

Dose of Ted

Anonymous analytics collected with Ackee