Skip to content

Commit 3ee2e95

Browse files
authored
feat(server): GET method guard plugin (#347)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a security enhancement to restrict unauthorized GET requests. - Added a new sidebar entry for the GET Method Guard Plugin. - **Documentation** - Expanded documentation for the GET Method Guard Plugin. - Enhanced guidance on CSRF prevention and updated the description of the Simple CSRF Protection Plugin. - **Tests** - Implemented tests to validate the behavior of the new GET Method Guard Plugin. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2f2dbaa commit 3ee2e95

File tree

7 files changed

+162
-3
lines changed

7 files changed

+162
-3
lines changed

apps/content/.vitepress/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ 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' },
113114
{ text: 'Client Retry', link: '/docs/plugins/client-retry' },
114115
{ text: 'Body Limit', link: '/docs/plugins/body-limit' },
115116
{ text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' },

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

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const router = {
2929

3030
Any HTTP method can be used. Input can be provided via URL query parameters or the request body.
3131

32+
:::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.
34+
:::
35+
3236
### Input in URL Query
3337

3438
```ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
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.
4+
---
5+
6+
# GET Method Guard Plugin
7+
8+
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.
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+
## How it works
15+
16+
The plugin enforces a simple rule: only procedures explicitly configured with `method: 'GET'` can be invoked via a `GET` request. All other procedures will reject `GET` requests.
17+
18+
```ts
19+
import { os } from '@orpc/server'
20+
21+
const ping = os
22+
.route({ method: 'GET' }) // [!code highlight]
23+
.handler(() => 'pong')
24+
```
25+
26+
## Setup
27+
28+
```ts twoslash
29+
import { RPCHandler } from '@orpc/server/fetch'
30+
import { router } from './shared/planet'
31+
// ---cut---
32+
import { GetMethodGuardPlugin } from '@orpc/server/plugins'
33+
34+
const handler = new RPCHandler(router, {
35+
plugins: [
36+
new GetMethodGuardPlugin()
37+
],
38+
})
39+
```

apps/content/docs/plugins/simple-csrf-protection.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
22
title: Simple CSRF Protection Plugin
3-
description: Simple CSRF Protection plugin for oRPC.
3+
description: Add 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.
44
---
55

6-
# Simple CSRF Protection
6+
# Simple CSRF Protection Plugin
77

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.
8+
This plugin adds basic [Cross-Site Request Forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention) 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.
99

1010
## When to Use
1111

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { RPCHandler } from '../adapters/fetch'
2+
import { os } from '../builder'
3+
import { GetMethodGuardPlugin } from './get-method-guard'
4+
5+
describe('getMethodGuardPlugin', () => {
6+
const interceptor = vi.fn(({ next }) => next())
7+
8+
const handler = new RPCHandler({
9+
ping: os.handler(() => 'pong'),
10+
pong: os.route({ method: 'GET' }).handler(() => 'pong'),
11+
}, {
12+
plugins: [
13+
new GetMethodGuardPlugin(),
14+
],
15+
rootInterceptors: [interceptor],
16+
})
17+
18+
it('not allow use GET method for procedure not explicitly marked as GET', async () => {
19+
await expect(
20+
handler.handle(new Request('http://localhost/ping?data=%7B%7D')),
21+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 405) })
22+
23+
await expect(
24+
handler.handle(new Request('http://localhost/ping', {
25+
method: 'POST',
26+
body: JSON.stringify({ }),
27+
headers: {
28+
'Content-Type': 'application/json',
29+
},
30+
})),
31+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 200) })
32+
})
33+
34+
it('allow use GET method for procedure explicitly marked as GET', async () => {
35+
await expect(
36+
handler.handle(new Request('http://localhost/pong?data=%7B%7D')),
37+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 200) })
38+
})
39+
40+
it('should throw error when interceptor messes with the context', async () => {
41+
interceptor.mockImplementation((options) => {
42+
return options.next({
43+
...options,
44+
context: {}, // <-- interceptor messes with the context
45+
})
46+
})
47+
48+
await expect(
49+
handler.handle(new Request('http://localhost/ping', {
50+
method: 'POST',
51+
body: JSON.stringify({}),
52+
headers: {
53+
'Content-Type': 'application/json',
54+
},
55+
})),
56+
).resolves.toEqual({ matched: true, response: expect.toSatisfy(response => response.status === 500) })
57+
})
58+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { StandardHandlerOptions, StandardHandlerPlugin } from '../adapters/standard'
2+
import type { Context } from '../context'
3+
import { fallbackContractConfig, ORPCError } from '@orpc/contract'
4+
5+
export interface GetMethodGuardPluginOptions {
6+
7+
/**
8+
* The error thrown when a GET request is made to a procedure that doesn't allow GET.
9+
*
10+
* @default new ORPCError('METHOD_NOT_SUPPORTED')
11+
*/
12+
error?: InstanceType<typeof ORPCError>
13+
}
14+
15+
const GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL = Symbol('GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT')
16+
17+
export class GetMethodGuardPlugin<T extends Context> implements StandardHandlerPlugin<T> {
18+
private readonly error: Exclude<GetMethodGuardPluginOptions['error'], undefined>
19+
20+
order = 7_000_000
21+
22+
constructor(options: GetMethodGuardPluginOptions = {}) {
23+
this.error = options.error ?? new ORPCError('METHOD_NOT_SUPPORTED')
24+
}
25+
26+
init(options: StandardHandlerOptions<T>): void {
27+
options.rootInterceptors ??= []
28+
options.clientInterceptors ??= []
29+
30+
options.rootInterceptors.unshift((options) => {
31+
const isGetMethod = options.request.method === 'GET'
32+
33+
return options.next({
34+
...options,
35+
context: {
36+
...options.context,
37+
[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL]: isGetMethod,
38+
},
39+
})
40+
})
41+
42+
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')
45+
}
46+
47+
const procedureMethod = fallbackContractConfig('defaultMethod', options.procedure['~orpc'].route.method)
48+
49+
if (options.context[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] && procedureMethod !== 'GET') {
50+
throw this.error
51+
}
52+
53+
return options.next()
54+
})
55+
}
56+
}

packages/server/src/plugins/index.ts

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

0 commit comments

Comments
 (0)