Conversation
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.
Port of the springboot3 test to the springboot2 module, adapted for the javax.servlet namespace. javax.servlet.Filter has no default init and destroy, unlike its jakarta counterpart, so the filter implements both. Same app, same assertions, same three request types. Spring Security auto-configuration is excluded as on 2.x, since Boot 2 does ship those classes and would otherwise secure every path.
…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.
This was referenced 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.
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#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#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.
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.
ckawl
added a commit
to ckawl/serverless-java-container
that referenced
this pull request
Oct 1, 2026
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.
ckawl
added a commit
to ckawl/serverless-java-container
that referenced
this pull request
Oct 1, 2026
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.
gha_build.sh runs Gradle against the generated archetype projects and the samples at lines 50 and 71. The Gradle preinstalled on the GitHub runners now requires JVM 17 or later, so on this branch it refuses to start and the job dies before any build runs. Every job that sets up JDK 11 failed there, which is why Jersey, Spring and SpringBoot 2 were red while Spark and Struts, which set up no JDK at all, stayed green. Gradle 8.x starts on JVM 11, so pinning 8.14 for those three jobs keeps the Gradle steps on the same JDK as the rest of the job. Pinning the wrapper instead would not help: the failure is the preinstalled launcher refusing to start, which happens before a wrapper exists. Verified locally that Gradle 8.14 under JDK 11 runs both `gradle wrapper` and `./gradlew clean build` against samples/jersey/pet-store and produces the expected pet-store.zip. This is pre-existing rot rather than anything to do with the filter fix in this branch, and is carried here so the repair is validated by this PR's own run.
ckawl
marked this pull request as ready for review
October 2, 2026 22:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-patternmatching usedrequest.getRequestURI(), which returns the raw undecoded path, while servlet resolution usedgetPathInfo(), 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:
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:
/public/../admin/secretdoes not select the/admin/*filter but still routes to the admin handler/admin/../public/infodoes not select it either, because its canonical form is/public/infowhile Spring MVC still hands it to an/admin/**handlerAn 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:
canonicalizePathanddecodePathlive inAwsHttpServletRequest, alongsidecleanUri.decodePathgathers consecutive escapes and decodes them as a group using the configureduriEncoding, and copies literal characters straight through, so a surrogate pair is never split.getPathInfo()returns the canonical path in bothAwsProxyHttpServletRequestandAwsHttpApiV2ProxyHttpServletRequest, and inAwsHttpServletRequestWrapper, which is reached on async dispatch and had kept a copy of the pre-fix logic.UrlPathValidatorreads a decode-only path with the context path stripped. It rejects traversal segments, so it needs them visible: the raw URI leaves%2e%2eunrecognizable, and the canonical path has already resolved them away.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.getServletForPathread past the end of the request pathThe 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 throughSpringBootLambdaContainerHandler,SpringLambdaContainerHandlerandAwsProxyRequestDispatcher.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 andcanonicalizePathdirectly: 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.FilterAuthorizationBypassTestdrives a Spring Boot app end to end throughSpringBootLambdaContainerHandler.proxy(...), protected only by a deny-by-default servletFilterwith 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, spark, spring, struts, springboot2 and the five archetypes. All 11 modules build with zero test failures, on both JDK 8 and JDK 11.
One workflow change is included, and it is not part of the fix. CI on this branch was already broken before this PR.
gha_build.shruns Gradle against the generated archetype projects and the samples at lines 50 and 71, those steps run under JDK 11, and the Gradle preinstalled on the runners now requires JVM 17 or later, so it refuses to start. All three jobs that set up JDK 11 died there, which is why Jersey, Spring and SpringBoot 2 were red while Spark and Struts, which set up no JDK at all, were green.The fix is to pin Gradle 8.14 in those three jobs, since 8.x starts on JVM 11. Pinning the wrapper would not have worked, because the failure is the preinstalled launcher refusing to start before a wrapper exists. Verified locally: Gradle 8.14 runs
gradle wrapperand./gradlew clean buildagainstsamples/jersey/pet-storeunder JDK 11 and produces the expectedpet-store.zip.It is carried here rather than in its own PR so the repair is validated by this PR's own run. If reviewers would rather see it separately, say so and I will split it out.
Because those jobs never got past the Gradle step, nothing after it has run in CI since 2024-01-21. I reproduced the steps that exercise this change locally: core on JDK 8 (372 tests), Jersey 2.27 on JDK 8 (102), Spring 5.0 on JDK 11 (83), Spring Boot 2.2 on JDK 11 (78), all passing. The remaining matrix entries are unexercised and may hold unrelated rot.
Related
This is the 1.x backport. The production change is identical to the
mainPR. The test scaffolding differs in four ways, all forced by the older stack:javax.servletimports instead ofjakarta.servlet.AdminAuthorizationFilterimplementsinitanddestroy, becausejavax.servlet.Filterhas no default methods.springboot2module rather thanspringboot4.Review suggestion: review the
mainPR for the logic, and this one for the scaffolding delta.By submitting this pull request