Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,10 @@ export default defineConfig({
{ text: 'CORS', link: '/docs/plugins/cors' },
{ text: 'Response Headers', link: '/docs/plugins/response-headers' },
{ text: 'Batch Request/Response', link: '/docs/plugins/batch-request-response' },
{ text: 'GET method guard', link: '/docs/plugins/get-method-guard' },
{ text: 'Client Retry', link: '/docs/plugins/client-retry' },
{ text: 'Body Limit', link: '/docs/plugins/body-limit' },
{ text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' },
{ text: 'Strict GET method', link: '/docs/plugins/strict-get-method' },
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/content/docs/advanced/rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const router = {
Any HTTP method can be used. Input can be provided via URL query parameters or the request body.

:::info
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.
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.
:::

### Input in URL Query
Expand Down
4 changes: 4 additions & 0 deletions apps/content/docs/client/rpc-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ If a property in `ClientContext` is required, oRPC enforces its inclusion when c

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.

::: warning
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.
:::

```ts twoslash
import { RPCLink } from '@orpc/client/fetch'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
---
title: GET Method Guard Plugin
description: Enhance security by restricting GET requests to explicitly allowed procedures, mitigating Cross-Site Request Forgery (CSRF) risks.
title: Strict GET Method Plugin
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.
---

# GET Method Guard Plugin
# Strict GET Method Plugin

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.

::: info
[RPCHandler](/docs/rpc-handler) enabled this plugin by default.
:::

## When to Use

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

const handler = new RPCHandler(router, {
plugins: [
new GetMethodGuardPlugin()
new StrictGetMethodPlugin()
],
})
```
6 changes: 6 additions & 0 deletions apps/content/docs/rpc-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@ const handler = new RPCHandler(router, {
eventIteratorKeepAliveComment: '',
})
```

## Default Plugins

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:

- [StrictGetMethodPlugin](/docs/plugins/strict-get-method) - Disable by setting `strictGetMethodPluginEnabled` to `false`.
8 changes: 6 additions & 2 deletions packages/client/src/adapters/fetch/rpc-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ describe.each(supportedDataTypes)('rpcLink: $name', ({ value, expected }) => {
async function assertSuccessCase(value: unknown, expected: unknown): Promise<true> {
const handler = vi.fn(({ input }) => input)

const rpcHandler = new RPCHandler(os.handler(handler))
const rpcHandler = new RPCHandler(os.handler(handler), {
strictGetMethodPluginEnabled: false,
})

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

const rpcHandler = new RPCHandler(os.handler(handler))
const rpcHandler = new RPCHandler(os.handler(handler), {
strictGetMethodPluginEnabled: false,
})

const rpcLink = new RPCLink({
url: 'http://api.example.com',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('bodyLimitPlugin', () => {

it('should ignore for non-body request', async () => {
const handler = new RPCHandler(os.handler(() => 'ping'), {
strictGetMethodPluginEnabled: false,
plugins: [
new BodyLimitPlugin({ maxBodySize: 22 }),
],
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/adapters/fetch/rpc-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { RPCHandler } from './rpc-handler'

describe('rpcHandler', () => {
it('works', async () => {
const handler = new RPCHandler(os.handler(() => 'pong'))
const handler = new RPCHandler(os.handler(() => 'pong'), {
strictGetMethodPluginEnabled: false,
})

const { response } = await handler.handle(new Request('https://example.com/api/v1/?data=%7B%7D'), {
prefix: '/api/v1',
Expand Down
11 changes: 2 additions & 9 deletions packages/server/src/adapters/fetch/rpc-handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import type { Context } from '../../context'
import type { Router } from '../../router'
import type { StandardRPCHandlerOptions } from '../standard'
import type { FetchHandlerOptions } from './handler'
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '../standard'
import { StandardRPCHandler, type StandardRPCHandlerOptions } from '../standard'
import { FetchHandler } from './handler'

export class RPCHandler<T extends Context> extends FetchHandler<T> {
constructor(router: Router<any, T>, options: NoInfer<FetchHandlerOptions<T> & StandardRPCHandlerOptions<T>> = {}) {
const jsonSerializer = new StandardRPCJsonSerializer(options)
const serializer = new StandardRPCSerializer(jsonSerializer)
const matcher = new StandardRPCMatcher()
const codec = new StandardRPCCodec(serializer)

super(new StandardHandler(router, matcher, codec, options), options)
super(new StandardRPCHandler(router, options), options)
}
}
4 changes: 3 additions & 1 deletion packages/server/src/adapters/node/rpc-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { RPCHandler } from './rpc-handler'

describe('rpcHandler', () => {
it('works', async () => {
const handler = new RPCHandler(os.handler(() => 'pong'))
const handler = new RPCHandler(os.handler(() => 'pong'), {
strictGetMethodPluginEnabled: false,
})

const res = await request(async (req: IncomingMessage, res: ServerResponse) => {
await handler.handle(req, res)
Expand Down
11 changes: 2 additions & 9 deletions packages/server/src/adapters/node/rpc-handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import type { Context } from '../../context'
import type { Router } from '../../router'
import type { StandardRPCHandlerOptions } from '../standard'
import type { NodeHttpHandlerOptions } from './handler'
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '../standard'
import { StandardRPCHandler, type StandardRPCHandlerOptions } from '../standard'
import { NodeHttpHandler } from './handler'

export class RPCHandler<T extends Context> extends NodeHttpHandler<T> {
constructor(router: Router<any, T>, options: NoInfer<StandardRPCHandlerOptions<T> & NodeHttpHandlerOptions<T>> = {}) {
const jsonSerializer = new StandardRPCJsonSerializer(options)
const serializer = new StandardRPCSerializer(jsonSerializer)
const matcher = new StandardRPCMatcher()
const codec = new StandardRPCCodec(serializer)

super(new StandardHandler(router, matcher, codec, options), options)
super(new StandardRPCHandler(router, options), options)
}
}
55 changes: 55 additions & 0 deletions packages/server/src/adapters/standard/rpc-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe } from 'vitest'
import { os } from '../../builder'
import { StandardRPCHandler } from './rpc-handler'

describe('standardRPCHandler', () => {
const handler = new StandardRPCHandler({
ping: os.handler(({ input }) => ({ output: input })),
pong: os.route({ method: 'GET' }).handler(({ input }) => ({ output: input })),
}, {})

it('works', async () => {
const { response } = await handler.handle({
url: new URL('https://example.com/api/v1/ping'),
body: () => Promise.resolve({
json: 'value',
}),
headers: {},
method: 'POST',
signal: undefined,
}, {
prefix: '/api/v1',
context: {},
})

expect(response?.body).toEqual({ json: { output: 'value' } })
})

it('restrict GET method by default', async () => {
const { response: r1 } = await handler.handle({
url: new URL('https://example.com/api/v1/ping?data=%7B%7D'),
body: () => Promise.resolve(undefined),
headers: {},
method: 'GET',
signal: undefined,
}, {
prefix: '/api/v1',
context: {},
})

expect(r1?.status).toEqual(405)

const { response: r2 } = await handler.handle({
url: new URL('https://example.com/api/v1/pong?data=%7B%7D'),
body: () => Promise.resolve(undefined),
headers: {},
method: 'GET',
signal: undefined,
}, {
prefix: '/api/v1',
context: {},
})

expect(r2?.status).toEqual(200)
})
})
36 changes: 33 additions & 3 deletions packages/server/src/adapters/standard/rpc-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import type { StandardRPCJsonSerializerOptions } from '@orpc/client/standard'
import type { Context } from '../../context'
import type { StandardHandlerOptions } from './handler'
import type { Router } from '../../router'
import { StandardRPCJsonSerializer, type StandardRPCJsonSerializerOptions, StandardRPCSerializer } from '@orpc/client/standard'
import { StrictGetMethodPlugin } from '../../plugins'
import { StandardHandler, type StandardHandlerOptions } from './handler'
import { StandardRPCCodec } from './rpc-codec'
import { StandardRPCMatcher } from './rpc-matcher'

export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {}
export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {
/**
* Enables or disables the StrictGetMethodPlugin.
*
* @default true
*/
strictGetMethodPluginEnabled?: boolean
}

export class StandardRPCHandler<T extends Context> extends StandardHandler<T> {
constructor(router: Router<any, T>, options: StandardRPCHandlerOptions<T>) {
options.plugins ??= []

const strictGetMethodPluginEnabled = options.strictGetMethodPluginEnabled ?? true

if (strictGetMethodPluginEnabled) {
options.plugins.push(new StrictGetMethodPlugin())
}

const jsonSerializer = new StandardRPCJsonSerializer(options)
const serializer = new StandardRPCSerializer(jsonSerializer)
const matcher = new StandardRPCMatcher()
const codec = new StandardRPCCodec(serializer)

super(router, matcher, codec, options)
}
}
2 changes: 1 addition & 1 deletion packages/server/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './batch'
export * from './cors'
export * from './get-method-guard'
export * from './response-headers'
export * from './simple-csrf-protection'
export * from './strict-get-method'
2 changes: 2 additions & 0 deletions packages/server/src/plugins/simple-csrf-protection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('simpleCsrfProtectionHandlerPlugin', () => {
const handler = new RPCHandler({
ping: os.handler(() => 'pong'),
}, {
strictGetMethodPluginEnabled: false,
plugins: [
new SimpleCsrfProtectionHandlerPlugin(),
],
Expand Down Expand Up @@ -59,6 +60,7 @@ describe('simpleCsrfProtectionHandlerPlugin', () => {
const ping = os.handler(() => 'pong')

const handler = new RPCHandler({ ping }, {
strictGetMethodPluginEnabled: false,
plugins: [
new SimpleCsrfProtectionHandlerPlugin({
exclude,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { RPCHandler } from '../adapters/fetch'
import { os } from '../builder'
import { GetMethodGuardPlugin } from './get-method-guard'
import { StrictGetMethodPlugin } from './strict-get-method'

describe('getMethodGuardPlugin', () => {
describe('strictGetMethodPlugin', () => {
const interceptor = vi.fn(({ next }) => next())

const handler = new RPCHandler({
ping: os.handler(() => 'pong'),
pong: os.route({ method: 'GET' }).handler(() => 'pong'),
}, {
plugins: [
new GetMethodGuardPlugin(),
new StrictGetMethodPlugin(),
],
rootInterceptors: [interceptor],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { StandardHandlerOptions, StandardHandlerPlugin } from '../adapters/
import type { Context } from '../context'
import { fallbackContractConfig, ORPCError } from '@orpc/contract'

export interface GetMethodGuardPluginOptions {
export interface StrictGetMethodPluginOptions {

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

const GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL = Symbol('GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT')
const STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL = Symbol('STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT')

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

order = 7_000_000

constructor(options: GetMethodGuardPluginOptions = {}) {
constructor(options: StrictGetMethodPluginOptions = {}) {
this.error = options.error ?? new ORPCError('METHOD_NOT_SUPPORTED')
}

Expand All @@ -34,19 +34,19 @@ export class GetMethodGuardPlugin<T extends Context> implements StandardHandlerP
...options,
context: {
...options.context,
[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL]: isGetMethod,
[STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL]: isGetMethod,
},
})
})

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

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

if (options.context[GET_METHOD_GUARD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] && procedureMethod !== 'GET') {
if (options.context[STRICT_GET_METHOD_PLUGIN_IS_GET_METHOD_CONTEXT_SYMBOL] && procedureMethod !== 'GET') {
throw this.error
}

Expand Down