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
3 changes: 2 additions & 1 deletion packages/aws-lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ The event carries the request body as a fully buffered, optionally base64-encode
- **`set-cookie` is sent via metadata cookies.** Multiple cookies survive because they are sent through the dedicated `cookies` metadata field; every other multi-value header is joined with `, `.
- **Request bodies are buffered.** API Gateway delivers the whole request body at once, so request-side streaming degrades to a single buffered chunk. Response-side streaming is real streaming.
- **Payload format 1.0 query strings are re-encoded.** API Gateway delivers them url-decoded, so the adapter re-encodes them when reconstructing the standard url. Payload format 2.0 provides the already encoded `rawQueryString`, which is used as-is.
- **Decoded paths are re-escaped.** HTTP APIs deliver the request path url-decoded (payload format 2.0 `rawPath` included), while REST APIs and Lambda Function URLs deliver it still encoded. The adapter percent-encodes the same characters the WHATWG [`URL`](https://url.spec.whatwg.org/#path-percent-encode-set) parser escapes in a pathname (`?`, `#`, spaces, non-ascii, ...), so encoded paths pass through unchanged and a decoded `?` or `#` cannot be mistaken for the query string or fragment. Characters API Gateway already decoded into url syntax (`%2F` → `/`) or dropped cannot be recovered.
- **HTTP API paths are re-escaped.** HTTP APIs deliver the request path url-decoded (payload format 1.0 `path` and 2.0 `rawPath`), while REST APIs, ALB and Lambda Function URLs deliver it still encoded. Encoded paths only get the characters the WHATWG [`URL`](https://url.spec.whatwg.org/#path-percent-encode-set) parser escapes in a pathname (`?`, `#`, spaces, non-ascii, ...), so they pass through unchanged. Decoded paths get everything but RFC 3986 path characters escaped, `%` and `\` included, so they decode back to exactly what API Gateway delivered: a decoded `?` or `#` cannot be mistaken for the query string or fragment, and a decoded `%2e%2e` or `\` cannot become a dot segment or a `/` that resolves to a path API Gateway never routed. HTTP API events are recognized by `version: '1.0'` in payload format 1.0, and in 2.0 by any `requestContext.domainName` (or none) other than a Function URL's `<url-id>.lambda-url.<region>.on.aws`.
- **HTTP API path decoding cannot be fully undone.** Characters API Gateway already decoded into url syntax (`%2F` → `/`) or dropped (a decoded `?` truncates the path) cannot be recovered. HTTP APIs have also been observed decoding the path twice (`%2525` arrives as `%`, `%252F` as `/`), so a request such as `/public/%252e%252e/secret` may match a `/public/{proxy+}` route yet reach the function with a literal `..` segment. A dot segment cannot be represented in a URL without being resolved, so your app would see `/secret`. Do not rely on route-level authorizers alone to protect a path next to an unauthenticated greedy route; also authorize in the app, against the path it routes on.
- **Payload format 2.0 cookies are restored.** API Gateway strips the `cookie` header into the separate `cookies` field, and the adapter joins them back into a `cookie` header on the standard request.

## Learn more
Expand Down
18 changes: 16 additions & 2 deletions packages/aws-lambda/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import type { Writable } from 'node:stream'
* A structural subset of the same-named type from `@types/aws-lambda`.
*/
export interface APIGatewayProxyEvent {
/**
* Only set by HTTP APIs, REST APIs and ALB omit it.
*
* @example '1.0'
*/
version?: string
/**
* @example 'GET', 'POST', etc.
*/
httpMethod: string
/**
* Url-decoded when delivered by an HTTP API, still encoded from a REST API.
* Url-decoded when delivered by an HTTP API (`version` is `'1.0'`), still encoded from a REST API or ALB.
*
* @example '/example'
*/
Expand Down Expand Up @@ -46,7 +52,8 @@ export interface APIGatewayProxyEvent {
*/
export interface APIGatewayProxyEventV2 {
/**
* Url-decoded when delivered by an HTTP API, still encoded from a Lambda Function URL.
* Url-decoded when delivered by an HTTP API, still encoded from a Lambda Function URL,
* told apart by `requestContext.domainName`.
*
* @example '/example'
*/
Expand All @@ -64,6 +71,13 @@ export interface APIGatewayProxyEventV2 {
*/
cookies?: string[] | null
requestContext: {
/**
* `<url-id>.lambda-url.<region>.on.aws` for Lambda Function URLs,
* anything else, or missing, is treated as an HTTP API.
*
* @example 'id.execute-api.us-east-1.amazonaws.com'
*/
domainName?: string
http: {
/**
* @example 'GET', 'POST', etc.
Expand Down
149 changes: 82 additions & 67 deletions packages/aws-lambda/src/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { safeDecodeURIComponent } from '@standard-server/shared'

import { toStandardUrl } from './url'

describe('toStandardUrl (v2)', () => {
Expand Down Expand Up @@ -114,8 +116,18 @@ describe('toStandardUrl (v1)', () => {
})

describe('toStandardUrl path escaping', () => {
const v2 = (domainName?: string) => (rawPath: string) => ({
rawPath,
requestContext: { domainName, http: { method: 'GET' } },
})

const httpApiV2 = v2('id.execute-api.us-east-1.amazonaws.com')
const functionUrl = v2('url-id.lambda-url.us-east-1.on.aws')
const httpApiV1 = (path: string) => ({ version: '1.0', httpMethod: 'GET', path })
const restApi = (path: string) => ({ httpMethod: 'GET', path })

// HTTP APIs deliver the path url-decoded, values observed on a live API Gateway HTTP API
const decoded: [rawPath: string, pathname: string][] = [
const decoded: [path: string, pathname: string][] = [
['/capture/space value', '/capture/space%20value'],
['/capture/hash#value', '/capture/hash%23value'],
['/capture/literal?value', '/capture/literal%3Fvalue'],
Expand All @@ -124,12 +136,20 @@ describe('toStandardUrl path escaping', () => {
'/capture/quote"brace{}angle<>tick`caret^',
'/capture/quote%22brace%7B%7Dangle%3C%3Etick%60caret%5E',
],
['/capture/bracket[value]|pipe', '/capture/bracket%5Bvalue%5D%7Cpipe'],
['/capture/tab\tnewline\ndel\x7F', '/capture/tab%09newline%0Adel%7F'],
// a decoded `%` or `\` is data, `URL` must neither decode it again nor treat `\` as `/`
['/capture/literal%value', '/capture/literal%25value'],
['/capture/percent%25done', '/capture/percent%2525done'],
['/capture/literal%2525value', '/capture/literal%252525value'],
// matched a `/public/{proxy+}` route, it must not resolve to `/secret`
['/public/%2e%2e/secret', '/public/%252e%252e/secret'],
['/public/x\\..\\..\\secret', '/public/x%5C..%5C..%5Csecret'],
// encodeURIComponent throws URIError on a lone surrogate, it becomes U+FFFD instead
['/capture/lone\uD800surrogate', '/capture/lone%EF%BF%BDsurrogate'],
]

// REST APIs and Lambda Function URLs deliver the path still encoded, it must not be double-encoded,
// REST APIs, ALB and Lambda Function URLs deliver the path still encoded, it must not be double-encoded,
// and characters `URL` leaves alone in a pathname stay as-is too
const untouched = [
'/capture/space%20value',
Expand All @@ -140,91 +160,86 @@ describe('toStandardUrl path escaping', () => {
'/capture/literal%2525value',
'/capture/unicode-%CE%BB-%E4%B8%96%E7%95%8C',
'/capture/lower%2fcase',
'/public/%2e%2e/secret',
"/AZaz09-._~!$&'()*+,;=:@",
'/a[b]|c\\d',
'/capture/literal%value',
]

describe('v2', () => {
it.each(decoded)('escapes decoded %s', (rawPath, pathname) => {
expect(toStandardUrl({ rawPath, requestContext: { http: { method: 'GET' } } })).toBe(pathname)
describe.each([
['HTTP API (v2)', httpApiV2],
['HTTP API (v1)', httpApiV1],
] as const)('%s', (_, toEvent) => {
it.each(decoded)('escapes decoded %s', (path, pathname) => {
expect(toStandardUrl(toEvent(path))).toBe(pathname)
})

it.each(untouched)('keeps %s as-is', (rawPath) => {
expect(toStandardUrl({ rawPath, requestContext: { http: { method: 'GET' } } })).toBe(rawPath)
})
it('decodes back to the delivered path exactly once', () => {
for (const [path] of decoded.filter(([path]) => !path.includes('\uD800'))) {
const url = new URL(toStandardUrl(toEvent(path)), 'http://localhost')

it('keeps the query string separate from a decoded ? or # in the path', () => {
expect(
toStandardUrl({
rawPath: '/orders#',
rawQueryString: 'tenant=acme',
requestContext: { http: { method: 'GET' } },
}),
).toBe('/orders%23?tenant=acme')

expect(
toStandardUrl({
rawPath: '/users/me?admin=true',
rawQueryString: 'tenant=acme',
requestContext: { http: { method: 'GET' } },
}),
).toBe('/users/me%3Fadmin=true?tenant=acme')
expect(safeDecodeURIComponent(url.pathname)).toBe(path)
}
})
})

describe('v1', () => {
it.each(decoded)('escapes decoded %s', (path, pathname) => {
expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(pathname)
})

describe.each([
['Lambda Function URL', functionUrl],
['REST API', restApi],
] as const)('%s', (_, toEvent) => {
it.each(untouched)('keeps %s as-is', (path) => {
expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(path)
expect(toStandardUrl(toEvent(path))).toBe(path)
})

it('keeps the query string separate from a decoded ? or # in the path', () => {
expect(
toStandardUrl({
httpMethod: 'GET',
path: '/orders#',
queryStringParameters: { tenant: 'acme' },
}),
).toBe('/orders%23?tenant=acme')

expect(
toStandardUrl({
httpMethod: 'GET',
path: '/users/me?admin=true',
queryStringParameters: { tenant: 'acme' },
}),
).toBe('/users/me%3Fadmin=true?tenant=acme')
it('escapes like the URL pathname setter', () => {
// `^` is left out: it joined the path percent-encode set in 2023 and Node 20/22 still leave it as-is
for (const path of [
'/space value',
'/hash#value',
'/literal?value',
'/unicode-λ-世界',
'/quote"brace{}angle<>tick`',
'/a[b]|c',
'/literal%value',
'/%20%2F',
'/lone\uD800surrogate',
]) {
const url = new URL('http://localhost')
url.pathname = path

expect(toStandardUrl(toEvent(path))).toBe(url.pathname)
}
})
})

it('escapes the same characters as the URL pathname setter', () => {
// `^` is left out: it joined the path percent-encode set in 2023 and Node 20/22 still leave it as-is
for (const path of [
'/space value',
'/hash#value',
'/literal?value',
'/unicode-λ-世界',
'/quote"brace{}angle<>tick`',
'/a[b]|c',
'/literal%value',
'/%20%2F',
'/lone\uD800surrogate',
]) {
const url = new URL('http://localhost')
url.pathname = path
it('treats a v2 event as an HTTP API unless its domainName is a Function URL', () => {
expect(toStandardUrl(v2()('/a%2e%2e'))).toBe('/a%252e%252e')
expect(toStandardUrl(v2('url-id.lambda-url.example.com')('/a%2e%2e'))).toBe('/a%252e%252e')
})

expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(url.pathname)
}
it('keeps the query string separate from a decoded ? or # in the path', () => {
expect(toStandardUrl({ ...httpApiV2('/orders#'), rawQueryString: 'tenant=acme' })).toBe(
'/orders%23?tenant=acme',
)
expect(
toStandardUrl({ ...httpApiV2('/users/me?admin=true'), rawQueryString: 'tenant=acme' }),
).toBe('/users/me%3Fadmin=true?tenant=acme')

expect(
toStandardUrl({ ...httpApiV1('/orders#'), queryStringParameters: { tenant: 'acme' } }),
).toBe('/orders%23?tenant=acme')
expect(
toStandardUrl({
...httpApiV1('/users/me?admin=true'),
queryStringParameters: { tenant: 'acme' },
}),
).toBe('/users/me%3Fadmin=true?tenant=acme')
})

it('adds a leading slash after escaping', () => {
expect(toStandardUrl({ httpMethod: 'GET', path: 'a b' })).toBe('/a%20b')
expect(toStandardUrl({ rawPath: 'a b', requestContext: { http: { method: 'GET' } } })).toBe(
'/a%20b',
)
expect(toStandardUrl(restApi('a b'))).toBe('/a%20b')
expect(toStandardUrl(httpApiV1('a%b'))).toBe('/a%25b')
expect(toStandardUrl(httpApiV2('a%b'))).toBe('/a%25b')
expect(toStandardUrl(functionUrl('a b'))).toBe('/a%20b')
})
})
23 changes: 19 additions & 4 deletions packages/aws-lambda/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import { safeEncodeURIComponent } from '@standard-server/shared'

import type { AnyAPIGatewayProxyEvent } from './types'

// the WHATWG path percent-encode set, characters that can never appear literally in an encoded path
const UNENCODED_PATH_CHAR_RE = /[\0-\x20"#<>?^`{}\x7F-\u{10FFFF}]/gu

// anything but an RFC 3986 path character, a decoded path carries `%`, `\`, `?`, ... as data: left as-is,
// `URL` would decode `%2e%2e` again into a dot segment or treat `\` as `/`, resolving segments API Gateway never routed on
const DECODED_PATH_CHAR_RE = /[^A-Za-z0-9\-._~!$&'()*+,;=:@/]/gu

// anchored, so an HTTP API custom domain cannot pass for a Lambda Function URL
const FUNCTION_URL_DOMAIN_RE = /\.lambda-url\.[a-z0-9-]+\.on\.aws$/

/**
* Build a standard url from an API Gateway proxy event.
*
Expand All @@ -13,12 +21,16 @@ const UNENCODED_PATH_CHAR_RE = /[\0-\x20"#<>?^`{}\x7F-\u{10FFFF}]/gu
*/
export function toStandardUrl(event: AnyAPIGatewayProxyEvent): StandardUrl {
if (!('httpMethod' in event)) {
const pathname = toPathname(event.rawPath)
// HTTP APIs deliver `rawPath` url-decoded, Lambda Function URLs still encoded
const isDecoded = !FUNCTION_URL_DOMAIN_RE.test(event.requestContext.domainName ?? '')
const pathname = toPathname(event.rawPath, isDecoded)

return event.rawQueryString ? `${pathname}?${event.rawQueryString}` : pathname
}

const pathname = toPathname(event.path)
// only HTTP APIs set `version` and deliver `path` url-decoded, REST APIs and ALB still encoded,
// decoded is inferred from 2.0 captures: wrongly escaping costs a double-encoding, not escaping a traversal
const pathname = toPathname(event.path, event.version === '1.0')
Comment thread
dinwwwh marked this conversation as resolved.

const query = new URLSearchParams()

Expand Down Expand Up @@ -47,8 +59,11 @@ export function toStandardUrl(event: AnyAPIGatewayProxyEvent): StandardUrl {
return search === '' ? pathname : `${pathname}?${search}`
}

function toPathname(path: string): `/${string}` {
const encoded = path.replace(UNENCODED_PATH_CHAR_RE, safeEncodeURIComponent)
function toPathname(path: string, isDecoded: boolean): `/${string}` {
const encoded = path.replace(
isDecoded ? DECODED_PATH_CHAR_RE : UNENCODED_PATH_CHAR_RE,
safeEncodeURIComponent,
)

return encoded.startsWith('/') ? (encoded as `/${string}`) : `/${encoded}`
}
Loading