Skip to content

fix(server): avoid double-decoding HTTP API paths in AWS Lambda adapter - #2245

Closed
dinwwwh wants to merge 2 commits into
mainfrom
claude/compassionate-curie-szo3uw
Closed

dinwwwh wants to merge 2 commits into
mainfrom
claude/compassionate-curie-szo3uw

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 15, 2026

Copy link
Copy Markdown
Member

Security hardening fix. API Gateway HTTP APIs deliver the request path already URL-decoded (rawPath in payload 2.0, path in payload 1.0). @standard-server/aws-lambda's toStandardLazyRequest treats every path as encoded and never escapes %, so the oRPC matcher decoded a literal %XX a second time (CWE-174). A request could then reach a different procedure than the one API Gateway routed on, which can get it past path-scoped AWS controls in front of a single oRPC Lambda: an HTTP API route authorizer on /rpc/admin/{proxy+} next to an open /rpc/{proxy+}, or WAF / resource-policy path rules. oRPC's own middleware and auth were never bypassed.

@orpc/openapi's AWS Lambda OpenAPIHandler extends AwsLambdaHandler, so the same change fixes it.

Fix

  • AwsLambdaHandler.handle now passes toStandardLazyRequest a shallow copy of the event with the decoded path re-escaped. The escape uses an RFC 3986 allow-list (/[^\w\-.~!$&'()*+,;=:@/]/gu with safeEncodeURIComponent), so % gets escaped, along with \, [, ], | and non-ASCII. The matcher's single decode then yields exactly the path API Gateway delivered.
  • The default 'auto' detection:
    • Payload 1.0: decoded only when the top-level version is '1.0' (HTTP API). REST API and ALB events leave version out and are unchanged.
    • Payload 2.0: encoded only when requestContext.domainName matches the anchored Function URL host pattern /\.lambda-url\.[a-z0-9-]+\.on\.aws$/. Every other 2.0 event counts as decoded, including custom domains and a missing domainName.
  • New option pathEncoding?: 'auto' | 'encoded' | 'decoded' (default 'auto') to override the detection, with JSDoc.
  • RPCMatcher, normalizeHttpPath and the OpenAPI matcher are unchanged. Their single decode is correct for every other adapter.

Compatibility

  • Legitimate oRPC clients are unaffected: procedure paths delivered by an HTTP API contain no %.
  • A literal % in an HTTP API path or OpenAPI path param now resolves to what API Gateway delivered (for example /users/%41 now gives id: '%41', where it used to give 'A'). This also fixes correctness.
  • REST API, ALB and Function URL behavior is unchanged.
  • Characters API Gateway already decoded into URL syntax (%2F → /) cannot be recovered, and this PR doesn't try to.

Upstream note

The long-term fix belongs in middleapi/standard-server packages/aws-lambda/src/url.ts, which is what the closed, unmerged middleapi/standard-server#148 did. If @standard-server/aws-lambda is later bumped to a version that escapes decoded paths itself, remove the escaping here in the same change, or paths will be escaped twice. The new /users/100% test catches that, because it would yield 100%25.

Tests

  • New packages/server/src/adapters/aws-lambda/handler.test.ts:
    • The literal /rpc/%61dmin/deleteUser must not reach admin.deleteUser for: HTTP API 2.0, a custom domain, a custom domain posing as a Function URL, a missing domainName, and HTTP API 1.0.
    • It still matches for a plain HTTP API path, an encoded Function URL path and an encoded REST API path.
    • Plus tests for each pathEncoding value.
  • openapi-handler.test.ts: path param cases a%2Fb, %41, 100%, a space, é\ from an HTTP API, and a%2Fb from a Function URL (decoded once).
  • Before the fix, the HTTP API cases failed (the procedure ran, and a%2Fb / %41 came back as a/b / A). They pass now, and so do the existing Lambda and matcher tests.
  • pnpm type:check, pnpm lint and pnpm docs:validate pass.

Docs

docs/adapters/aws-lambda.mdx gets a new "Path Encoding" section. It covers HTTP API vs. REST API / Function URL behavior and the pathEncoding option, and recommends enforcing authorization in oRPC middleware instead of relying only on API Gateway route authorizers or WAF path rules.

The branch has a stray pnpm-lock.yaml rewrite from a local install, reverted in its second commit, so the net diff touches only the four files above. Please squash merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xa1ajNQxtP8VeB8FnC1LEw


Generated by Claude Code

API Gateway HTTP APIs deliver `rawPath` (payload 2.0) and `path`
(payload 1.0) already url-decoded, but `toStandardLazyRequest` treats
every path as encoded and never escapes `%`, so a literal `%XX` was
decoded a second time by the matcher. A request could then route to a
different procedure than the one API Gateway routed on, getting past
path-scoped AWS controls such as route authorizers or WAF rules.

`AwsLambdaHandler` now re-escapes decoded paths before matching, so they
are decoded only once. Payload 1.0 events are decoded when they carry
`version: '1.0'`, payload 2.0 events unless `requestContext.domainName`
is a Lambda Function URL host. REST API, ALB and Function URL events are
unchanged. A new `pathEncoding` option overrides the detection.

`@orpc/openapi`'s AWS Lambda handler is fixed by the same change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa1ajNQxtP8VeB8FnC1LEw
@codspeed

codspeed Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/compassionate-curie-szo3uw (5ea093d) with main (8768e97)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pkg-pr-new

pkg-pr-new Bot commented Oct 15, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2245

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2245

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2245

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2245

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2245

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2245

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2245

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2245

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2245

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2245

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2245

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2245

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2245

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2245

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2245

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2245

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2245

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2245

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2245

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2245

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2245

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2245

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2245

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2245

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2245

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2245

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2245

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2245

commit: 5ea093d

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Decoded-path re-escaping (packages/server/src/adapters/aws-lambda/handler.ts) — AwsLambdaHandler.handle now hands toStandardLazyRequest a shallow copy of the event whose path (rawPath for 2.0, path for 1.0) is re-escaped with an RFC 3986 allow-list, so the matcher's single decode yields exactly what API Gateway delivered and a literal %XX is no longer decoded twice. Adds pathEncoding?: 'auto' | 'encoded' | 'decoded' (default 'auto').
  • Source detection — payload 1.0 counts as decoded only with top-level version: '1.0'; payload 2.0 counts as decoded unless requestContext.domainName matches the anchored Function URL host pattern.
  • Tests — new handler.test.ts asserts /rpc/%61dmin/deleteUser does not reach admin.deleteUser across HTTP API 2.0, a custom domain, a domain posing as a Function URL, a missing domainName, and HTTP API 1.0, while it still matches plain/encoded REST/Function URL paths, plus one case per pathEncoding value; openapi-handler.test.ts adds path-param round-trips.
  • Docs — new "Path Encoding" section in aws-lambda.mdx covering the behavior and the pathEncoding override.

I traced the escape → toStandardUrl/toPathname → both matchers (normalizeHttpPath and normalizeRou3Path/decodeParams) and confirmed the escaping is exactly inverted by one decode, with % always escaped, so neither matcher can decode a delivered %XX again. pnpm vitest run packages/server/src/adapters packages/openapi/src/adapters passes (326 tests), and the upstream premise (HTTP API decodes rawPath, Function URL/REST/ALB keep it encoded) matches the live captures the fix cites.

ℹ️ The domainName heuristic is the one place escaping is skipped

isDecodedPathEvent skips re-escaping entirely when a 2.0 event's requestContext.domainName ends in .lambda-url.<region>.on.aws, so a decoded-path event that presents such a host would be treated as encoded and re-open the double decode. This is not attacker-controllable in practice — the lambda-url.<region>.on.aws zone is AWS-owned, so a custom domain cannot live under it — but it is the single trust point in the fix, and the suffix regex is the only signal used. Worth keeping in mind if the detection is ever revisited.

Technical details
# `domainName` heuristic is the sole gate on escaping

## Affected sites
- `packages/server/src/adapters/aws-lambda/handler.ts:80-90` — `escapeDecodedPath` returns the event untouched when `pathEncoding === 'encoded'` or (`'auto'` and `!isDecodedPathEvent(event)`).
- `packages/server/src/adapters/aws-lambda/handler.ts:96-105` — `isDecodedPathEvent`; for 2.0 it trusts `requestContext.domainName` against `FUNCTION_URL_DOMAIN_REGEX` (`:33`).

## Why it is not exploitable as written
- API Gateway populates `requestContext.domainName` from the host used to invoke the API; a host matching the regex requires a custom domain inside the AWS-owned `lambda-url.<region>.on.aws` zone, which a tenant cannot create.
- The suffix is anchored (`$`), so `...on.aws.example.com` is correctly classified as decoded (covered by the test at `handler.test.ts:58`).

## Optional hardening (not required)
- Lowercase `domainName` before the test, since DNS host names are case-insensitive and the regex is not.
- Guard a missing `requestContext` before reading `domainName` (`handler.ts:102`); AWS events always include it, so this is only defensiveness.

## Open question for the human
- Is there a more robust signal than the domain suffix for distinguishing a Function URL from an HTTP API? If not, the current heuristic is the best available and no change is needed.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc 5ea093d Commit Preview URL

Branch Preview URL
Oct 15 2026, 06:59 AM

@codecov

codecov Bot commented Oct 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dinwwwh dinwwwh closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants