diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index d73f360d0..9a8a0ca95 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -151,13 +151,13 @@ export default withMermaid(defineConfig({ items: [ { text: 'Batch', link: '/docs/plugins/batch' }, { text: 'Body Compression', link: '/docs/plugins/body-compression' }, - { text: 'Body Limit', link: '/docs/plugins/body-limit' }, { text: 'CORS', link: '/docs/plugins/cors' }, { text: 'CSRF Guard', link: '/docs/plugins/csrf-guard' }, { text: 'Dedupe', link: '/docs/plugins/dedupe' }, { text: 'OpenAPI Reference', link: '/docs/plugins/openapi-reference' }, { text: 'Request Compression', link: '/docs/plugins/request-compression' }, { text: 'Request Headers', link: '/docs/plugins/request-headers' }, + { text: 'Request Limit', link: '/docs/plugins/request-limit' }, { text: 'Request Validation', link: '/docs/plugins/request-validation' }, { text: 'Response Headers', link: '/docs/plugins/response-headers' }, { text: 'Response Validation', link: '/docs/plugins/response-validation' }, diff --git a/apps/content/docs/plugins/body-limit.md b/apps/content/docs/plugins/body-limit.md deleted file mode 100644 index 2cd146b20..000000000 --- a/apps/content/docs/plugins/body-limit.md +++ /dev/null @@ -1,32 +0,0 @@ -# Body Limit Plugin - -**Body Limit Plugin** helps restrict the size of the request body. - -## Import - -Depending on your adapter, import the corresponding plugin: - -```ts -import { BodyLimitHandlerPlugin } from '@orpc/server/fetch' -import { BodyLimitHandlerPlugin } from '@orpc/server/node' -``` - -## Setup - -Set `maxBodySize` to the maximum number of bytes allowed: - -```ts -const handler = new RPCHandler(router, { - plugins: [ - new BodyLimitHandlerPlugin({ - maxBodySize: 1024 * 1024, // 1MB - }), - ], -}) -``` - - - -## Learn More - -For implementation details, see the [fetch adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/fetch/body-limit-plugin.ts) and the [node adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/node/body-limit-plugin.ts). diff --git a/apps/content/docs/plugins/request-compression.md b/apps/content/docs/plugins/request-compression.md index 819626694..e6678c7ae 100644 --- a/apps/content/docs/plugins/request-compression.md +++ b/apps/content/docs/plugins/request-compression.md @@ -50,3 +50,11 @@ const handler = new RPCHandler(router, { ``` + +::: tip +Combine with the [Request Limit Plugin](/docs/plugins/request-limit) to limit the decompressed payload size. +::: + +## Learn More + +For implementation details, see the [RequestCompressionLinkPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/request-compression.ts) or the [RequestCompressionHandlerPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/request-compression.ts). diff --git a/apps/content/docs/plugins/request-limit.md b/apps/content/docs/plugins/request-limit.md new file mode 100644 index 000000000..642cd5e7c --- /dev/null +++ b/apps/content/docs/plugins/request-limit.md @@ -0,0 +1,32 @@ +# Request Limit Plugin + +Restricts the size of incoming request bodies to protect the server from oversized payloads. + +## Setup + +Use `RequestLimitHandlerPlugin` to limit the size of incoming request bodies. + +```ts +import { RequestLimitHandlerPlugin } from '@orpc/server/plugins' + +const handler = new RPCHandler(router, { + plugins: [ + new RequestLimitHandlerPlugin({ + /** + * The maximum allowed request body size in bytes. + */ + maxBodySize: 1024 * 1024, // 1MB + }), + ], +}) +``` + + + +::: info +When used with [Request Compression](/docs/plugins/request-compression), `maxBodySize` applies to the **decompressed** payload size, not the compressed wire size. +::: + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/request-limit.ts). diff --git a/packages/server/src/adapters/fetch/body-limit-plugin.test.ts b/packages/server/src/adapters/fetch/body-limit-plugin.test.ts deleted file mode 100644 index c9f451895..000000000 --- a/packages/server/src/adapters/fetch/body-limit-plugin.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { os } from '../../builder' -import { BodyLimitHandlerPlugin } from './body-limit-plugin' -import { RPCHandler } from './rpc-handler' - -describe('bodyLimitHandlerPlugin', () => { - const size22Json = { json: { foo: 'bar' } } - - it('ignores requests without a body', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 22 })], - }, - ) - - const { matched, response } = await handler.handle(new Request('https://example.com/ping?data=%7B%7D')) - - expect(matched).toBe(true) - await expect(response!.text()).resolves.toContain('ping') - expect(response!.status).toBe(200) - }) - - it('allows bodies within the limit', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 22 })], - }, - ) - - const { matched, response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(size22Json), - })) - - expect(matched).toBe(true) - await expect(response!.text()).resolves.toContain('ping') - expect(response!.status).toBe(200) - }) - - it('checks the content-length header', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 21 })], - }, - ) - - const { matched, response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-length': '22', - }, - body: JSON.stringify({}), - })) - - expect(matched).toBe(true) - await expect(response!.text()).resolves.toContain('PAYLOAD_TOO_LARGE') - expect(response!.status).toBe(413) - }) - - it('checks the streamed body size', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 21 })], - }, - ) - - const { matched, response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(size22Json), - })) - - expect(matched).toBe(true) - await expect(response!.text()).resolves.toContain('PAYLOAD_TOO_LARGE') - expect(response!.status).toBe(413) - }) -}) diff --git a/packages/server/src/adapters/fetch/body-limit-plugin.ts b/packages/server/src/adapters/fetch/body-limit-plugin.ts deleted file mode 100644 index bf5177acd..000000000 --- a/packages/server/src/adapters/fetch/body-limit-plugin.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Context } from '../../context' -import type { FetchHandlerOptions } from './handler' -import type { FetchHandlerPlugin } from './plugin' -import { ORPCError } from '@orpc/client' -import { toArray } from '@orpc/shared' - -export interface BodyLimitHandlerPluginOptions { - /** - * The maximum size of the body in bytes. - */ - maxBodySize: number -} - -export class BodyLimitHandlerPlugin implements FetchHandlerPlugin { - name = '~body-limit' - - private readonly maxBodySize: number - - constructor(options: BodyLimitHandlerPluginOptions) { - this.maxBodySize = options.maxBodySize - } - - initFetchHandlerOptions(options: FetchHandlerOptions): FetchHandlerOptions { - return { - ...options, - fetchInterceptors: [ - async (interceptorOptions) => { - if (!interceptorOptions.request.body) { - return interceptorOptions.next() - } - - let currentBodySize = 0 - const rawReader = interceptorOptions.request.body.getReader() - - const body = new ReadableStream({ - start: async (controller) => { - const reject = async (error: unknown) => { - controller.error(error) - await rawReader.cancel(error) - } - - const contentLength = interceptorOptions.request.headers.get('content-length') - if (contentLength && Number(contentLength) > this.maxBodySize) { - await reject(new ORPCError('PAYLOAD_TOO_LARGE')) - return - } - - while (true) { - const { done, value } = await rawReader.read() - - if (done) { - controller.close() - return - } - - currentBodySize += value.length - if (currentBodySize > this.maxBodySize) { - await reject(new ORPCError('PAYLOAD_TOO_LARGE')) - return - } - - controller.enqueue(value) - } - }, - }) - - const requestInit: RequestInit & { duplex: 'half' } = { body, duplex: 'half' } - - return interceptorOptions.next({ - ...interceptorOptions, - request: new Request(interceptorOptions.request, requestInit), - }) - }, - ...toArray(options.fetchInterceptors), - ], - } - } -} diff --git a/packages/server/src/adapters/fetch/index.test.ts b/packages/server/src/adapters/fetch/index.test.ts index fef728c78..402c91a07 100644 --- a/packages/server/src/adapters/fetch/index.test.ts +++ b/packages/server/src/adapters/fetch/index.test.ts @@ -1,7 +1,6 @@ -it('exports RPCHandler, BodyCompressionHandlerPlugin, BodyLimitHandlerPlugin', async () => { +it('exports RPCHandler, BodyCompressionHandlerPlugin', async () => { await expect(import('.')).resolves.toMatchObject({ RPCHandler: expect.any(Function), BodyCompressionHandlerPlugin: expect.any(Function), - BodyLimitHandlerPlugin: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/fetch/index.ts b/packages/server/src/adapters/fetch/index.ts index e88265fb2..2426bcb83 100644 --- a/packages/server/src/adapters/fetch/index.ts +++ b/packages/server/src/adapters/fetch/index.ts @@ -1,5 +1,4 @@ export * from './body-compression-plugin' -export * from './body-limit-plugin' export * from './handler' export * from './plugin' export * from './rpc-handler' diff --git a/packages/server/src/adapters/node/body-limit-plugin.test.ts b/packages/server/src/adapters/node/body-limit-plugin.test.ts deleted file mode 100644 index 3fa3bfbd5..000000000 --- a/packages/server/src/adapters/node/body-limit-plugin.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { NodeHttpHandlerNodeHttpInterceptorOptions } from './handler' -import { Buffer } from 'node:buffer' -import request from 'supertest' -import { os } from '../../builder' -import { BodyLimitHandlerPlugin } from './body-limit-plugin' -import { RPCHandler } from './rpc-handler' - -describe('bodyLimitHandlerPlugin', () => { - const size22Json = { json: { foo: 'bar' } } - const toRequestListener = (handler: RPCHandler) => async (req: IncomingMessage, response: ServerResponse) => { - const result = await handler.handle(req as any, response as any) - - if (!result.matched) { - response.statusCode = 404 - response.end('not matched') - } - } - - it('ignores requests without a body', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 22 })], - }, - ) - - const res = await request(toRequestListener(handler)).get('/ping?data=%7B%7D') - - expect(res.status).toBe(200) - expect(res.text).toContain('ping') - }) - - it('allows bodies within the limit', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 22 })], - }, - ) - - const res = await request(toRequestListener(handler)) - .post('/ping') - .set('content-type', 'application/json') - .send(size22Json) - - expect(res.status).toBe(200) - expect(res.text).toContain('ping') - }) - - it('checks the content-length header', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 21 })], - }, - ) - - const res = await request(toRequestListener(handler)) - .post('/ping') - .set('content-type', 'application/json') - .set('content-length', '22') - .send({}) - - expect(res.status).toBe(413) - expect(res.text).toContain('PAYLOAD_TOO_LARGE') - }) - - it('checks the streamed body size', async () => { - const handler = new RPCHandler( - { - ping: os.handler(() => 'ping'), - }, - { - plugins: [new BodyLimitHandlerPlugin({ maxBodySize: 21 })], - }, - ) - - const res = await request(toRequestListener(handler)) - .post('/ping') - .set('content-type', 'application/json') - .send(size22Json) - - expect(res.status).toBe(413) - expect(res.text).toContain('PAYLOAD_TOO_LARGE') - }) - - it('handles repeated data events and restores emit after streamed overflow', async () => { - const plugin = new BodyLimitHandlerPlugin({ maxBodySize: 1 }) - const interceptor = plugin.initNodeHttpHandlerOptions({}).nodeHttpInterceptors![0]! - - const originalEmit = vi.fn().mockReturnValue('__EMITTED__') - const request = { - headers: { - 'content-length': '1', - }, - emit: originalEmit, - } - - await expect(interceptor({ - request, - response: {} as any, - context: {} as any, - prefix: undefined, - path: '/ping', - procedure: {} as any, - sendStandardResponseOptions: undefined, - next: async (interceptorOptions: NodeHttpHandlerNodeHttpInterceptorOptions) => { - expect(interceptorOptions.request.emit('data', Buffer.from('a'))).toBe('__EMITTED__') - expect(interceptorOptions.request.emit('data')).toBe('__EMITTED__') - interceptorOptions.request.emit('data', Buffer.from('b')) - - return { matched: true } - }, - } as any)).rejects.toMatchObject({ - code: 'PAYLOAD_TOO_LARGE', - }) - - expect(request.emit).toBe(originalEmit) - }) -}) diff --git a/packages/server/src/adapters/node/body-limit-plugin.ts b/packages/server/src/adapters/node/body-limit-plugin.ts deleted file mode 100644 index deca84f06..000000000 --- a/packages/server/src/adapters/node/body-limit-plugin.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Context } from '../../context' -import type { NodeHttpHandlerOptions } from './handler' -import type { NodeHttpHandlerPlugin } from './plugin' -import { ORPCError } from '@orpc/client' -import { toArray } from '@orpc/shared' - -export interface BodyLimitHandlerPluginOptions { - /** - * The maximum size of the body in bytes. - */ - maxBodySize: number -} - -export class BodyLimitHandlerPlugin implements NodeHttpHandlerPlugin { - name = '~body-limit' - - private readonly maxBodySize: number - - constructor(options: BodyLimitHandlerPluginOptions) { - this.maxBodySize = options.maxBodySize - } - - initNodeHttpHandlerOptions(options: NodeHttpHandlerOptions): NodeHttpHandlerOptions { - return { - ...options, - nodeHttpInterceptors: [ - async (interceptorOptions) => { - let isHeaderChecked = false - const checkHeader = () => { - if (isHeaderChecked) { - return - } - - isHeaderChecked = true - - const contentLength = interceptorOptions.request.headers['content-length'] - if (contentLength && Number(contentLength) > this.maxBodySize) { - throw new ORPCError('PAYLOAD_TOO_LARGE') - } - } - - const originalEmit = interceptorOptions.request.emit - - let currentBodySize = 0 - interceptorOptions.request.emit = (event: string, ...args: any[]) => { - if (event === 'data') { - checkHeader() - - currentBodySize += args[0]?.length ?? 0 - if (currentBodySize > this.maxBodySize) { - throw new ORPCError('PAYLOAD_TOO_LARGE') - } - } - - return originalEmit.call(interceptorOptions.request, event, ...args) - } - - try { - return await interceptorOptions.next(interceptorOptions) - } - finally { - interceptorOptions.request.emit = originalEmit - } - }, - ...toArray(options.nodeHttpInterceptors), - ], - } - } -} diff --git a/packages/server/src/adapters/node/index.test.ts b/packages/server/src/adapters/node/index.test.ts index fef728c78..402c91a07 100644 --- a/packages/server/src/adapters/node/index.test.ts +++ b/packages/server/src/adapters/node/index.test.ts @@ -1,7 +1,6 @@ -it('exports RPCHandler, BodyCompressionHandlerPlugin, BodyLimitHandlerPlugin', async () => { +it('exports RPCHandler, BodyCompressionHandlerPlugin', async () => { await expect(import('.')).resolves.toMatchObject({ RPCHandler: expect.any(Function), BodyCompressionHandlerPlugin: expect.any(Function), - BodyLimitHandlerPlugin: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/node/index.ts b/packages/server/src/adapters/node/index.ts index e88265fb2..2426bcb83 100644 --- a/packages/server/src/adapters/node/index.ts +++ b/packages/server/src/adapters/node/index.ts @@ -1,5 +1,4 @@ export * from './body-compression-plugin' -export * from './body-limit-plugin' export * from './handler' export * from './plugin' export * from './rpc-handler' diff --git a/packages/server/src/plugins/index.test.ts b/packages/server/src/plugins/index.test.ts index 28c998ea1..186ef89fc 100644 --- a/packages/server/src/plugins/index.test.ts +++ b/packages/server/src/plugins/index.test.ts @@ -7,5 +7,6 @@ it('exports plugins', async () => { CSRFGuardHandlerPlugin: expect.any(Function), RethrowHandlerPlugin: expect.any(Function), RequestCompressionHandlerPlugin: expect.any(Function), + RequestLimitHandlerPlugin: expect.any(Function), }) }) diff --git a/packages/server/src/plugins/index.ts b/packages/server/src/plugins/index.ts index 02481bece..0c087efa4 100644 --- a/packages/server/src/plugins/index.ts +++ b/packages/server/src/plugins/index.ts @@ -3,5 +3,6 @@ export * from './cors' export * from './csrf-guard' export * from './request-compression' export * from './request-headers' +export * from './request-limit' export * from './response-headers' export * from './rethrow' diff --git a/packages/server/src/plugins/request-limit.test.ts b/packages/server/src/plugins/request-limit.test.ts new file mode 100644 index 000000000..2cf1f8b31 --- /dev/null +++ b/packages/server/src/plugins/request-limit.test.ts @@ -0,0 +1,236 @@ +import zlib from 'node:zlib' +import supertest from 'supertest' +import { RPCHandler } from '../adapters/fetch' +import { RPCHandler as NodeRPCHandler } from '../adapters/node' +import { os } from '../builder' +import { RequestCompressionHandlerPlugin } from './request-compression' +import { RequestLimitHandlerPlugin } from './request-limit' + +describe('requestLimitHandlerPlugin', () => { + const size22Json = { json: { foo: 'bar' } } + const procedureHandler = vi.fn(() => 'ping') + const procedure = os.handler(procedureHandler) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('ignores requests without a body', async () => { + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [new RequestLimitHandlerPlugin({ maxBodySize: 22 })], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping?data=%7B%7D')) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('ping') + expect(response!.status).toBe(200) + }) + + it('allows bodies within the limit', async () => { + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [new RequestLimitHandlerPlugin({ maxBodySize: 22 })], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(size22Json), + })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('ping') + expect(response!.status).toBe(200) + }) + + it('rejects when content-length exceeds the limit', async () => { + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [new RequestLimitHandlerPlugin({ maxBodySize: 21 })], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-length': '22', + }, + body: JSON.stringify({}), + })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('PAYLOAD_TOO_LARGE') + expect(response!.status).toBe(413) + expect(procedureHandler).not.toHaveBeenCalled() + }) + + it('rejects when the streamed body exceeds the limit', async () => { + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [new RequestLimitHandlerPlugin({ maxBodySize: 21 })], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(size22Json), + })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('PAYLOAD_TOO_LARGE') + expect(response!.status).toBe(413) + expect(procedureHandler).not.toHaveBeenCalled() + }) + + it('does not limit when resolveBody returns a non-ReadableStream', async () => { + const handler = new RPCHandler(procedure, { + plugins: [ + new RequestLimitHandlerPlugin({ maxBodySize: 1 }), + { + name: 'test-plugin', + init(options) { + return { + ...options, + routingInterceptors: [ + async ({ next, ...interceptorOptions }) => { + return next({ + ...interceptorOptions, + request: { + ...interceptorOptions.request, + async resolveBody() { + return { json: '__MOCKED__' } + }, + }, + }) + }, + ...options.routingInterceptors ?? [], + ], + } + }, + }, + ], + }) + + const { response } = await handler.handle(new Request('http://localhost', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + })) + + expect(response?.status).toBe(200) + expect(procedureHandler).toHaveBeenCalledWith(expect.any(Object), '__MOCKED__') + }) + + it('works with the Node.js adapter', async () => { + const nodeHandler = new NodeRPCHandler( + { + ping: procedure, + }, + { + plugins: [new RequestLimitHandlerPlugin({ maxBodySize: 21 })], + }, + ) + + const server = supertest((req: any, res: any) => { + nodeHandler.handle(req, res) + }) + + const response = await server.post('/ping') + .set('content-type', 'application/json') + .send(size22Json) + + expect(response.status).toBe(413) + expect(response.text).toContain('PAYLOAD_TOO_LARGE') + expect(procedureHandler).not.toHaveBeenCalled() + }) + + describe('with RequestCompressionHandlerPlugin', () => { + it('applies the limit after decompression', async () => { + // Highly compressible: small on the wire, large after decompression. + const payload = JSON.stringify({ json: 'a'.repeat(10_000) }) + const compressed = zlib.gzipSync(payload) + const maxBodySize = 5_000 + + expect(compressed.byteLength).toBeLessThan(maxBodySize) + expect(payload.length).toBeGreaterThan(maxBodySize) + + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [ + new RequestLimitHandlerPlugin({ maxBodySize }), + new RequestCompressionHandlerPlugin(), + ], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json', + }, + body: compressed, + })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('PAYLOAD_TOO_LARGE') + expect(response!.status).toBe(413) + expect(procedureHandler).not.toHaveBeenCalled() + }) + + it('allows decompressed bodies within the limit', async () => { + const payload = JSON.stringify(size22Json) + const compressed = zlib.gzipSync(payload) + + const handler = new RPCHandler( + { + ping: procedure, + }, + { + plugins: [ + new RequestLimitHandlerPlugin({ maxBodySize: 1024 }), + new RequestCompressionHandlerPlugin(), + ], + }, + ) + + const { matched, response } = await handler.handle(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json', + }, + body: compressed, + })) + + expect(matched).toBe(true) + await expect(response!.text()).resolves.toContain('ping') + expect(response!.status).toBe(200) + }) + }) +}) diff --git a/packages/server/src/plugins/request-limit.ts b/packages/server/src/plugins/request-limit.ts new file mode 100644 index 000000000..5a2b8fd0d --- /dev/null +++ b/packages/server/src/plugins/request-limit.ts @@ -0,0 +1,100 @@ +import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '../adapters/standard' +import type { Context } from '../context' +import { ORPCError } from '@orpc/client' +import { toArray } from '@orpc/shared' +import { flattenStandardHeader } from '@standardserver/core' +import { toFetchHeaders, toStandardBody } from '@standardserver/fetch' + +export interface RequestLimitHandlerPluginOptions { + /** + * The maximum allowed request body size in bytes. + */ + maxBodySize: number +} + +/** + * Rejects requests whose body exceeds `maxBodySize`. + * + * When used with the request compression plugin, the limit applies to the + * decompressed payload rather than the compressed wire size. + * + * @see {@link https://orpc.dev/docs/plugins/request-limit Request Limit Plugin Docs} + */ +export class RequestLimitHandlerPlugin implements StandardHandlerPlugin { + name = '~request-limit' + + /** + * Should limit the original batch request body instead of sub-requests. + */ + after = ['~batch'] + + /** + * Should limit the final body size instead of the compressed one. + */ + before = ['~request-compression'] + + private readonly maxBodySize: number + + constructor(options: RequestLimitHandlerPluginOptions) { + this.maxBodySize = options.maxBodySize + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const maxBodySize = this.maxBodySize + + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + return next({ + ...interceptorOptions, + request: { + ...interceptorOptions.request, + async resolveBody(hint) { + const contentLength = Number( + flattenStandardHeader(interceptorOptions.request.headers['content-length']), + ) + + if (Number.isFinite(contentLength) && contentLength > maxBodySize) { + throw new ORPCError('PAYLOAD_TOO_LARGE') + } + + const stream = await interceptorOptions.request.resolveBody('octet-stream') + + // adapter might not support hint (e.g. peer adapter) + if (!(stream instanceof ReadableStream)) { + return stream + } + + let currentBodySize = 0 + const limitedStream = stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + currentBodySize += chunk.byteLength + + if (currentBodySize > maxBodySize) { + controller.error(new ORPCError('PAYLOAD_TOO_LARGE')) + return + } + + controller.enqueue(chunk) + }, + }), + ) + + const response = new Response(limitedStream, { + headers: toFetchHeaders(interceptorOptions.request.headers), + }) + + return toStandardBody(response, { hint }) + }, + }, + }) + } + + return { + ...options, + routingInterceptors: [ + routingInterceptor, + ...toArray(options.routingInterceptors), + ], + } + } +}