Skip to content

fix(lambda): filter path canonicalization - #1628

Open
ckawl wants to merge 9 commits into
aws:mainfrom
ckawl:fix/filter-path-canonicalization
Open

ckawl wants to merge 9 commits into
aws:mainfrom
ckawl:fix/filter-path-canonicalization

Conversation

@ckawl

@ckawl ckawl commented Oct 1, 2026 •

Copy link
Copy Markdown

Issue #, if available:

Description of changes:

Two related fixes in the servlet emulation layer, plus regression tests.

1. Filter matching and servlet resolution read different paths

Filter url-pattern matching used request.getRequestURI(), which returns the raw undecoded path, while servlet resolution used getPathInfo(), which was decoded. Because the two decisions read different spellings of the same request, a path could be written so that they disagreed about which resource was being asked for. A filter mapped to /admin/* was not selected for /%61dmin/secret, while the request still routed to the handler mapped to /admin/*.

The first revisions of this PR tried to fix that by agreeing on one canonical path. Review showed that does not work, twice, in opposite directions, because consumers downstream do not agree on which form they route on:

  • servlet resolution here uses the canonical path
  • Spring MVC matches on the raw request URI and leaves dot segments to a servlet container, which does not exist in Lambda
  • other code reads the decoded-but-not-normalized form

So filter selection no longer chooses a winner. A filter applies if its url-pattern matches the path under either the decoded-but-not-normalized or the canonical spelling. Both are load-bearing, and dropping either one reopens a bypass that a test in this PR catches:

  • without the canonical spelling, /public/../admin/secret does not select the /admin/* filter but still routes to the admin handler
  • without the decoded spelling, /admin/../public/info does not select it either, because its canonical form is /public/info while Spring MVC still hands it to an /admin/** handler

An earlier revision also matched the undecoded request URI, making three spellings. That arm was dropped. Across a sweep of 688,908 (path, url-pattern) pairs it was the only matching arm in 1,462 of them, and in every one of those the url-pattern itself contained a percent escape such as /adm%69n/*. Since url-patterns are matched against the decoded path, a pattern written that way would not match in a real container either, so the arm only widened the over-selection without protecting anything.

Supporting changes:

  • canonicalizePath and decodePath live in AwsHttpServletRequest, alongside cleanUri. decodePath gathers consecutive escapes and decodes them as a group using the configured uriEncoding, and copies literal characters straight through, so a surrogate pair is never split.
  • getPathInfo() returns the canonical path in both AwsProxyHttpServletRequest and AwsHttpApiV2ProxyHttpServletRequest, and in AwsHttpServletRequestWrapper, which is reached on async dispatch and had kept a copy of the pre-fix logic.
  • UrlPathValidator reads a decode-only path with the context path stripped. It rejects traversal segments, so it needs them visible: the raw URI leaves %2e%2e unrecognizable, and the canonical path has already resolved them away.
  • Decoding is not delegated to URLDecoder, which implements form encoding and would turn a literal + in a path segment into a space. There is a test pinning that.

Behaviour changes: this PR has six observable behaviour changes, including that filters now run more often by design. They are listed explicitly in a separate comment on this PR rather than left to be inferred from the diff. The over-selection item in particular is worth a second opinion.

2. AwsServletContext.getServletForPath read past the end of the request path

The matching loop was bounded by the length of the mapping while indexing into the request path, so any request with fewer segments than a registered mapping threw ArrayIndexOutOfBoundsException. The method is reached on every request through SpringBootLambdaContainerHandler, SpringLambdaContainerHandler and AwsProxyRequestDispatcher.

It also dereferenced its argument without a null check, and that argument is always getPathInfo(), which is null whenever the servlet path covered the whole request.

Both are fixed. The loop is now bounded by the request path as well, and a trailing wildcard still matches the empty remainder since the spec has /a/* match /a. A null path is treated as the root, which is the branch / already took.

Testing

  • FilterChainManagerPathBypassTest (24 cases) covers the matcher and canonicalizePath directly: encoded characters, encoded separators, uppercase hex, dot segments leading both into and out of a protected path, double encoding decoding exactly once, malformed escapes not throwing, + surviving, and cache behaviour across encoded and plain requests. The key case asserts that filter selection never UNDER-selects: if any spelling could reach a servlet under a protected prefix, that prefix's filter must run. Over-selection is permitted. An earlier version asserted exact agreement between filter selection and servlet resolution, which review showed to be the wrong property.
  • UrlPathValidatorTraversalTest (7 cases) covers plain, percent-encoded, uppercase and mixed-encoding traversal, and that a configured base path does not change the verdict. Four fail against a raw-URI version.
  • AwsServletContextServletForPathTest (8 cases) covers short paths, null paths, wildcard-matches-bare-prefix, and the existing prefix behaviour.
  • FilterAuthorizationBypassTest drives a Spring Boot app end to end through SpringBootLambdaContainerHandler.proxy(...), protected only by a deny-by-default servlet Filter with a path-scoped url-pattern. Parameterized over API Gateway REST, ALB and HTTP API v2, 33 cases. Covers spellings that resolve into the protected prefix and spellings that step out of it, the latter via a permanent /admin/** fixture, without which the app cannot demonstrate that class at all.

All tests were written before the fixes and confirmed to fail against the unpatched code.

Full local build green across core, jersey, spring, springboot4 and the three archetypes. Two pre-existing test failures remain on this branch and are unrelated to this change: AwsServletContextTest.getMimeType_mimeTypeOfJavascript_expectApplicationJavascript and AwsProxyHttpServletRequestTest.serverName_albHostHeader_returnsHostHeader. Both fail on unmodified main.

Related

The same production change applies to the other two maintained lines. The diffs to the main source files are identical across all three; only test scaffolding differs.

By submitting this pull request

  • I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • I confirm that I've made a best effort attempt to update all relevant documentation.

Filter selection keyed off getRequestURI(), which returns the raw
undecoded path, while servlet resolution keys off getPathInfo(), which
decodes. A percent-encoded or dot-segment spelling of a protected path
therefore failed to select the filter mapped to that path while still
routing to the servlet mapped to it, bypassing filter-based
authorization.

Canonicalize the path once (single percent-decode, then normalize away
empty, "." and ".." segments) and use that for both matching and the
filter chain cache key, so filter selection and servlet resolution can
no longer disagree about which path is being requested.

Decoding is not delegated to URLDecoder, which implements form encoding
and would turn a literal "+" in a path segment into a space.

Also make pathMatches consistently case-insensitive. Its exact-equality
fast path always lowercased both sides while its segment comparison was
case-sensitive, so the two halves disagreed and a differently-cased path
could skip its filter. Over-selecting a filter is fail-safe;
under-selecting one is an authorization bypass.
The mapping loop was bounded by the length of the mapping while indexing
into the request path, so any request with fewer segments than a
registered mapping threw ArrayIndexOutOfBoundsException. The method is
reached on every request via SpringBootLambdaContainerHandler,
SpringLambdaContainerHandler and AwsProxyRequestDispatcher, so this was
an unauthenticated crash path.

Bound the loop by the request path as well. A trailing wildcard still
matches the empty remainder, since the servlet spec has "/a/*" match
"/a"; any other mapping segment cannot match a path that has run out.

Also guard against a null path. The argument is always getPathInfo(),
which is null whenever the servlet path covered the whole request, and
the first statement dereferenced it. A null path is now treated as the
root, which is the same branch "/" already took.
Drives a Spring Boot app through the shipped SpringBootLambdaContainerHandler
entry point, the same one a deployed function executes, rather than
exercising the filter matcher in isolation.

The app protects /admin/* with a deny-by-default servlet Filter registered
through FilterRegistrationBean with a path-scoped url-pattern - the
configuration the reported bypass affects. The filter never calls
chain.doFilter, so the response is unambiguous: 403 FORBIDDEN means the
filter was selected, and the protected body means it was skipped.

Covers API Gateway REST, ALB and HTTP API v2. Against the unfixed matcher
18 of the 24 cases fail, with /%61dmin/secret, /adm%69n/secret and
/%61%64%6d%69%6e/secret each returning the protected body on all three
request types.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 75822fe..8299182
Files: 8
Comments: 1

…r matching

Review feedback on aws#1628: resolving dot segments for filter matching alone
reintroduced the filter/servlet disagreement in the opposite direction.
"/admin/x/../../public" canonicalized to "/public" for filter selection, so
a filter mapped to /admin/* was skipped, while getPathInfo() still returned
the un-normalized "/admin/x/../../public" and so still matched a servlet
mapped to /admin/*. Reproduced before and after: the filter ran on
unmodified main and stopped running with the first version of this change.

Canonicalization now backs both decisions. It moves to AwsHttpServletRequest
alongside cleanUri and decodeRequestPath, and getPathInfo() returns the
canonical path in both the API Gateway and HTTP API v2 request types, so
filter selection and servlet resolution read the same path by construction.

Decoding only, without resolving dot segments, would have closed the
reported direction and left its mirror: a filter mapped to /public/* would
be skipped for "/admin/x/../../public" while the framework routed to
/public.

UrlPathValidator now inspects getRequestURI() instead of getPathInfo().
It rejects paths containing traversal segments, and reading the canonical
path would have normalized them away before it could see them. A validator
of suspicious input has to inspect the input as it arrived.

Adds four tests: a dot segment leading out of a protected path, the same
percent-encoded, a dot segment leading into one, and a case asserting that
filter selection and servlet resolution agree for every spelling covered.
Four of them fail without the getPathInfo change.
@ckawl
ckawl marked this pull request as draft October 1, 2026 17:53
ckawl added a commit to ckawl/serverless-java-container that referenced this pull request Oct 1, 2026
…r matching

Review feedback on aws#1628: resolving dot segments for filter matching alone
reintroduced the filter/servlet disagreement in the opposite direction.
"/admin/x/../../public" canonicalized to "/public" for filter selection, so
a filter mapped to /admin/* was skipped, while getPathInfo() still returned
the un-normalized "/admin/x/../../public" and so still matched a servlet
mapped to /admin/*. Reproduced before and after: the filter ran on
unmodified main and stopped running with the first version of this change.

Canonicalization now backs both decisions. It moves to AwsHttpServletRequest
alongside cleanUri and decodeRequestPath, and getPathInfo() returns the
canonical path in both the API Gateway and HTTP API v2 request types, so
filter selection and servlet resolution read the same path by construction.

Decoding only, without resolving dot segments, would have closed the
reported direction and left its mirror: a filter mapped to /public/* would
be skipped for "/admin/x/../../public" while the framework routed to
/public.

UrlPathValidator now inspects getRequestURI() instead of getPathInfo().
It rejects paths containing traversal segments, and reading the canonical
path would have normalized them away before it could see them. A validator
of suspicious input has to inspect the input as it arrived.

Adds four tests: a dot segment leading out of a protected path, the same
percent-encoded, a dot segment leading into one, and a case asserting that
filter selection and servlet resolution agree for every spelling covered.
Four of them fail without the getPathInfo change.
ckawl added a commit to ckawl/serverless-java-container that referenced this pull request Oct 1, 2026
…r matching

Review feedback on aws#1628: resolving dot segments for filter matching alone
reintroduced the filter/servlet disagreement in the opposite direction.
"/admin/x/../../public" canonicalized to "/public" for filter selection, so
a filter mapped to /admin/* was skipped, while getPathInfo() still returned
the un-normalized "/admin/x/../../public" and so still matched a servlet
mapped to /admin/*. Reproduced before and after: the filter ran on
unmodified main and stopped running with the first version of this change.

Canonicalization now backs both decisions. It moves to AwsHttpServletRequest
alongside cleanUri and decodeRequestPath, and getPathInfo() returns the
canonical path in both the API Gateway and HTTP API v2 request types, so
filter selection and servlet resolution read the same path by construction.

Decoding only, without resolving dot segments, would have closed the
reported direction and left its mirror: a filter mapped to /public/* would
be skipped for "/admin/x/../../public" while the framework routed to
/public.

UrlPathValidator now inspects getRequestURI() instead of getPathInfo().
It rejects paths containing traversal segments, and reading the canonical
path would have normalized them away before it could see them. A validator
of suspicious input has to inspect the input as it arrived.

Adds four tests: a dot segment leading out of a protected path, the same
percent-encoded, a dot segment leading into one, and a case asserting that
filter selection and servlet resolution agree for every spelling covered.
Four of them fail without the getPathInfo change.
ckawl added 2 commits October 1, 2026 18:51
Review feedback on aws#1628: decodePathSegments converted non-escape
characters to bytes one UTF-16 char at a time. A character outside the BMP
is stored as a surrogate pair, and a lone surrogate cannot be encoded, so
each half became the replacement byte and the character came out as "??".

Reproduced: "/<U+1F600>/%61dmin" decoded to "/??/admin". Only triggered
when the path also contains a '%', since the early return otherwise skips
decoding entirely. BMP characters were unaffected.

This reached the application, not just filter matching, because
getPathInfo() now returns the canonical path in both request types. The
URLDecoder call this replaced did not have the problem.

A surrogate pair is now written as a unit. Adds tests for an emoji, a CJK
Extension B character, BMP multi-byte characters, and unpaired surrogates,
which must not throw.
Review feedback on aws#1630. Three forms of a request path matter, not two,
and the previous revision conflated them.

UrlPathValidator needs the path decoded but NOT normalized. The comment
added in the previous revision was wrong: at the merge base getPathInfo()
returned decodeRequestPath(cleanUri(path)), which decodes while leaving dot
segments in place, so the validator did see "%2e%2e" as "..". Switching it
to getRequestURI() left the escapes encoded, so "/%2e%2e/%2e%2e/x" scored
zero dot segments and passed. It now reads a decode-only form.

The context path is also excluded from what the validator inspects. It is
configured rather than client-supplied, and it contributes slashes without
contributing dot segments, which loosens the ratio check: "/../x" was
rejected while "/prod/../x" passed.

decodePath is the new decode-only helper. It gathers consecutive escapes
and decodes them as a group using the configured uriEncoding, which the
previous revision ignored in favour of hardcoded UTF-8, and copies literal
characters straight through, so a surrogate pair can no longer be split by
construction rather than by a special case.

Filter matching now reads request.getPathInfo() rather than canonicalizing
getRequestURI(). getRequestURI() includes the context path while
getPathInfo() does not, and url-patterns are relative to the context, so
with a configured stage or base path that mismatch alone could skip a
path-scoped filter the servlet still matched. Filter selection and servlet
resolution are now the same call, so they cannot drift apart.

AwsHttpServletRequestWrapper.getPathInfo also returns the canonical path.
It had kept a copy of the pre-fix logic, and it is reached on async
dispatch, where AwsProxyRequestDispatcher resolves the servlet from
getPathInfo. decodeRequestPath had no callers left and is removed.

Adds UrlPathValidatorTraversalTest, seven cases covering encoded, uppercase
and mixed-encoding traversal plus the context path. Four fail against the
raw-URI version. Also adds a wrapper consistency test.
ckawl added a commit to ckawl/serverless-java-container that referenced this pull request Oct 1, 2026
Review feedback on aws#1628: decodePathSegments converted non-escape
characters to bytes one UTF-16 char at a time. A character outside the BMP
is stored as a surrogate pair, and a lone surrogate cannot be encoded, so
each half became the replacement byte and the character came out as "??".

Reproduced: "/<U+1F600>/%61dmin" decoded to "/??/admin". Only triggered
when the path also contains a '%', since the early return otherwise skips
decoding entirely. BMP characters were unaffected.

This reached the application, not just filter matching, because
getPathInfo() now returns the canonical path in both request types. The
URLDecoder call this replaced did not have the problem.

A surrogate pair is now written as a unit. Adds tests for an emoji, a CJK
Extension B character, BMP multi-byte characters, and unpaired surrogates,
which must not throw.
ckawl added a commit to ckawl/serverless-java-container that referenced this pull request Oct 1, 2026
Review feedback on aws#1628: decodePathSegments converted non-escape
characters to bytes one UTF-16 char at a time. A character outside the BMP
is stored as a surrogate pair, and a lone surrogate cannot be encoded, so
each half became the replacement byte and the character came out as "??".

Reproduced: "/<U+1F600>/%61dmin" decoded to "/??/admin". Only triggered
when the path also contains a '%', since the early return otherwise skips
decoding entirely. BMP characters were unaffected.

This reached the application, not just filter matching, because
getPathInfo() now returns the canonical path in both request types. The
URLDecoder call this replaced did not have the problem.

A surrogate pair is now written as a unit. Adds tests for an emoji, a CJK
Extension B character, BMP multi-byte characters, and unpaired surrogates,
which must not throw.
ckawl added 2 commits October 1, 2026 22:40
Review feedback on aws#1630. Resolving dot segments for both filter selection
and servlet resolution still left a bypass, because Spring MVC routes on
getRequestURI(), which is neither decoded nor normalized, and it leaves dot
segments to a servlet container that does not exist in Lambda.

Reproduced through the shipped Lambda entry point against an app with an
/admin/** handler and a filter mapped /admin/*:

  /admin/../public/info                    200 WILDCARD_TOP_SECRET filters=0
  /admin/secret%2F..%2F..%2Fpublic%2Finfo  200 WILDCARD_TOP_SECRET filters=0
  /admin/x/../../public/info               200 WILDCARD_TOP_SECRET filters=0

All three canonicalize to /public/info, so the /admin/* filter was not
selected, while Spring matched /admin/** on the raw path and served the
handler. The filter ran for all three before this PR.

Consumers do not agree on which form of the path they route on: servlet
resolution here uses the canonical path, Spring MVC uses the raw request
URI, and other code reads the decoded-but-not-normalized form. Choosing one
and assuming the rest follow is what produced this bug class repeatedly, so
filter selection no longer chooses. A filter applies if its url-pattern
matches under the raw, decoded, or canonical spelling.

This over-selects by design. A filter may run for a request that is routed
elsewhere, which is harmless for an authorization filter and a behaviour
change for a filter that transforms requests or responses. Under-selecting
is the authorization bypass.

Matching two forms is not enough: "/%61dmin/../public" matches /admin/* in
its decoded-but-not-normalized form while matching neither raw nor
canonical.

Not taken from the review: building getRequestURI() from the canonical form
would undo the UrlPathValidator fix, which needs the raw path to see
traversal segments, and conflicts with the spec requirement that
getRequestURI() returns the URI as sent. Leaving a decoded %2F as a
non-separator was also not taken, because under over-selection decoding it
makes the protective filter more likely to apply.

Tests: the invariant changed from "filter selection and servlet resolution
agree" to "filter selection never under-selects", which is the property that
matters. filterSelectionNeverUnderSelects covers 13 spellings, with a
companion asserting unrelated paths are not swept in. AdminWildcardController
is now a permanent fixture, since without an /admin/** handler the end-to-end
app cannot demonstrate this class at all, and three end-to-end cases cover
the step-out spellings across API Gateway, ALB and HTTP API v2.
The third arm of the union matched the raw path, and it was not earning its
place. A brute-force sweep of 688,908 (path, url-pattern) pairs found it was
the only matching arm in 1,462 of them, and in every one of those the
url-pattern itself contained a percent escape, for example /adm%69n/*. The
servlet specification matches url-patterns against the decoded path, so such a
pattern would not match in a real container either.

Dropping it means filter selection over-selects on two spellings instead of
three, which is a smaller behaviour change to justify, and nothing changes for
any pattern anyone would realistically register.

The comment also credited the raw arm with covering Spring MVC. That was wrong.
Spring MVC routes on the undecoded request URI, and the decoded-but-not-
normalized arm is what covers it: matching on the canonical path alone leaves
/admin/../public/info reaching an /admin/** handler with its filter skipped.
@ckawl ckawl changed the title Fix(lambda): filter path canonicalization fix(lambda): filter path canonicalization Oct 2, 2026
@ckawl
ckawl marked this pull request as ready for review October 2, 2026 22:16
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.

1 participant