jdaugherty commented on code in PR #16311:
URL: https://github.com/apache/grails-core/pull/16311#discussion_r3931061288
##########
grails-interceptors/src/main/groovy/grails/artefact/Interceptor.groovy:
##########
@@ -89,25 +90,17 @@ trait Interceptor implements ResponseRenderer,
ResponseRedirector, RequestForwar
allMatchers << matcher
}
- HttpServletRequest req = request
- String ctxPath = req.contextPath
- String uri = req.requestURI
- String noCtxUri = uri - ctxPath
- boolean checkNoCtxUri = ctxPath && uri.startsWith(ctxPath)
+ String uri =
UrlPathHelper.defaultInstance.getPathWithinApplication(request)
def matchedInfo =
request.getAttribute(UrlMappingsHandlerMapping.MATCHED_REQUEST)
UrlMappingInfo grailsMappingInfo = (UrlMappingInfo) matchedInfo
for (Matcher matcher in allMatchers) {
- boolean matchUri = matcher.doesMatch(uri, grailsMappingInfo,
req.method)
- boolean matchNoCtxUri = matcher.doesMatch(noCtxUri,
grailsMappingInfo, req.method)
-
- if (matcher.isExclude() && matchUri && matchNoCtxUri) {
- // Exclude interceptors are special because with only one of
the conditions being false the interceptor
- // won't be applied to the request
- return true
- } else if (!matcher.isExclude() && (matchUri || (checkNoCtxUri &&
matchNoCtxUri))) {
+ boolean matches = matcher instanceof UrlMappingMatcher
+ ? ((UrlMappingMatcher) matcher).doesMatch(uri,
grailsMappingInfo, request.method, request.contextPath)
+ : matcher.doesMatch(uri, grailsMappingInfo, request.method)
+ if (matches) {
Review Comment:
Two things about this dispatch.
`grails.interceptors.Matcher` is public API, but the context-path-aware
overload only exists on the internal `UrlMappingMatcher`, so a third-party
`Matcher` gets no context path — and, less visibly, a different `uri` argument
than before (decoded and application-relative, where it used to be the raw
context-prefixed request URI). A default method on `Matcher` would keep that
contract explicit instead of leaving it to an `instanceof`.
Second, this is a per-request hot path:
`GrailsInterceptorHandlerInterceptorAdapter.preHandle` calls
`doesMatch(request)` once per interceptor, each call does a full
`getPathWithinApplication` (semicolon scan, decode, sanitize), and then every
matcher does another `canonicalizePath` on top. That is N + N*M decode passes
per request where there were previously none. Canonicalizing once here and
passing the result down would fix both that and the double decode in
`UrlMappingMatcher`.
`grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/interceptors/UrlMappingMatcherBenchmark.java`
is worth a run either way.
##########
grails-interceptors/src/main/groovy/grails/artefact/Interceptor.groovy:
##########
@@ -89,25 +90,17 @@ trait Interceptor implements ResponseRenderer,
ResponseRedirector, RequestForwar
allMatchers << matcher
}
- HttpServletRequest req = request
- String ctxPath = req.contextPath
- String uri = req.requestURI
- String noCtxUri = uri - ctxPath
- boolean checkNoCtxUri = ctxPath && uri.startsWith(ctxPath)
+ String uri =
UrlPathHelper.defaultInstance.getPathWithinApplication(request)
Review Comment:
`UrlPathHelper.getRequestUri()` consults
`jakarta.servlet.include.request_uri` before it falls back to
`getRequestURI()`, so this changes which URI is matched during an include
sub-dispatch. Grails re-runs the interceptor chain on those (that is why
`UrlMappingUtils.includeForUrl` clears `MATCHED_REQUEST` and the
once-per-request filter marker before `dispatcher.include`), so an interceptor
that used to see the outer page URI now sees the included one.
Measured with `match(uri: '/admin/**')` on a request for `/admin/dashboard`:
| | 8.0.x | this branch |
| --- | --- | --- |
| normal request | true | true |
| during `<g:include>` of `/stats/index` | true | **false** |
The effect is that interceptors decorating a page stop running for anything
pulled in through `<g:include>`. If matching the included URI is the intent it
should be deliberate and pinned by a test; otherwise this could fall back to
`request.requestURI` when `WebUtils.isInclude(request)`.
##########
grails-interceptors/src/main/groovy/grails/artefact/Interceptor.groovy:
##########
@@ -89,25 +90,17 @@ trait Interceptor implements ResponseRenderer,
ResponseRedirector, RequestForwar
allMatchers << matcher
}
- HttpServletRequest req = request
- String ctxPath = req.contextPath
- String uri = req.requestURI
- String noCtxUri = uri - ctxPath
- boolean checkNoCtxUri = ctxPath && uri.startsWith(ctxPath)
+ String uri =
UrlPathHelper.defaultInstance.getPathWithinApplication(request)
Review Comment:
Separate point on the same line: `UriUtils.decode` throws
`IllegalArgumentException` on a malformed escape, and
`UrlPathHelper.decodeInternal` only catches `UnsupportedCharsetException`, so
that exception escapes here. `canonicalizePath` in `UrlMappingMatcher` guards
it, but none of the three new `getPathWithinApplication(request)` call sites do.
Request URI `/foo%`:
| | 8.0.x | this branch |
| --- | --- | --- |
| `Interceptor.doesMatch()` | false | throws `IllegalArgumentException` |
| `IpAddressFilter.doFilter` | request allowed | throws |
| compat `AntPathRequestMatcher.matches` | false | throws |
Most containers reject these before dispatch, but
`doesMatch(HttpServletRequest)` is public API and is driven directly from
application interceptor unit tests via `withRequest(uri: ...)`, and a security
filter throwing mid-chain is a worse failure mode than not matching. The
try/catch already used in `canonicalizePath` would cover all three.
##########
grails-spring-security/compat/build.gradle:
##########
@@ -52,8 +52,15 @@ dependencies {
compileOnly 'jakarta.servlet:jakarta.servlet-api'
compileOnly 'org.apache.groovy:groovy'
compileOnly 'org.slf4j:slf4j-api'
+
+ testImplementation 'jakarta.servlet:jakarta.servlet-api'
+ testImplementation 'org.springframework:spring-test'
+ testImplementation 'org.spockframework:spock-core'
+
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
apply {
from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
Review Comment:
`gradle/test-config.gradle` disables `Test` tasks when
`onlySpringSecurityTests` is set, and that is the flag the "Spring Security
Tests" workflow job runs with, so this module's new spec is skipped in that job:
```
./gradlew :grails-spring-security-compat:test -PonlySpringSecurityTests
> Task :grails-spring-security-compat:test SKIPPED
```
It still runs in the core job so the coverage is not lost, but the sibling
`grails-spring-security/plugin` applies
`gradle/spring-security-test-config.gradle`, which gates on
`skipSpringSecurityTests` / `onlyCoreTests` instead. Using that file here would
put the spec in the job that owns this code.
##########
grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy:
##########
@@ -138,6 +134,19 @@ class IpAddressFilter extends GenericFilterBean {
false
}
+ protected String getPathWithinApplication(HttpServletRequest request) {
+ String forwardUri =
request.getAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE) as String
+ if (forwardUri) {
+ return
urlPathHelper.removeSemicolonContent(urlPathHelper.getPathWithinApplication(new
HttpServletRequestWrapper(request) {
+ @Override
+ String getRequestURI() {
+ forwardUri
+ }
+ }))
+ }
+
urlPathHelper.removeSemicolonContent(urlPathHelper.getPathWithinApplication(request))
+ }
+
Review Comment:
The wrapper does not take effect when an include attribute is present:
`UrlPathHelper.getRequestUri()` reads `jakarta.servlet.include.request_uri`
before it calls `getRequestURI()`, so the override is never consulted and the
restricted forward URI is skipped entirely.
With restriction `/admin/**`, `forward.request_uri = /admin/x`,
`include.request_uri = /public` and request URI `/public`:
| | 8.0.x | this branch |
| --- | --- | --- |
| result | 404, chain not invoked | **200, chain invoked** |
Stripping the context path off `forwardUri` and running
`removeSemicolonContent` plus the decode on the string directly would avoid
both this and the per-request wrapper allocation. The decode needs the same
`IllegalArgumentException` guard as `canonicalizePath` too: `/foo%` currently
throws out of `doFilter`, where before it simply did not match.
##########
grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilterSpec.groovy:
##########
@@ -189,4 +190,42 @@ class IpAddressFilterSpec extends AbstractUnitSpec {
0 == chainCount
}
+
+ void 'doFilter canonicalizes restricted paths'() {
+ given:
+ filter.allowLocalhost = false
+ filter.ipRestrictions = [[pattern: '/admin/**', access: '10.0.0.0/8']]
+ int chainCount = 0
+ def chain = [doFilter: { req, res -> chainCount++ }] as FilterChain
+ request.remoteAddr = '192.168.1.123'
+
+ when:
+ request.requestURI = requestUri
+ response.reset()
+ filter.doFilter(request, response, chain)
+
+ then:
+ response.status == 404
+ chainCount == 0
+
+ where:
+ requestUri << ['/admin;x=1/deleteUser', '/admin%3Bx=1/deleteUser',
'/%61dmin/deleteUser']
+ }
+
+ void 'doFilter canonicalizes forwarded restricted paths'() {
+ given:
+ filter.allowLocalhost = false
+ filter.ipRestrictions = [[pattern: '/admin/**', access: '10.0.0.0/8']]
+ def chain = Mock(FilterChain)
+ request.remoteAddr = '192.168.1.123'
+ request.requestURI = '/public'
+ request.setAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE,
'/admin;x=1/deleteUser')
+
+ when:
+ filter.doFilter(request, response, chain)
+
+ then:
+ response.status == 404
+ 0 * chain.doFilter(_, _)
+ }
Review Comment:
Could these grow two more cases? Both regress as things stand:
- `forward.request_uri` set together with `include.request_uri` — the
forward URI is not evaluated at all, so a restricted path is allowed through.
- a request URI with a malformed escape such as `/foo%` — `doFilter` now
throws instead of falling through.
##########
grails-spring-security/compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy:
##########
@@ -47,11 +48,7 @@ class AntPathRequestMatcher implements RequestMatcher {
if (httpMethod && !httpMethod.equalsIgnoreCase(request.method)) {
return false
}
- def path = request.requestURI ?: '/'
- def contextPath = request.contextPath
- if (contextPath && path.startsWith(contextPath)) {
- path = path.substring(contextPath.length())
- }
+ def path =
UrlPathHelper.defaultInstance.removeSemicolonContent(UrlPathHelper.defaultInstance.getPathWithinApplication(request))
?: '/'
Review Comment:
Worth noting this class is on the core security path rather than only a shim
for application code: `GrailsSecurityFilterChain` and
`ChannelFilterInvocationSecurityMetadataSourceFactoryBean` both resolve to it,
so this changes which filter chain a request selects.
Three deltas beyond the intended fix, measured against 8.0.x with
`contextPath = '/app'` and pattern `/admin/**`:
| request URI | 8.0.x | this branch |
| --- | --- | --- |
| `/app/foo%` | false | throws `IllegalArgumentException` |
| `/APP/admin/x` | false | true — `getRemainingPath` compares the context
path with `ignoreCase = true` |
| `/app/public` with `include.request_uri = /app/admin/x` | false | true |
And the mirror of the last row: pattern `/public/**` against that same
request goes true to false. The throw is the one I would guard first here,
since it aborts chain selection rather than declining to match.
##########
grails-interceptors/src/main/groovy/org/grails/plugins/web/interceptors/UrlMappingMatcher.groovy:
##########
@@ -99,6 +110,30 @@ class UrlMappingMatcher implements Matcher {
return false
}
+ private String canonicalizePath(String uri) {
+ if (uri == null || uri.isEmpty()) {
+ return '/'
+ }
+ String path = UrlPathHelper.defaultInstance.removeSemicolonContent(uri)
+ try {
+ path = UriUtils.decode(path, StandardCharsets.UTF_8)
+ } catch (IllegalArgumentException ignored) {
+ // keep the semicolon-stripped form when the URI is malformed
+ }
+ path = UrlPathHelper.defaultInstance.removeSemicolonContent(path)
+ path ?: '/'
Review Comment:
This decodes and strips semicolons a second time. `Interceptor.doesMatch`
already hands in the output of `UrlPathHelper.getPathWithinApplication`, which
has done both. So the matcher's path is not the path dispatch routes on, which
is the desync this PR is closing, just moved one step along:
| request | path `UrlMappingsHandlerMapping` routes on | path the matcher
sees | 8.0.x | this branch |
| --- | --- | --- | --- | --- |
| `/%2561dmin/x` vs `match(uri: '/admin/**')` | `/%61dmin/x` | `/admin/x` |
false | **true** |
| `/health%3Bx` vs `matchAll().excludes(uri: '/health')` | `/health;x` |
`/health` | interceptor runs | **interceptor skipped** |
The second row is the one that matters: over-normalizing on the exclude side
is fail-open, and `%3B` is a literal semicolon inside a path segment rather
than a matrix parameter, so stripping it after decoding is wrong independently
of the double decode.
The decode here is also hardcoded to UTF-8 while `UrlPathHelper` uses
`request.getCharacterEncoding()` with an ISO-8859-1 fallback, so the two passes
can disagree on charset as well.
Suggest canonicalizing exactly once: either skip this for the 4-arg
overload, or have `Interceptor` pass `request.requestURI` and let this method
own the single decode.
##########
grails-interceptors/src/main/groovy/org/grails/plugins/web/interceptors/UrlMappingMatcher.groovy:
##########
@@ -99,6 +110,30 @@ class UrlMappingMatcher implements Matcher {
return false
}
+ private String canonicalizePath(String uri) {
+ if (uri == null || uri.isEmpty()) {
+ return '/'
+ }
+ String path = UrlPathHelper.defaultInstance.removeSemicolonContent(uri)
+ try {
+ path = UriUtils.decode(path, StandardCharsets.UTF_8)
+ } catch (IllegalArgumentException ignored) {
+ // keep the semicolon-stripped form when the URI is malformed
+ }
+ path = UrlPathHelper.defaultInstance.removeSemicolonContent(path)
+ path ?: '/'
+ }
+
+ private boolean matchesPattern(String pattern, String path, String
contextPath) {
+ if (pathMatcher.match(pattern, path)) {
+ return true
+ }
+ if (contextPath && contextPath != '/' && (pattern == contextPath ||
pattern.startsWith(contextPath + '/'))) {
+ return pathMatcher.match(pattern.substring(contextPath.length())
?: '/', path)
+ }
+ false
+ }
Review Comment:
Stripping the context path off the *pattern* covers the 10857 cases, but it
is not equivalent to the old "match against contextPath + path" rule for
patterns that do not literally start with the context path. With
`server.servlet.context-path=/app`, `match(uri: '/*/*')` matched `/app/save`
before and no longer does.
That old match was accidental so I am not arguing to keep it, but it is a
silent semantic change and nothing in the suite pins it either way.
##########
grails-interceptors/src/test/groovy/grails/artefact/InterceptorSpec.groovy:
##########
@@ -267,6 +267,19 @@ class InterceptorSpec extends Specification {
'/foo/bar' | true
}
+ void "Test URI matching uses the canonical request path"() {
+ given:
+ def interceptor = new TestAdminUriInterceptor()
+ def webRequest = GrailsWebMockUtil.bindMockWebRequest(new
MockServletContext(), new MockHttpServletRequest('', requestUri), new
MockHttpServletResponse())
+
webRequest.request.setAttribute(UrlMappingsHandlerMapping.MATCHED_REQUEST, new
ForwardUrlMappingInfo(controllerName: 'admin', action: 'deleteUser'))
+
+ expect:
+ interceptor.doesMatch()
+
+ where:
+ requestUri << ['/admin;x=1/deleteUser', '/%61dmin/deleteUser']
+ }
+
Review Comment:
The case this changes most for existing applications is not covered here: an
interceptor combining `match(uri:)` with `excludes(uri:)` could never fire
under a non-root context path before, because the old `isExclude() && matchUri
&& matchNoCtxUri` gate also required the pattern to match the context-prefixed
URI.
```groovy
match(uri: '/api/**').excludes(uri: '/api/health') // contextPath '/app',
request '/app/api/orders'
// 8.0.x: false, this branch: true
```
That is the correct behaviour, but it means applications deployed at a
context path start executing interceptors that have silently never run, so it
deserves a case here and a release note.
Also uncovered, and both currently changing behaviour: matching during an
include sub-dispatch, and a request URI with a malformed percent escape.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]