diff --git a/apps/content/docs/adapters/aws-lambda.mdx b/apps/content/docs/adapters/aws-lambda.mdx index a10f4bd1d0..c2fbdce1ca 100644 --- a/apps/content/docs/adapters/aws-lambda.mdx +++ b/apps/content/docs/adapters/aws-lambda.mdx @@ -101,6 +101,29 @@ const cors = new CORSHandlerPlugin({ ::: +## Path Encoding + +API Gateway HTTP APIs deliver the request path (`rawPath` in payload 2.0, `path` in payload 1.0) already URL-decoded, while REST APIs, ALB, and Lambda Function URLs deliver it still encoded. The handler re-escapes decoded paths before matching, so every path is decoded only once and a literal `%61` in an HTTP API path stays `%61` instead of becoming `a`. + +By default (`'auto'`), a payload 1.0 event is treated as decoded when it carries `version: '1.0'` (HTTP API), and a payload 2.0 event is treated as decoded unless `requestContext.domainName` is a Function URL host (`*.lambda-url..on.aws`). Set `pathEncoding` to override the detection: + +```ts +const handler = new RPCHandler(router, { + /** + * 'auto': detect from the event (default) + * 'encoded': every path is still URL-encoded + * 'decoded': every path is already URL-decoded + * + * @default 'auto' + */ + pathEncoding: 'auto', +}) +``` + +:::warning +Characters that API Gateway has already decoded into URL syntax, such as `%2F` into `/`, cannot be recovered. Enforce authorization in oRPC [middleware](/docs/middleware) rather than relying only on API Gateway route-level authorizers or WAF path rules to protect a subset of procedures, since those operate on a path form that can differ from the one oRPC matches. +::: + ## Event Stream Options You can configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `sendStandardResponse.eventStream` options when creating the handler. diff --git a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts b/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts index db2258468b..90accd8846 100644 --- a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts @@ -109,3 +109,30 @@ describe('openapiHandler', () => { expect(responseStream.text).toBe('"intercepted"') }) }) + +describe('openapiHandler path decoding', () => { + const HTTP_API = 'abc123.execute-api.us-east-1.amazonaws.com' + const FUNCTION_URL = 'abc123xyz.lambda-url.us-east-1.on.aws' + + const handler = new OpenAPIHandler({ + get: os.meta(openapi({ method: 'GET', path: '/users/{id}' })).handler(({ input }) => input), + }) + + it.each([ + ['/users/a%2Fb', HTTP_API, 'a%2Fb'], + ['/users/%41', HTTP_API, '%41'], + ['/users/100%', HTTP_API, '100%'], + ['/users/a b', HTTP_API, 'a b'], + ['/users/é\\', HTTP_API, 'é\\'], + ['/users/a%2Fb', FUNCTION_URL, 'a/b'], // encoded, decoded once + ])('%s from %s yields id %s', async (rawPath, domainName, id) => { + const responseStream = new TestResponseStream() + await handler.handle(createEvent({ + rawPath, + body: null, + requestContext: { http: { method: 'GET' }, domainName } as APIGatewayProxyEventV2['requestContext'], + }), responseStream) + + expect(JSON.parse(responseStream.text)).toEqual({ id }) + }) +}) diff --git a/packages/server/src/adapters/aws-lambda/handler.test.ts b/packages/server/src/adapters/aws-lambda/handler.test.ts new file mode 100644 index 0000000000..163d7cb46e --- /dev/null +++ b/packages/server/src/adapters/aws-lambda/handler.test.ts @@ -0,0 +1,98 @@ +import type { AnyAPIGatewayProxyEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2, AwsLambdaGlobal, HttpResponseStream } from '@standard-server/aws-lambda' +import { Buffer } from 'node:buffer' +import { Writable } from 'node:stream' +import { os } from '../../builder' +import { RPCHandler } from './rpc-handler' + +class TestResponseStream extends Writable implements HttpResponseStream { + chunks: Buffer[] = [] + setContentType(): void { + // noop + } + + override _write(chunk: any, _encoding: BufferEncoding, callback: () => void): void { + this.chunks.push(Buffer.from(chunk)) + callback() + } +} + +const headers = { 'host': 'example.com', 'content-type': 'application/json' } +const body = JSON.stringify({ json: null }) + +// 2.0: HTTP APIs (any domain but a Function URL's) deliver rawPath decoded, Function URLs still encoded +function v2Event(rawPath: string, domainName?: string): APIGatewayProxyEventV2 { + return { rawPath, rawQueryString: '', headers, requestContext: { http: { method: 'POST' }, domainName }, body, isBase64Encoded: false } as APIGatewayProxyEventV2 +} + +// 1.0: HTTP APIs set `version: '1.0'` and deliver `path` decoded, REST APIs/ALB omit `version` and keep it encoded +function v1Event(path: string, version?: '1.0'): APIGatewayProxyEvent { + return { ...(version && { version }), httpMethod: 'POST', path, headers, body, isBase64Encoded: false } as APIGatewayProxyEvent +} + +const HTTP_API = 'abc123.execute-api.us-east-1.amazonaws.com' +const FUNCTION_URL = 'abc123xyz.lambda-url.us-east-1.on.aws' + +beforeEach(() => { + vi.stubGlobal('awslambda', { + streamifyResponse: handler => handler, + HttpResponseStream: { from: responseStream => responseStream }, + } satisfies AwsLambdaGlobal) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function call(event: AnyAPIGatewayProxyEvent, options: ConstructorParameters[1] = {}) { + const deleteUser = vi.fn(() => 'deleted') + const handler = new RPCHandler({ admin: { deleteUser: os.handler(deleteUser) } }, options) + const result = await handler.handle(event, new TestResponseStream(), { prefix: '/rpc' }) + return { matched: result.matched, called: deleteUser.mock.calls.length > 0 } +} + +describe('awsLambdaHandler path decoding', () => { + // the event carries the literal text "%61dmin", which must not become "admin" + it.each([ + ['HTTP API 2.0', v2Event('/rpc/%61dmin/deleteUser', HTTP_API)], + ['HTTP API 2.0 custom domain', v2Event('/rpc/%61dmin/deleteUser', 'api.example.com')], + ['HTTP API 2.0 custom domain posing as a Function URL', v2Event('/rpc/%61dmin/deleteUser', `${FUNCTION_URL}.example.com`)], + ['2.0 without domainName', v2Event('/rpc/%61dmin/deleteUser')], + ['HTTP API 1.0', v1Event('/rpc/%61dmin/deleteUser', '1.0')], + ])('%s: does not decode an already-decoded path again', async (_, event) => { + expect(await call(event)).toEqual({ matched: false, called: false }) + }) + + it.each([ + ['HTTP API 2.0 plain path', v2Event('/rpc/admin/deleteUser', HTTP_API)], + ['HTTP API 1.0 plain path', v1Event('/rpc/admin/deleteUser', '1.0')], + ['Function URL (encoded)', v2Event('/rpc/%61dmin/deleteUser', FUNCTION_URL)], + ['REST API 1.0 (encoded)', v1Event('/rpc/%61dmin/deleteUser')], + ])('%s: still matches', async (_, event) => { + expect(await call(event)).toEqual({ matched: true, called: true }) + }) + + describe('pathEncoding option', () => { + it('encoded: decodes every path once', async () => { + expect(await call(v2Event('/rpc/%61dmin/deleteUser', HTTP_API), { pathEncoding: 'encoded' })) + .toEqual({ matched: true, called: true }) + expect(await call(v1Event('/rpc/%61dmin/deleteUser', '1.0'), { pathEncoding: 'encoded' })) + .toEqual({ matched: true, called: true }) + }) + + it('decoded: never decodes a path again', async () => { + expect(await call(v2Event('/rpc/%61dmin/deleteUser', FUNCTION_URL), { pathEncoding: 'decoded' })) + .toEqual({ matched: false, called: false }) + expect(await call(v1Event('/rpc/%61dmin/deleteUser'), { pathEncoding: 'decoded' })) + .toEqual({ matched: false, called: false }) + expect(await call(v2Event('/rpc/admin/deleteUser', FUNCTION_URL), { pathEncoding: 'decoded' })) + .toEqual({ matched: true, called: true }) + }) + + it('auto: is the default', async () => { + expect(await call(v2Event('/rpc/%61dmin/deleteUser', HTTP_API), { pathEncoding: 'auto' })) + .toEqual({ matched: false, called: false }) + expect(await call(v2Event('/rpc/%61dmin/deleteUser', FUNCTION_URL), { pathEncoding: 'auto' })) + .toEqual({ matched: true, called: true }) + }) + }) +}) diff --git a/packages/server/src/adapters/aws-lambda/handler.ts b/packages/server/src/adapters/aws-lambda/handler.ts index 8422ce1fa5..369f3263b7 100644 --- a/packages/server/src/adapters/aws-lambda/handler.ts +++ b/packages/server/src/adapters/aws-lambda/handler.ts @@ -2,7 +2,7 @@ import type { MaybeOptionalOptions } from '@orpc/shared' import type { AnyAPIGatewayProxyEvent, HttpResponseStream, SendStandardResponseOptions } from '@standard-server/aws-lambda' import type { Context } from '../../context' import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' -import { resolveMaybeOptionalOptions } from '@orpc/shared' +import { resolveMaybeOptionalOptions, safeEncodeURIComponent } from '@orpc/shared' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/aws-lambda' import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' @@ -13,20 +13,43 @@ export interface AwsLambdaHandlerOptions<_T extends Context> { * Custom options for `sendStandardResponse`, used to send a `Standard Response` */ sendStandardResponse?: SendStandardResponseOptions | undefined + + /** + * Whether the event path (`rawPath` in payload 2.0, `path` in payload 1.0) arrives url-encoded or already url-decoded. + * + * API Gateway HTTP APIs deliver the path already decoded, while REST APIs, ALB and Lambda Function URLs deliver it still encoded. + * A decoded path is re-escaped before matching, so it is decoded only once and a literal `%XX` in it is not decoded a second time. + * + * - `'auto'`: payload 1.0 events are decoded when they carry `version: '1.0'` (HTTP API), and payload 2.0 events are decoded + * unless `requestContext.domainName` is a Lambda Function URL host (`*.lambda-url..on.aws`). + * - `'encoded'`: every path is still encoded. + * - `'decoded'`: every path is already decoded. + * + * @default 'auto' + */ + pathEncoding?: 'auto' | 'encoded' | 'decoded' | undefined } +const FUNCTION_URL_DOMAIN_REGEX = /\.lambda-url\.[a-z0-9-]+\.on\.aws$/ +/** + * Everything but RFC 3986 unreserved characters, sub-delims, `:`, `@` and `/`. + */ +const NON_PATH_CHAR_REGEX = /[^\w\-.~!$&'()*+,;=:@/]/gu + /** * Requires the AWS Lambda Node.js runtime with response streaming enabled, * handlers should be wrapped with `awslambda.streamifyResponse`. */ export class AwsLambdaHandler { private readonly sendStandardResponseOptions: AwsLambdaHandlerOptions['sendStandardResponse'] + private readonly pathEncoding: Exclude['pathEncoding'], undefined> constructor( private readonly standardHandler: StandardHandler, options: NoInfer> = {}, ) { this.sendStandardResponseOptions = options.sendStandardResponse + this.pathEncoding = options.pathEncoding ?? 'auto' } async handle( @@ -34,7 +57,7 @@ export class AwsLambdaHandler { responseStream: HttpResponseStream, ...rest: MaybeOptionalOptions> ): Promise { - const standardRequest = toStandardLazyRequest(event, responseStream) + const standardRequest = toStandardLazyRequest(this.escapeDecodedPath(event), responseStream) const result = await this.standardHandler.handle( standardRequest, @@ -49,4 +72,34 @@ export class AwsLambdaHandler { return result } + + /** + * Re-escapes an already-decoded path so it is decoded only once, + * since `toStandardLazyRequest` treats every path as encoded and leaves `%` as is. + */ + private escapeDecodedPath(event: AnyAPIGatewayProxyEvent): AnyAPIGatewayProxyEvent { + if (this.pathEncoding === 'encoded' || (this.pathEncoding === 'auto' && !isDecodedPathEvent(event))) { + return event + } + + if ('httpMethod' in event) { + return { ...event, path: event.path.replace(NON_PATH_CHAR_REGEX, safeEncodeURIComponent) } + } + + return { ...event, rawPath: event.rawPath.replace(NON_PATH_CHAR_REGEX, safeEncodeURIComponent) } + } +} + +/** + * HTTP APIs deliver decoded paths, REST APIs, ALB and Lambda Function URLs deliver encoded ones. + */ +function isDecodedPathEvent(event: AnyAPIGatewayProxyEvent): boolean { + if ('httpMethod' in event) { + // only HTTP APIs set `version` in payload 1.0 + return (event as { version?: unknown }).version === '1.0' + } + + const domainName = (event.requestContext as { domainName?: unknown }).domainName + + return typeof domainName !== 'string' || !FUNCTION_URL_DOMAIN_REGEX.test(domainName) }