ali-jawad opened a new issue, #16739:
URL: https://github.com/apache/grails-core/issues/16739
### Expected Behavior
As on Grails 7.2.x, and as documented for
`grails.plugin.springsecurity.fii.observeOncePerRequest` (default `true`), the
`filterInvocationInterceptor` authorizes a request once. Later dispatches of
the same request, such as the ERROR dispatch that renders its error page, are
not authorized again.
An unauthenticated request to a public action that answers 404 gets the 404
error page. A direct request to `/error` is still authorized against its own
rule.
### Actual Behaviour
On 8.0.0 every dispatch is authorized again, ERROR dispatches included. The
security filter chain is registered for `REQUEST` and `ERROR` dispatches, so
the ERROR dispatch to `/error` is checked against `/error`'s rule. When
`/error` is not public, the anonymous caller is denied and
`ExceptionTranslationFilter` sends a 302 to the login page.
In our application, after upgrading from 7.2.4 to 8.0.0, every container
error page (404, 410, 413) sent to an unauthenticated caller became a redirect
to the login page. The compat interceptor never reads
`fii.observeOncePerRequest`, so the setting has no effect.
### Steps To Reproduce
1. Create a Grails 8.0.0 app with the Spring Security plugin:
```
sdk install grails 8.0.0
grails -t forge create-app example
cd example
```
Add `implementation "org.apache.grails:grails-spring-security"` to
`dependencies` in `build.gradle`, then run `./grailsw s2-quickstart example
User Role`. It generates `User`, `Role` and `UserRole` in
`grails-app/domain/example/` and the security settings in
`grails-app/conf/application.groovy`. `grails -t forge create-app example
--features=grails-spring-security` generates the equivalent without the
quickstart.
For the comparison, repeat with `sdk install grails 7.2.4`. Its Forge has
no `grails-spring-security` feature; the BOM resolves the plugin to 7.2.4.
2. In `grails-app/conf/application.groovy`, delete the `/error` entry
(`[pattern: '/error', access: ['permitAll']],`) from
`grails.plugin.springsecurity.controllerAnnotations.staticRules`. With
`rejectIfNoRule = true` (the default), `/error` is then denied.
3. Add `grails-app/controllers/example/PublicController.groovy`:
```groovy
package example
import grails.plugin.springsecurity.annotation.Secured
@Secured('permitAll')
class PublicController {
def missing() {
response.sendError(404)
}
}
```
4. Start the app with `./gradlew bootRun`, then `curl -i
http://localhost:8080/public/missing`:
- Grails 7.2.4: `404` with the application's not-found page (`<h1>Error:
Page Not Found (404)</h1>`)
- Grails 8.0.0: `302`, `Location: http://localhost:8080/login/auth`,
empty body
5. `curl -i http://localhost:8080/error` answers `302` with `Location:
http://localhost:8080/login/auth` on both versions, which is expected: it is
that request's first dispatch.
With the `/error` entry kept, step 4 answers `404` on 8.0.0 too.
The `/error` rule that the quickstart
([s2-quickstart.groovy#L229](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/plugin/src/main/scripts/s2-quickstart.groovy#L229)),
the Forge `grails-spring-security` feature and the functional test app
([application.groovy#L31](https://github.com/apache/grails-core/blob/e4b22127dc63341050a290d8c1d738c7df2b2ff5/grails-test-examples/spring-security/core/functional-test-app/grails-app/conf/application.groovy#L31))
all ship hides the problem. Applications that never permitted `/error`,
because they relied on once-per-request semantics, are affected.
### Cause
Spring Security 7 removed `FilterSecurityInterceptor`, and Grails 8 ships a
replacement in `grails-spring-security-compat`, in Spring's own package. The
plugin still wires `observeOncePerRequest` into it from
`fii.observeOncePerRequest`
([SpringSecurityCoreGrailsPlugin.groovy#L399-L410](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy#L399-L410),
default `true` in
[DefaultSecurityConfig.groovy#L333-L340](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/plugin/grails-app/conf/DefaultSecurityConfig.groovy#L333-L340)).
The compat `AbstractSecurityInterceptor` declares the property
([AbstractSecurityInterceptor.groovy#L46](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/compat/src/main/groovy/org/springframework/security/acc
ess/intercept/AbstractSecurityInterceptor.groovy#L46)). But the compat
`doFilter` never reads it and authorizes every dispatch
([FilterSecurityInterceptor.groovy#L41-L58](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy#L41-L58)).
The same holds on the current `8.0.x` after the #16632 fix
([L42-L62](https://github.com/apache/grails-core/blob/e4b22127dc63341050a290d8c1d738c7df2b2ff5/grails-spring-security/compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy#L42-L62)).
The chain is registered for `ERROR` and `REQUEST` dispatches
([SpringSecurityCoreGrailsPlugin.groovy#L230-L235](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy#L230-L235)),
so the error page's dispatch reaches the interceptor.
Grails 7.2.4 has the same registration and wiring ([7.2.4
L399-L410](https://github.com/apache/grails-core/blob/ea4ddc768f362a77fceb926ced14491c18524ca3/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy#L399-L410)).
It worked there because Spring Security 6's `FilterSecurityInterceptor`
honours the flag: it marks the request with `FILTER_APPLIED` and skips
authorization on later dispatches ([Spring Security 6.5.11
FilterSecurityInterceptor.java#L104-L128](https://github.com/spring-projects/spring-security/blob/73b077790fcb04ac3712033d3e939daf42264545/web/src/main/java/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.java#L104-L128)).
### Suggested fix
Honour `observeOncePerRequest` the way Spring Security 6 did, using the same
request attribute. Mark the request before authorizing, so the ERROR dispatch
of a denied request (e.g. a 403 error page) is not authorized again either:
```groovy
private static final String FILTER_APPLIED =
'__spring_security_filterSecurityInterceptor_filterApplied'
@Override
void doFilter(ServletRequest request, ServletResponse response, FilterChain
chain) throws IOException, ServletException {
if (observeOncePerRequest && request.getAttribute(FILTER_APPLIED) !=
null) {
chain.doFilter(request, response)
return
}
if (observeOncePerRequest) {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE)
}
// existing body unchanged
}
```
Tests that would cover it:
- `FilterSecurityInterceptorSpec`:
- a second dispatch of the same request does not call `decide`
- the ERROR dispatch after a denied first dispatch does not call `decide`
- with `observeOncePerRequest = false`, the second dispatch calls `decide`
again
- `grails-test-examples/spring-security/core/functional-test-app`: an
environment without the `/error` rule in which an unauthenticated
`sendError(404)` from a public action answers 404, while a direct `GET /error`
still redirects to the login page.
The same bean is also wired with `alwaysReauthenticate`,
`publishAuthorizationSuccess`, `runAsManager` and `afterInvocationManager`, and
the compat `doFilter` uses none of them. That may deserve a separate look; this
issue is only about `observeOncePerRequest`.
### Workaround
Replace the interceptor's class through the plugin's `<beanName>BeanClass`
override
([BeanTypeResolver.groovy#L48-L54](https://github.com/apache/grails-core/blob/081cd1c93ab753a234e111e2828f089ebdd810ee/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/BeanTypeResolver.groovy#L48-L54)).
The plugin keeps wiring all its properties. In
`src/main/groovy/example/OncePerRequestFilterSecurityInterceptor.groovy`:
```groovy
package example
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
class OncePerRequestFilterSecurityInterceptor extends
FilterSecurityInterceptor {
private static final String FILTER_APPLIED =
'__spring_security_filterSecurityInterceptor_filterApplied'
@Override
void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
if (observeOncePerRequest && request.getAttribute(FILTER_APPLIED) !=
null) {
chain.doFilter(request, response)
return
}
if (observeOncePerRequest) {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE)
}
super.doFilter(request, response, chain)
}
}
```
```groovy
// grails-app/conf/application.groovy
grails.plugin.springsecurity.filterInvocationInterceptorBeanClass =
example.OncePerRequestFilterSecurityInterceptor
```
Permitting `/error` also hides the symptom, but it makes a direct `GET
/error` public as well.
### Environment Information
- Grails 8.0.0 (Spring Boot 4.1.1, Spring Security 7.1.1, Tomcat 11.0.24,
Groovy 5.1.3), Java 21, Gradle 9.8.0
- Works on Grails 7.2.4 (Spring Boot 3.5.16, Spring Security 6.5.11, Tomcat
10.1.55, Groovy 4.0.33)
### Example Application
None; the steps above create a minimal one from the Forge templates.
### Version
8.0.0
Related: #16632 / #16654 (same compat class, missing null-`Authentication`
guard).
--
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]