Skip to content

Commit c72b962

Browse files
authored
feat(server): renamed to StrictGetMethodPlugin and enabled it by default in RPCHandler (#348)
GetMethodGuardPlugin -> StrictGetMethodPlugin <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Documentation** - Updated sidebar navigation and guides to reflect the shift to a stricter GET method enforcement in RPC documentation. - Added clarifications regarding the default behavior of the RPCHandler and the necessity for explicit permissions for GET requests. - **Enhancements** - Improved RPC security defaults by enforcing strict GET request handling, with a new option to disable this behavior if needed. - **Tests** - Added test cases to ensure that the new default configuration is correctly initialized and applied. - Modified existing tests to incorporate the new configuration option for the RPCHandler. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3ee2e95 commit c72b962

17 files changed

+141
-45
lines changed

apps/content/.vitepress/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ export default defineConfig({
110110
{ text: 'CORS', link: '/docs/plugins/cors' },
111111
{ text: 'Response Headers', link: '/docs/plugins/response-headers' },
112112
{ text: 'Batch Request/Response', link: '/docs/plugins/batch-request-response' },
113-
{ text: 'GET method guard', link: '/docs/plugins/get-method-guard' },
114113
{ text: 'Client Retry', link: '/docs/plugins/client-retry' },
115114
{ text: 'Body Limit', link: '/docs/plugins/body-limit' },
116115
{ text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' },
116+
{ text: 'Strict GET method', link: '/docs/plugins/strict-get-method' },
117117
],
118118
},
119119
{

apps/content/docs/advanced/rpc-protocol.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const router = {
3030
Any HTTP method can be used. Input can be provided via URL query parameters or the request body.
3131

3232
:::info
33-
To help prevent [Cross-Site Request Forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention) attacks, you can use the [GET Method Guard Plugin](/docs/plugins/get-method-guard) to restrict the use of the `GET` method.
33+
You can use any method, but by default, [RPCHandler](/docs/rpc-handler) enabled [StrictGetMethodPlugin](/docs/rpc-handler#default-plugins) which blocks GET requests except for procedures explicitly allowed.
3434
:::
3535

3636
### Input in URL Query

apps/content/docs/client/rpc-link.md

+4
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ If a property in `ClientContext` is required, oRPC enforces its inclusion when c
6262

6363
By default, RPCLink sends requests via `POST`. You can override this to use methods like `GET` (for browser or CDN caching) based on your requirements.
6464

65+
::: warning
66+
By default, [RPCHandler](/docs/rpc-handler) enabled [StrictGetMethodPlugin](/docs/rpc-handler#default-plugins) which blocks GET requests except for procedures explicitly allowed. please refer to [StrictGetMethodPlugin](/docs/plugins/strict-get-method) for more details.
67+
:::
68+
6569
```ts twoslash
6670
import { RPCLink } from '@orpc/client/fetch'
6771

apps/content/docs/plugins/get-method-guard.md renamed to apps/content/docs/plugins/strict-get-method.md

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
---
2-
title: GET Method Guard Plugin
3-
description: Enhance security by restricting GET requests to explicitly allowed procedures, mitigating Cross-Site Request Forgery (CSRF) risks.
2+
title: Strict GET Method Plugin
3+
description: Enhance security by ensuring only procedures explicitly marked to accept `GET` requests can be called using the HTTP `GET` method for RPC Protocol. This helps prevent certain types of Cross-Site Request Forgery (CSRF) attacks.
44
---
55

6-
# GET Method Guard Plugin
6+
# Strict GET Method Plugin
77

88
This plugin enhances security by ensuring only procedures explicitly marked to accept `GET` requests can be called using the HTTP `GET` method for [RPC Protocol](/docs/advanced/rpc-protocol). This helps prevent certain types of [Cross-Site Request Forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention) attacks.
99

10+
::: info
11+
[RPCHandler](/docs/rpc-handler) enabled this plugin by default.
12+
:::
13+
1014
## When to Use
1115

1216
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`.
@@ -29,11 +33,11 @@ const ping = os
2933
import { RPCHandler } from '@orpc/server/fetch'
3034
import { router } from './shared/planet'
3135
// ---cut---
32-
import { GetMethodGuardPlugin } from '@orpc/server/plugins'
36+
import { StrictGetMethodPlugin } from '@orpc/server/plugins'
3337

3438
const handler = new RPCHandler(router, {
3539
plugins: [
36-
new GetMethodGuardPlugin()
40+
new StrictGetMethodPlugin()
3741
],
3842
})
3943
```

apps/content/docs/rpc-handler.md

+6
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,9 @@ const handler = new RPCHandler(router, {
8383
eventIteratorKeepAliveComment: '',
8484
})
8585
```
86+
87+
## Default Plugins
88+
89+
RPCHandler is pre-configured with plugins that help enforce best practices and enhance security out of the box. By default, the following plugin is enabled:
90+
91+
- [StrictGetMethodPlugin](/docs/plugins/strict-get-method) - Disable by setting `strictGetMethodPluginEnabled` to `false`.

packages/client/src/adapters/fetch/rpc-link.test.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ describe.each(supportedDataTypes)('rpcLink: $name', ({ value, expected }) => {
1313
async function assertSuccessCase(value: unknown, expected: unknown): Promise<true> {
1414
const handler = vi.fn(({ input }) => input)
1515

16-
const rpcHandler = new RPCHandler(os.handler(handler))
16+
const rpcHandler = new RPCHandler(os.handler(handler), {
17+
strictGetMethodPluginEnabled: false,
18+
})
1719

1820
const rpcLink = new RPCLink({
1921
url: 'http://api.example.com',
@@ -45,7 +47,9 @@ describe.each(supportedDataTypes)('rpcLink: $name', ({ value, expected }) => {
4547
})
4648
})
4749

48-
const rpcHandler = new RPCHandler(os.handler(handler))
50+
const rpcHandler = new RPCHandler(os.handler(handler), {
51+
strictGetMethodPluginEnabled: false,
52+
})
4953

5054
const rpcLink = new RPCLink({
5155
url: 'http://api.example.com',

packages/server/src/adapters/fetch/body-limit-plugin.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('bodyLimitPlugin', () => {
77

88
it('should ignore for non-body request', async () => {
99
const handler = new RPCHandler(os.handler(() => 'ping'), {
10+
strictGetMethodPluginEnabled: false,
1011
plugins: [
1112
new BodyLimitPlugin({ maxBodySize: 22 }),
1213
],

packages/server/src/adapters/fetch/rpc-handler.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { RPCHandler } from './rpc-handler'
33

44
describe('rpcHandler', () => {
55
it('works', async () => {
6-
const handler = new RPCHandler(os.handler(() => 'pong'))
6+
const handler = new RPCHandler(os.handler(() => 'pong'), {
7+
strictGetMethodPluginEnabled: false,
8+
})
79

810
const { response } = await handler.handle(new Request('https://example.com/api/v1/?data=%7B%7D'), {
911
prefix: '/api/v1',
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import type { Context } from '../../context'
22
import type { Router } from '../../router'
3-
import type { StandardRPCHandlerOptions } from '../standard'
43
import type { FetchHandlerOptions } from './handler'
5-
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
6-
import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '../standard'
4+
import { StandardRPCHandler, type StandardRPCHandlerOptions } from '../standard'
75
import { FetchHandler } from './handler'
86

97
export class RPCHandler<T extends Context> extends FetchHandler<T> {
108
constructor(router: Router<any, T>, options: NoInfer<FetchHandlerOptions<T> & StandardRPCHandlerOptions<T>> = {}) {
11-
const jsonSerializer = new StandardRPCJsonSerializer(options)
12-
const serializer = new StandardRPCSerializer(jsonSerializer)
13-
const matcher = new StandardRPCMatcher()
14-
const codec = new StandardRPCCodec(serializer)
15-
16-
super(new StandardHandler(router, matcher, codec, options), options)
9+
super(new StandardRPCHandler(router, options), options)
1710
}
1811
}

packages/server/src/adapters/node/rpc-handler.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { RPCHandler } from './rpc-handler'
55

66
describe('rpcHandler', () => {
77
it('works', async () => {
8-
const handler = new RPCHandler(os.handler(() => 'pong'))
8+
const handler = new RPCHandler(os.handler(() => 'pong'), {
9+
strictGetMethodPluginEnabled: false,
10+
})
911

1012
const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
1113
await handler.handle(req, res)
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import type { Context } from '../../context'
22
import type { Router } from '../../router'
3-
import type { StandardRPCHandlerOptions } from '../standard'
43
import type { NodeHttpHandlerOptions } from './handler'
5-
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
6-
import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '../standard'
4+
import { StandardRPCHandler, type StandardRPCHandlerOptions } from '../standard'
75
import { NodeHttpHandler } from './handler'
86

97
export class RPCHandler<T extends Context> extends NodeHttpHandler<T> {
108
constructor(router: Router<any, T>, options: NoInfer<StandardRPCHandlerOptions<T> & NodeHttpHandlerOptions<T>> = {}) {
11-
const jsonSerializer = new StandardRPCJsonSerializer(options)
12-
const serializer = new StandardRPCSerializer(jsonSerializer)
13-
const matcher = new StandardRPCMatcher()
14-
const codec = new StandardRPCCodec(serializer)
15-
16-
super(new StandardHandler(router, matcher, codec, options), options)
9+
super(new StandardRPCHandler(router, options), options)
1710
}
1811
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe } from 'vitest'
2+
import { os } from '../../builder'
3+
import { StandardRPCHandler } from './rpc-handler'
4+
5+
describe('standardRPCHandler', () => {
6+
const handler = new StandardRPCHandler({
7+
ping: os.handler(({ input }) => ({ output: input })),
8+
pong: os.route({ method: 'GET' }).handler(({ input }) => ({ output: input })),
9+
}, {})
10+
11+
it('works', async () => {
12+
const { response } = await handler.handle({
13+
url: new URL('https://example.com/api/v1/ping'),
14+
body: () => Promise.resolve({
15+
json: 'value',
16+
}),
17+
headers: {},
18+
method: 'POST',
19+
signal: undefined,
20+
}, {
21+
prefix: '/api/v1',
22+
context: {},
23+
})
24+
25+
expect(response?.body).toEqual({ json: { output: 'value' } })
26+
})
27+
28+
it('restrict GET method by default', async () => {
29+
const { response: r1 } = await handler.handle({
30+
url: new URL('https://example.com/api/v1/ping?data=%7B%7D'),
31+
body: () => Promise.resolve(undefined),
32+
headers: {},
33+
method: 'GET',
34+
signal: undefined,
35+
}, {
36+
prefix: '/api/v1',
37+
context: {},
38+
})
39+
40+
expect(r1?.status).toEqual(405)
41+
42+
const { response: r2 } = await handler.handle({
43+
url: new URL('https://example.com/api/v1/pong?data=%7B%7D'),
44+
body: () => Promise.resolve(undefined),
45+
headers: {},
46+
method: 'GET',
47+
signal: undefined,
48+
}, {
49+
prefix: '/api/v1',
50+
context: {},
51+
})
52+
53+
expect(r2?.status).toEqual(200)
54+
})
55+
})
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
1-
import type { StandardRPCJsonSerializerOptions } from '@orpc/client/standard'
21
import type { Context } from '../../context'
3-
import type { StandardHandlerOptions } from './handler'
2+
import type { Router } from '../../router'
3+
import { StandardRPCJsonSerializer, type StandardRPCJsonSerializerOptions, StandardRPCSerializer } from '@orpc/client/standard'
4+
import { StrictGetMethodPlugin } from '../../plugins'
5+
import { StandardHandler, type StandardHandlerOptions } from './handler'
6+
import { StandardRPCCodec } from './rpc-codec'
7+
import { StandardRPCMatcher } from './rpc-matcher'
48

5-
export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {}
9+
export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {
10+
/**
11+
* Enables or disables the StrictGetMethodPlugin.
12+
*
13+
* @default true
14+
*/
15+
strictGetMethodPluginEnabled?: boolean
16+
}
17+
18+
export class StandardRPCHandler<T extends Context> extends StandardHandler<T> {
19+
constructor(router: Router<any, T>, options: StandardRPCHandlerOptions<T>) {
20+
options.plugins ??= []
21+
22+
const strictGetMethodPluginEnabled = options.strictGetMethodPluginEnabled ?? true
23+
24+
if (strictGetMethodPluginEnabled) {
25+
options.plugins.push(new StrictGetMethodPlugin())
26+
}
27+
28+
const jsonSerializer = new StandardRPCJsonSerializer(options)
29+
const serializer = new StandardRPCSerializer(jsonSerializer)
30+
const matcher = new StandardRPCMatcher()
31+
const codec = new StandardRPCCodec(serializer)
32+
33+
super(router, matcher, codec, options)
34+
}
35+
}

packages/server/src/plugins/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export * from './batch'
22
export * from './cors'
3-
export * from './get-method-guard'
43
export * from './response-headers'
54
export * from './simple-csrf-protection'
5+
export * from './strict-get-method'

packages/server/src/plugins/simple-csrf-protection.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('simpleCsrfProtectionHandlerPlugin', () => {
1212
const handler = new RPCHandler({
1313
ping: os.handler(() => 'pong'),
1414
}, {
15+
strictGetMethodPluginEnabled: false,
1516
plugins: [
1617
new SimpleCsrfProtectionHandlerPlugin(),
1718
],
@@ -59,6 +60,7 @@ describe('simpleCsrfProtectionHandlerPlugin', () => {
5960
const ping = os.handler(() => 'pong')
6061

6162
const handler = new RPCHandler({ ping }, {
63+
strictGetMethodPluginEnabled: false,
6264
plugins: [
6365
new SimpleCsrfProtectionHandlerPlugin({
6466
exclude,

packages/server/src/plugins/get-method-guard.test.ts renamed to packages/server/src/plugins/strict-get-method.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { RPCHandler } from '../adapters/fetch'
22
import { os } from '../builder'
3-
import { GetMethodGuardPlugin } from './get-method-guard'
3+
import { StrictGetMethodPlugin } from './strict-get-method'
44

5-
describe('getMethodGuardPlugin', () => {
5+
describe('strictGetMethodPlugin', () => {
66
const interceptor = vi.fn(({ next }) => next())
77

88
const handler = new RPCHandler({
99
ping: os.handler(() => 'pong'),
1010
pong: os.route({ method: 'GET' }).handler(() => 'pong'),
1111
}, {
1212
plugins: [
13-
new GetMethodGuardPlugin(),
13+
new StrictGetMethodPlugin(),
1414
],
1515
rootInterceptors: [interceptor],
1616
})

packages/server/src/plugins/get-method-guard.ts renamed to packages/server/src/plugins/strict-get-method.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { StandardHandlerOptions, StandardHandlerPlugin } from '../adapters/
22
import type { Context } from '../context'
33
import { fallbackContractConfig, ORPCError } from '@orpc/contract'
44

5-
export interface GetMethodGuardPluginOptions {
5+
export interface StrictGetMethodPluginOptions {
66

77
/**
88
* The error thrown when a GET request is made to a procedure that doesn't allow GET.
@@ -12,14 +12,14 @@ export interface GetMethodGuardPluginOptions {
1212
error?: InstanceType<typeof ORPCError>
1313
}
1414

15-
const GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL = Symbol('GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT')
15+
const STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL = Symbol('STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT')
1616

17-
export class GetMethodGuardPlugin<T extends Context> implements StandardHandlerPlugin<T> {
18-
private readonly error: Exclude<GetMethodGuardPluginOptions['error'], undefined>
17+
export class StrictGetMethodPlugin<T extends Context> implements StandardHandlerPlugin<T> {
18+
private readonly error: Exclude<StrictGetMethodPluginOptions['error'], undefined>
1919

2020
order = 7_000_000
2121

22-
constructor(options: GetMethodGuardPluginOptions = {}) {
22+
constructor(options: StrictGetMethodPluginOptions = {}) {
2323
this.error = options.error ?? new ORPCError('METHOD_NOT_SUPPORTED')
2424
}
2525

@@ -34,19 +34,19 @@ export class GetMethodGuardPlugin<T extends Context> implements StandardHandlerP
3434
...options,
3535
context: {
3636
...options.context,
37-
[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL]: isGetMethod,
37+
[STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL]: isGetMethod,
3838
},
3939
})
4040
})
4141

4242
options.clientInterceptors.unshift((options) => {
43-
if (typeof options.context[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] !== 'boolean') {
44-
throw new TypeError('[GetMethodGuardPlugin] GET method guard context has been corrupted or modified by another plugin or interceptor')
43+
if (typeof options.context[STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] !== 'boolean') {
44+
throw new TypeError('[StrictGetMethodPlugin] strict GET method context has been corrupted or modified by another plugin or interceptor')
4545
}
4646

4747
const procedureMethod = fallbackContractConfig('defaultMethod', options.procedure['~orpc'].route.method)
4848

49-
if (options.context[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] && procedureMethod !== 'GET') {
49+
if (options.context[STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] && procedureMethod !== 'GET') {
5050
throw this.error
5151
}
5252

0 commit comments

Comments
 (0)