Describe the bug
When using Spring Security with WebFlux, /actuator can be unsecured but all other actuator endpoints cannot. The sample code below shows /actuator and /actuator/info being configured with .permitAll(). The former allows anonymous traffic but the latter returns 401 Unauthorized. This issues does not happen when using a servlet web app.
To Reproduce
1. Create a new Spring Boot project using WebFlux, Actuator, and Spring Security
2. Attempt to remove security from actuator endpoints other than the root /actuator
Expected behavior
Endpoints defined as .permitAll() without an overriding definition should allow anonymous access.
Sample Demo Project Reactive Security Bug.postman_collection.json.txt
@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final ReactiveAuthenticationManager authenticationManager;
private final ServerSecurityContextRepository securityContextRepository;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.csrf().disable()
.formLogin().disable()
.httpBasic().disable();
http
.authorizeExchange()
.pathMatchers(HttpMethod.GET, "/actuator", "/actuator/info", "/demo").permitAll()
.anyExchange().authenticated();
http
.authenticationManager(authenticationManager)
.securityContextRepository(securityContextRepository);
return http.build();
}
}
Comment From: adenix
Adding debug logs for org.springframework.security
2021-02-26 16:16:41.222 DEBUG 58697 --- [ctor-http-nio-3] o.s.w.s.adapter.HttpWebHandlerAdapter : [8daef712-12] HTTP GET "/actuator/info"
2021-02-26 16:16:41.236 DEBUG 58697 --- [oundedElastic-3] o.s.w.s.s.DefaultWebSessionManager : Created new WebSession.
2021-02-26 16:16:41.238 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=POST}
2021-02-26 16:16:41.239 DEBUG 58697 --- [oundedElastic-3] athPatternParserServerWebExchangeMatcher : Request 'GET /actuator/info' doesn't match 'POST /logout'
2021-02-26 16:16:41.240 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2021-02-26 16:16:41.241 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/actuator', method=GET}
2021-02-26 16:16:41.242 DEBUG 58697 --- [oundedElastic-3] athPatternParserServerWebExchangeMatcher : Request 'GET /actuator/info' doesn't match 'GET /actuator'
2021-02-26 16:16:41.243 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/actuator/info', method=GET}
2021-02-26 16:16:41.243 DEBUG 58697 --- [oundedElastic-3] athPatternParserServerWebExchangeMatcher : Checking match of request : '/actuator/info'; against '/actuator/info'
2021-02-26 16:16:41.243 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2021-02-26 16:16:41.243 DEBUG 58697 --- [oundedElastic-3] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/actuator/info' using org.springframework.security.config.web.server.ServerHttpSecurity$AuthorizeExchangeSpec$Access$$Lambda$523/0x0000000800e05840@1ca33079
2021-02-26 16:16:41.244 DEBUG 58697 --- [oundedElastic-3] o.s.s.w.s.a.AuthorizationWebFilter : Authorization successful
2021-02-26 16:16:41.273 DEBUG 58697 --- [oundedElastic-3] org.springframework.web.HttpLogging : [8daef712-12] Resolved [UnauthorizedException: 401 UNAUTHORIZED] for HTTP GET /actuator/info
2021-02-26 16:16:41.279 DEBUG 58697 --- [oundedElastic-3] org.springframework.web.HttpLogging : [8daef712-12] Encoding [{timestamp=Fri Feb 26 16:16:41 EST 2021, path=/actuator/info, status=401, error=Unauthorized, messag (truncated)...]
2021-02-26 16:16:41.313 DEBUG 58697 --- [ctor-http-nio-3] o.s.w.s.adapter.HttpWebHandlerAdapter : [8daef712-12] Completed 401 UNAUTHORIZED
Comment From: jzheaux
What's interesting to me is that in your DemoSecurityContextRepository, you are making an authorization decision. Since that repository always errors when no X-Principal header is supplied, I'd expect all the public endpoints to fail in this case, not just actuator/info.
I think there are two things to address:
First, I'm wondering if it's correct for the filter chain to continue when ServerSecurityContextRepository implementations resolve to Mono.error. It seems like they should propagate the error at that point. I'll leave this ticket open to continue investigating that.
Second, it's better to have AuthorizationWebFilter to make your authorization decisions instead of the security context repository. I'd recommend returning Mono.empty() if you can't find a security context instead of returning Mono.error(UnauthorizedException::new).
or
Instead of having a custom security context repository, it's much more common for a custom web filter to use an authentication manager than a custom security context repository. Instead of a custom security context repository, consider:
public class XPrincipalAuthenticationConverter implements ServerAuthenticationConverter {
Mono<Authentication> converter(ServerWebExchange swe) {
ServerHttpRequest request = swe.getRequest();
String principal = request.getHeaders().getFirst("X-Principal");
if (principal != null) {
return Mono.just(
new UsernamePasswordAuthenticationToken(principal, principal));
} else {
return Mono.empty(); // no error, let the authorization filter
// take care of authorization decisions
}
}
}
and then instead of .securityContextRepository(securityContextRepository), do:
AuthenticationWebFilter xPrincipalFilter =
new AuthenticationWebFilter(authenticationManager);
xPrincipalFilter.setServerAuthenticationConverter(authenticationConverter);
// ...
http.addFilterAt(xPrincipalFilter, SecurityWebFiltersOrder.AUTHENTICATION);
Comment From: rwinch
The SecurityContextRepository is not the proper API to perform authentication. Instead, use AuthenticationWebFilter.
If you really want to use SecurityContextRepository, @jzheaux is correct. you should not return Mono.error if the SecurityContext is not found. Instead you should return Mono.empty.
The reason this does not impact /demo is because it never subscribes to the SecurityContext. The /actuator/info attempts to include the authenticated user in the response and so the SecurityContext is subscribed to. When it isn't found it produces the error and it is handled by returning the 401 response.