Skip to content
Closed
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
23 changes: 23 additions & 0 deletions apps/content/docs/adapters/aws-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<region>.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.
Expand Down
27 changes: 27 additions & 0 deletions packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
98 changes: 98 additions & 0 deletions packages/server/src/adapters/aws-lambda/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RPCHandler>[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 })
})
})
})
57 changes: 55 additions & 2 deletions packages/server/src/adapters/aws-lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -13,28 +13,51 @@ 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.<region>.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<T extends Context> {
private readonly sendStandardResponseOptions: AwsLambdaHandlerOptions<T>['sendStandardResponse']
private readonly pathEncoding: Exclude<AwsLambdaHandlerOptions<T>['pathEncoding'], undefined>

constructor(
private readonly standardHandler: StandardHandler<T>,
options: NoInfer<AwsLambdaHandlerOptions<T>> = {},
) {
this.sendStandardResponseOptions = options.sendStandardResponse
this.pathEncoding = options.pathEncoding ?? 'auto'
}

async handle(
event: AnyAPIGatewayProxyEvent,
responseStream: HttpResponseStream,
...rest: MaybeOptionalOptions<FriendlyStandardHandlerHandleOptions<T>>
): Promise<AwsLambdaHandlerHandleResult> {
const standardRequest = toStandardLazyRequest(event, responseStream)
const standardRequest = toStandardLazyRequest(this.escapeDecodedPath(event), responseStream)

const result = await this.standardHandler.handle(
standardRequest,
Expand All @@ -49,4 +72,34 @@ export class AwsLambdaHandler<T extends Context> {

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)
}
Loading