Skip to content

Commit 2f2dbaa

Browse files
authored
feat(client, server): simple csrf protection plugin (#346)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new CSRF protection plugin that enhances security by validating token authenticity. - Added a sidebar link for easy access to the CSRF protection documentation. - **Documentation** - Updated the Batch Request/Response Plugin docs to clarify support for custom implementations. - Released detailed setup and integration instructions for the new CSRF protection plugin. - Added new documentation for the Simple CSRF Protection Plugin. - **Tests** - Added comprehensive tests to verify CSRF protection functionality on both client and server sides. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 350a165 commit 2f2dbaa

File tree

9 files changed

+367
-2
lines changed

9 files changed

+367
-2
lines changed

apps/content/.vitepress/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default defineConfig({
112112
{ text: 'Batch Request/Response', link: '/docs/plugins/batch-request-response' },
113113
{ text: 'Client Retry', link: '/docs/plugins/client-retry' },
114114
{ text: 'Body Limit', link: '/docs/plugins/body-limit' },
115+
{ text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' },
115116
],
116117
},
117118
{

apps/content/docs/plugins/batch-request-response.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const handler = new RPCHandler(router, {
2929
```
3030

3131
::: info
32-
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler) or [OpenAPIHandler](/docs/openapi/openapi-handler). Note that this plugin uses its own protocol for batching requests and responses, which is different from the handler’s native protocol.
32+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler) or custom implementations. Note that this plugin uses its own protocol for batching requests and responses, which is different from the handler’s native protocol.
3333
:::
3434

3535
### Client
@@ -38,8 +38,9 @@ To use the `BatchLinkPlugin`, define at least one group. Requests within the sam
3838

3939
```ts twoslash
4040
import { RPCLink } from '@orpc/client/fetch'
41-
import { BatchLinkPlugin } from '@orpc/client/plugins'
4241
// ---cut---
42+
import { BatchLinkPlugin } from '@orpc/client/plugins'
43+
4344
const link = new RPCLink({
4445
url: 'https://api.example.com/rpc',
4546
plugins: [
@@ -55,6 +56,10 @@ const link = new RPCLink({
5556
})
5657
```
5758

59+
::: info
60+
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
61+
:::
62+
5863
## Limitations
5964

6065
The plugin does not support [AsyncIteratorObject](/docs/rpc-handler#supported-data-types) or [File/Blob](/docs/rpc-handler#supported-data-types) in responses (requests will auto fall back to the default behavior). To exclude unsupported procedures, use the `exclude` option:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
title: Simple CSRF Protection Plugin
3+
description: Simple CSRF Protection plugin for oRPC.
4+
---
5+
6+
# Simple CSRF Protection
7+
8+
This plugin adds basic Cross-Site Request Forgery (CSRF) protection to your oRPC application. It helps ensure that requests to your procedures originate from JavaScript code, not from other sources like standard HTML forms or direct browser navigation.
9+
10+
## When to Use
11+
12+
This plugin is beneficial if your application stores sensitive data (like session or auth tokens) in Cookie storage using `SameSite=Lax` (the default) or `SameSite=None`.
13+
14+
## Setup
15+
16+
This plugin requires configuration on both the server and client sides.
17+
18+
### Server
19+
20+
```ts twoslash
21+
import { RPCHandler } from '@orpc/server/fetch'
22+
import { router } from './shared/planet'
23+
// ---cut---
24+
import { SimpleCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
25+
26+
const handler = new RPCHandler(router, {
27+
plugins: [
28+
new SimpleCsrfProtectionHandlerPlugin()
29+
],
30+
})
31+
```
32+
33+
::: info
34+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or custom implementations.
35+
:::
36+
37+
### Client
38+
39+
```ts twoslash
40+
import { RPCLink } from '@orpc/client/fetch'
41+
// ---cut---
42+
import { SimpleCsrfProtectionLinkPlugin } from '@orpc/client/plugins'
43+
44+
const link = new RPCLink({
45+
url: 'https://api.example.com/rpc',
46+
plugins: [
47+
new SimpleCsrfProtectionLinkPlugin(),
48+
],
49+
})
50+
```
51+
52+
::: info
53+
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
54+
:::

packages/client/src/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './batch'
22
export * from './retry'
3+
export * from './simple-csrf-protection'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { RPCHandler } from '../../../server/src/adapters/fetch/rpc-handler'
2+
import { os } from '../../../server/src/builder'
3+
import { SimpleCsrfProtectionHandlerPlugin } from '../../../server/src/plugins/simple-csrf-protection'
4+
import { RPCLink } from '../adapters/fetch'
5+
import { SimpleCsrfProtectionLinkPlugin } from './simple-csrf-protection'
6+
7+
describe('simpleCsrfProtectionLinkPlugin', () => {
8+
const handler = new RPCHandler({
9+
ping: os.handler(() => 'pong'),
10+
}, {
11+
plugins: [
12+
new SimpleCsrfProtectionHandlerPlugin(),
13+
],
14+
})
15+
16+
const link = new RPCLink({
17+
url: new URL('http://localhost/prefix'),
18+
fetch: async (request) => {
19+
const { response } = await handler.handle(request, { prefix: '/prefix' })
20+
21+
return response ?? new Response(null, {
22+
status: 500,
23+
})
24+
},
25+
plugins: [
26+
new SimpleCsrfProtectionLinkPlugin(),
27+
],
28+
})
29+
30+
it('should work', async () => {
31+
await expect(
32+
link.call(['ping'], 'input', { context: {} }),
33+
).resolves.toEqual('pong')
34+
})
35+
36+
it('can exclude procedure', async () => {
37+
const exclude = vi.fn(() => true)
38+
39+
const link = new RPCLink({
40+
url: new URL('http://localhost/prefix'),
41+
fetch: async (request) => {
42+
const { response } = await handler.handle(request, { prefix: '/prefix' })
43+
44+
return response ?? new Response(null, {
45+
status: 500,
46+
})
47+
},
48+
plugins: [
49+
new SimpleCsrfProtectionLinkPlugin({ exclude }),
50+
],
51+
})
52+
53+
await expect(
54+
link.call(['ping'], 'input', { context: {} }),
55+
).rejects.toThrowError('Invalid CSRF token')
56+
57+
expect(exclude).toHaveBeenCalledTimes(1)
58+
expect(exclude).toHaveBeenCalledWith(expect.objectContaining({
59+
path: ['ping'],
60+
}))
61+
})
62+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { StandardLinkClientInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard'
2+
import type { ClientContext } from '../types'
3+
import { value, type Value } from '@orpc/shared'
4+
5+
export interface SimpleCsrfProtectionLinkPluginOptions<T extends ClientContext> {
6+
/**
7+
* The name of the header to check.
8+
*
9+
* @default 'x-csrf-token'
10+
*/
11+
headerName?: Value<string, [options: StandardLinkClientInterceptorOptions<T>]>
12+
13+
/**
14+
* The value of the header to check.
15+
*
16+
* @default 'orpc'
17+
*
18+
*/
19+
headerValue?: Value<string, [options: StandardLinkClientInterceptorOptions<T>]>
20+
21+
/**
22+
* Exclude a procedure from the plugin.
23+
*
24+
* @default false
25+
*/
26+
exclude?: Value<boolean, [options: StandardLinkClientInterceptorOptions<T>]>
27+
}
28+
29+
export class SimpleCsrfProtectionLinkPlugin<T extends ClientContext> implements StandardLinkPlugin<T> {
30+
private readonly headerName: Exclude<SimpleCsrfProtectionLinkPluginOptions<T>['headerName'], undefined>
31+
private readonly headerValue: Exclude<SimpleCsrfProtectionLinkPluginOptions<T>['headerValue'], undefined>
32+
private readonly exclude: Exclude<SimpleCsrfProtectionLinkPluginOptions<T>['exclude'], undefined>
33+
34+
constructor(options: SimpleCsrfProtectionLinkPluginOptions<T> = {}) {
35+
this.headerName = options.headerName ?? 'x-csrf-token'
36+
this.headerValue = options.headerValue ?? 'orpc'
37+
this.exclude = options.exclude ?? false
38+
}
39+
40+
order = 8_000_000
41+
42+
init(options: StandardLinkOptions<T>): void {
43+
options.clientInterceptors ??= []
44+
45+
options.clientInterceptors.push(async (options) => {
46+
const excluded = await value(this.exclude, options)
47+
48+
if (excluded) {
49+
return options.next()
50+
}
51+
52+
const headerName = await value(this.headerName, options)
53+
const headerValue = await value(this.headerValue, options)
54+
55+
return options.next({
56+
...options,
57+
request: {
58+
...options.request,
59+
headers: {
60+
...options.request.headers,
61+
[headerName]: headerValue,
62+
},
63+
},
64+
})
65+
})
66+
}
67+
}

packages/server/src/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './batch'
22
export * from './cors'
33
export * from './response-headers'
4+
export * from './simple-csrf-protection'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { RPCHandler } from '../adapters/fetch'
2+
import { os } from '../builder'
3+
import { SimpleCsrfProtectionHandlerPlugin } from './simple-csrf-protection'
4+
5+
beforeEach(() => {
6+
vi.clearAllMocks()
7+
})
8+
9+
describe('simpleCsrfProtectionHandlerPlugin', () => {
10+
const interceptor = vi.fn(({ next }) => next())
11+
12+
const handler = new RPCHandler({
13+
ping: os.handler(() => 'pong'),
14+
}, {
15+
plugins: [
16+
new SimpleCsrfProtectionHandlerPlugin(),
17+
],
18+
rootInterceptors: [interceptor],
19+
})
20+
21+
it('should work', async () => {
22+
await expect(
23+
handler.handle(new Request('http://localhost/ping?data=%7B%7D')),
24+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 403) })
25+
26+
await expect(
27+
handler.handle(new Request('http://localhost/ping?data=%7B%7D', {
28+
headers: {
29+
'x-csrf-token': 'orpc',
30+
},
31+
})),
32+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 200) })
33+
})
34+
35+
it('should throw error when interceptor messes with the context', async () => {
36+
interceptor.mockImplementation((options) => {
37+
return options.next({
38+
...options,
39+
context: {}, // <-- interceptor messes with the context
40+
})
41+
})
42+
43+
await expect(
44+
handler.handle(new Request('http://localhost/ping?data=%7B%7D')),
45+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 500) })
46+
47+
await expect(
48+
handler.handle(new Request('http://localhost/ping?data=%7B%7D', {
49+
headers: {
50+
'x-csrf-token': 'orpc',
51+
},
52+
})),
53+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 500) })
54+
})
55+
56+
it('can exclude procedure', async () => {
57+
const exclude = vi.fn(() => true)
58+
59+
const ping = os.handler(() => 'pong')
60+
61+
const handler = new RPCHandler({ ping }, {
62+
plugins: [
63+
new SimpleCsrfProtectionHandlerPlugin({
64+
exclude,
65+
}),
66+
],
67+
})
68+
69+
await expect(
70+
handler.handle(new Request('http://localhost/ping?data=%7B%7D')),
71+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 200) })
72+
73+
expect(exclude).toHaveBeenCalledTimes(1)
74+
expect(exclude).toHaveBeenCalledWith(expect.objectContaining({
75+
path: ['ping'],
76+
procedure: ping,
77+
}))
78+
})
79+
})

0 commit comments

Comments
 (0)