From 7c548539b2d9b99c2179f6a4524c2104a620eec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:09:33 +0000 Subject: [PATCH 1/3] fix(aws-lambda): escape `%` and `\` in HTTP API paths so they are not decoded again API Gateway HTTP APIs deliver the request path url-decoded (payload format 1.0 `path` and 2.0 `rawPath`), but `toStandardUrl` left `%` and `\` as-is for every source. A decoded `%2e%2e` was then decoded again by `URL` into a dot segment, and a decoded `\` (sent as `%5C`) was treated as `/`, so the app resolved a path API Gateway never routed on: `/public/x%5C..%5C..%5Csecret` matches `/public/{proxy+}` yet the app serves `/secret`, skipping a route-level authorizer on `GET /secret`. ## Fixes - HTTP API paths now also escape `%` and `\`, so they decode back to exactly what API Gateway delivered and keep their segment structure. - REST APIs, ALB and Lambda Function URLs deliver encoded paths and keep passing through unchanged. ## Notes - Sources are told apart without guessing from the path: only HTTP APIs send a top-level `version` on payload format 1.0 (REST and ALB omit it), and Function URLs always carry a `requestContext.domainName` of `.lambda-url..on.aws`. A 2.0 event without a `domainName` (never seen in real events) is treated as an HTTP API, so hand-built test events with an encoded `rawPath` now need a Function URL `domainName` to pass through. - `version` and `requestContext.domainName` are added as optional fields, `@types/aws-lambda` events still satisfy both types. - Live captures (hotsock/voker#8) show HTTP APIs decoding the path twice (`%2525` arrives as `%`), so `%252e%252e` may still arrive as a literal `..`, which no URL can carry unresolved. The README documents this and advises authorizing in the app rather than relying on route-level authorizers alone. ## Testing - `url.test.ts` covers HTTP API v1/v2 with `%2e%2e`, `%25`, `%2525` and `\`, a decode round-trip, Function URL and REST passthrough, and the `domainName` detection. The new HTTP API cases fail on the previous implementation. - `pnpm run check` and `pnpm test` are green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbRvaNSKxHggDmMVk5VhHM --- packages/aws-lambda/README.md | 3 +- packages/aws-lambda/src/types.ts | 18 +++- packages/aws-lambda/src/url.test.ts | 131 ++++++++++++++++++++-------- packages/aws-lambda/src/url.ts | 22 ++++- 4 files changed, 132 insertions(+), 42 deletions(-) diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index 5fdb97a..b39abf0 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, as payload format 1.0 `path` (marked by the top-level `version: '1.0'` only HTTP APIs send) and as 2.0 `rawPath`. REST APIs, ALB and Lambda Function URLs deliver it still encoded; Function URLs are told apart from HTTP APIs by their `requestContext.domainName` (`.lambda-url..on.aws`), and a payload format 2.0 event without one is treated as an HTTP API. For every source the adapter percent-encodes the 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. For HTTP APIs it also escapes `%` and `\`, so the path decodes back to exactly what API Gateway delivered: a decoded `%2e%2e` or `\` stays part of its segment instead of becoming a dot segment or a `/` that resolves to a path API Gateway never routed. +- **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..8eda452 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,30 @@ describe('toStandardUrl (v1)', () => { }) describe('toStandardUrl path escaping', () => { + const httpApiV2 = (rawPath: string) => ({ + version: '2.0', + rawPath, + requestContext: { + domainName: 'id.execute-api.us-east-1.amazonaws.com', + http: { method: 'GET' }, + }, + }) + + const functionUrl = (rawPath: string) => ({ + version: '2.0', + rawPath, + requestContext: { + domainName: 'url-id.lambda-url.us-east-1.on.aws', + http: { method: 'GET' }, + }, + }) + + 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'], @@ -125,11 +149,17 @@ describe('toStandardUrl path escaping', () => { '/capture/quote%22brace%7B%7Dangle%3C%3Etick%60caret%5E', ], ['/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'], + ['/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,68 +170,98 @@ 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', () => { + describe('HTTP API (v2)', () => { it.each(decoded)('escapes decoded %s', (rawPath, pathname) => { - expect(toStandardUrl({ rawPath, requestContext: { http: { method: 'GET' } } })).toBe(pathname) + expect(toStandardUrl(httpApiV2(rawPath))).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 [rawPath] of decoded.filter(([path]) => !path.includes('\uD800'))) { + const url = new URL(toStandardUrl(httpApiV2(rawPath)), 'http://localhost') + + expect(safeDecodeURIComponent(url.pathname)).toBe(rawPath) + } }) - it('keeps the query string separate from a decoded ? or # in the path', () => { + it('keeps an encoded dot segment from climbing out of the routed path', () => { + // `GET /public/%252e%252e/secret` reaches the `/public/{proxy+}` route as `/public/%2e%2e/secret` + for (const rawPath of ['/public/%2e%2e/secret', '/public/x\\..\\..\\secret']) { + const url = new URL(toStandardUrl(httpApiV2(rawPath)), 'http://localhost') + + expect(url.pathname).toMatch(/^\/public\//) + } + }) + + it('treats a missing or non Function URL domainName as an HTTP API', () => { expect( - toStandardUrl({ - rawPath: '/orders#', - rawQueryString: 'tenant=acme', - requestContext: { http: { method: 'GET' } }, - }), - ).toBe('/orders%23?tenant=acme') + toStandardUrl({ rawPath: '/a%2e%2e', requestContext: { http: { method: 'GET' } } }), + ).toBe('/a%252e%252e') expect( toStandardUrl({ - rawPath: '/users/me?admin=true', - rawQueryString: 'tenant=acme', - requestContext: { http: { method: 'GET' } }, + rawPath: '/a%2e%2e', + requestContext: { domainName: 'url-id.lambda-url.example.com', http: { method: 'GET' } }, }), + ).toBe('/a%252e%252e') + }) + + 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') }) }) - describe('v1', () => { + describe('HTTP API (v1)', () => { it.each(decoded)('escapes decoded %s', (path, pathname) => { - expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(pathname) - }) - - it.each(untouched)('keeps %s as-is', (path) => { - expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(path) + expect(toStandardUrl(httpApiV1(path))).toBe(pathname) }) it('keeps the query string separate from a decoded ? or # in the path', () => { expect( - toStandardUrl({ - httpMethod: 'GET', - path: '/orders#', - queryStringParameters: { tenant: 'acme' }, - }), + toStandardUrl({ ...httpApiV1('/orders#'), queryStringParameters: { tenant: 'acme' } }), ).toBe('/orders%23?tenant=acme') expect( toStandardUrl({ - httpMethod: 'GET', - path: '/users/me?admin=true', + ...httpApiV1('/users/me?admin=true'), queryStringParameters: { tenant: 'acme' }, }), ).toBe('/users/me%3Fadmin=true?tenant=acme') }) }) - it('escapes the same characters as the URL pathname setter', () => { + describe('Lambda Function URL', () => { + it.each(untouched)('keeps %s as-is', (rawPath) => { + expect(toStandardUrl(functionUrl(rawPath))).toBe(rawPath) + }) + + it('escapes characters that cannot appear literally in an encoded path', () => { + expect(toStandardUrl(functionUrl('/a b#c?d'))).toBe('/a%20b%23c%3Fd') + }) + }) + + describe('REST API', () => { + it.each(untouched)('keeps %s as-is', (path) => { + expect(toStandardUrl(restApi(path))).toBe(path) + }) + + it('escapes characters that cannot appear literally in an encoded path', () => { + expect(toStandardUrl(restApi('/a b#c?d'))).toBe('/a%20b%23c%3Fd') + }) + }) + + it('escapes encoded paths 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', @@ -217,14 +277,15 @@ describe('toStandardUrl path escaping', () => { const url = new URL('http://localhost') url.pathname = path - expect(toStandardUrl({ httpMethod: 'GET', path })).toBe(url.pathname) + expect(toStandardUrl(restApi(path))).toBe(url.pathname) + expect(toStandardUrl(functionUrl(path))).toBe(url.pathname) } }) 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..2f43112 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 +// plus `%` and `\`, a decoded path carries them as data: left as-is, `URL` would decode `%2e%2e` +// again into a dot segment and treat `\` as `/`, resolving segments API Gateway never routed on +const DECODED_PATH_CHAR_RE = /[\0-\x20"#%<>?\\^`{}\x7F-\u{10FFFF}]/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,15 @@ 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 + const pathname = toPathname(event.path, event.version === '1.0') const query = new URLSearchParams() @@ -47,8 +58,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}` } From 71651eb9d2b8692b1d976eefa4696d3b48a9d445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:03:12 +0000 Subject: [PATCH 2/3] refactor(aws-lambda): escape decoded paths by allow-list and tighten url tests - Escape everything but RFC 3986 path characters in decoded HTTP API paths instead of the WHATWG set plus `%` and `\`. Complete by construction and no longer duplicates the encoded-path regex; it additionally escapes `[`, `]` and `|`, which decode to themselves. - Share one payload 2.0 test builder, drop the unused `version: '2.0'`, merge the per-source blocks with `describe.each`, and remove tests already covered by the case tables and the round-trip check. - Tighten the README bullet on path re-escaping. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbRvaNSKxHggDmMVk5VhHM --- packages/aws-lambda/README.md | 2 +- packages/aws-lambda/src/url.test.ts | 162 ++++++++++------------------ packages/aws-lambda/src/url.ts | 6 +- 3 files changed, 62 insertions(+), 108 deletions(-) diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index b39abf0..d922916 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -104,7 +104,7 @@ 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. -- **HTTP API paths are re-escaped.** HTTP APIs deliver the request path url-decoded, as payload format 1.0 `path` (marked by the top-level `version: '1.0'` only HTTP APIs send) and as 2.0 `rawPath`. REST APIs, ALB and Lambda Function URLs deliver it still encoded; Function URLs are told apart from HTTP APIs by their `requestContext.domainName` (`.lambda-url..on.aws`), and a payload format 2.0 event without one is treated as an HTTP API. For every source the adapter percent-encodes the 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. For HTTP APIs it also escapes `%` and `\`, so the path decodes back to exactly what API Gateway delivered: a decoded `%2e%2e` or `\` stays part of its segment instead of becoming a dot segment or a `/` that resolves to a path API Gateway never routed. +- **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. diff --git a/packages/aws-lambda/src/url.test.ts b/packages/aws-lambda/src/url.test.ts index 8eda452..fdbfccd 100644 --- a/packages/aws-lambda/src/url.test.ts +++ b/packages/aws-lambda/src/url.test.ts @@ -116,26 +116,14 @@ describe('toStandardUrl (v1)', () => { }) describe('toStandardUrl path escaping', () => { - const httpApiV2 = (rawPath: string) => ({ - version: '2.0', + const v2 = (domainName?: string) => (rawPath: string) => ({ rawPath, - requestContext: { - domainName: 'id.execute-api.us-east-1.amazonaws.com', - http: { method: 'GET' }, - }, - }) - - const functionUrl = (rawPath: string) => ({ - version: '2.0', - rawPath, - requestContext: { - domainName: 'url-id.lambda-url.us-east-1.on.aws', - http: { method: 'GET' }, - }, + 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 @@ -148,11 +136,13 @@ 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 @@ -176,110 +166,74 @@ describe('toStandardUrl path escaping', () => { '/capture/literal%value', ] - describe('HTTP API (v2)', () => { - it.each(decoded)('escapes decoded %s', (rawPath, pathname) => { - expect(toStandardUrl(httpApiV2(rawPath))).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('decodes back to the delivered path exactly once', () => { - for (const [rawPath] of decoded.filter(([path]) => !path.includes('\uD800'))) { - const url = new URL(toStandardUrl(httpApiV2(rawPath)), 'http://localhost') - - expect(safeDecodeURIComponent(url.pathname)).toBe(rawPath) - } - }) - - it('keeps an encoded dot segment from climbing out of the routed path', () => { - // `GET /public/%252e%252e/secret` reaches the `/public/{proxy+}` route as `/public/%2e%2e/secret` - for (const rawPath of ['/public/%2e%2e/secret', '/public/x\\..\\..\\secret']) { - const url = new URL(toStandardUrl(httpApiV2(rawPath)), 'http://localhost') + for (const [path] of decoded.filter(([path]) => !path.includes('\uD800'))) { + const url = new URL(toStandardUrl(toEvent(path)), 'http://localhost') - expect(url.pathname).toMatch(/^\/public\//) + expect(safeDecodeURIComponent(url.pathname)).toBe(path) } }) - - it('treats a missing or non Function URL domainName as an HTTP API', () => { - expect( - toStandardUrl({ rawPath: '/a%2e%2e', requestContext: { http: { method: 'GET' } } }), - ).toBe('/a%252e%252e') - - expect( - toStandardUrl({ - rawPath: '/a%2e%2e', - requestContext: { domainName: 'url-id.lambda-url.example.com', http: { method: 'GET' } }, - }), - ).toBe('/a%252e%252e') - }) - - 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') - }) }) - describe('HTTP API (v1)', () => { - it.each(decoded)('escapes decoded %s', (path, pathname) => { - expect(toStandardUrl(httpApiV1(path))).toBe(pathname) - }) - - it('keeps the query string separate from a decoded ? or # in the path', () => { - 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') - }) - }) - - describe('Lambda Function URL', () => { - it.each(untouched)('keeps %s as-is', (rawPath) => { - expect(toStandardUrl(functionUrl(rawPath))).toBe(rawPath) + describe.each([ + ['Lambda Function URL', functionUrl], + ['REST API', restApi], + ] as const)('%s', (_, toEvent) => { + it.each(untouched)('keeps %s as-is', (path) => { + expect(toStandardUrl(toEvent(path))).toBe(path) }) - it('escapes characters that cannot appear literally in an encoded path', () => { - expect(toStandardUrl(functionUrl('/a b#c?d'))).toBe('/a%20b%23c%3Fd') + 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) + } }) }) - describe('REST API', () => { - it.each(untouched)('keeps %s as-is', (path) => { - expect(toStandardUrl(restApi(path))).toBe(path) - }) - - it('escapes characters that cannot appear literally in an encoded path', () => { - expect(toStandardUrl(restApi('/a b#c?d'))).toBe('/a%20b%23c%3Fd') - }) + 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') }) - it('escapes encoded paths 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 + 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(restApi(path))).toBe(url.pathname) - expect(toStandardUrl(functionUrl(path))).toBe(url.pathname) - } + 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', () => { diff --git a/packages/aws-lambda/src/url.ts b/packages/aws-lambda/src/url.ts index 2f43112..a7e4321 100644 --- a/packages/aws-lambda/src/url.ts +++ b/packages/aws-lambda/src/url.ts @@ -6,9 +6,9 @@ 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 -// plus `%` and `\`, a decoded path carries them as data: left as-is, `URL` would decode `%2e%2e` -// again into a dot segment and treat `\` as `/`, resolving segments API Gateway never routed on -const DECODED_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$/ From 8e6e23a112ef925680db9a08b77e5274b94ca4a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:19:10 +0000 Subject: [PATCH 3/3] docs(aws-lambda): mark HTTP API 1.0 path decoding as inferred Live captures only cover payload format 2.0. Keep escaping the 1.0 `path` as decoded, and note why: wrongly escaping costs a double-encoding, wrongly not escaping reopens the traversal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbRvaNSKxHggDmMVk5VhHM --- packages/aws-lambda/src/url.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/aws-lambda/src/url.ts b/packages/aws-lambda/src/url.ts index a7e4321..11ea6d9 100644 --- a/packages/aws-lambda/src/url.ts +++ b/packages/aws-lambda/src/url.ts @@ -28,7 +28,8 @@ export function toStandardUrl(event: AnyAPIGatewayProxyEvent): StandardUrl { return event.rawQueryString ? `${pathname}?${event.rawQueryString}` : pathname } - // only HTTP APIs set `version` and deliver `path` url-decoded, REST APIs and ALB still encoded + // 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()