diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index 5fdb97a..d922916 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -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 `.lambda-url..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 diff --git a/packages/aws-lambda/src/types.ts b/packages/aws-lambda/src/types.ts index 4e11f08..b9640fa 100644 --- a/packages/aws-lambda/src/types.ts +++ b/packages/aws-lambda/src/types.ts @@ -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' */ @@ -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' */ @@ -64,6 +71,13 @@ export interface APIGatewayProxyEventV2 { */ cookies?: string[] | null requestContext: { + /** + * `.lambda-url..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. diff --git a/packages/aws-lambda/src/url.test.ts b/packages/aws-lambda/src/url.test.ts index 2a1335c..fdbfccd 100644 --- a/packages/aws-lambda/src/url.test.ts +++ b/packages/aws-lambda/src/url.test.ts @@ -1,3 +1,5 @@ +import { safeDecodeURIComponent } from '@standard-server/shared' + import { toStandardUrl } from './url' describe('toStandardUrl (v2)', () => { @@ -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'], @@ -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', @@ -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') }) }) diff --git a/packages/aws-lambda/src/url.ts b/packages/aws-lambda/src/url.ts index 0bf6f54..11ea6d9 100644 --- a/packages/aws-lambda/src/url.ts +++ b/packages/aws-lambda/src/url.ts @@ -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. * @@ -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') const query = new URLSearchParams() @@ -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}` }