From 00ab21335b9badf10e7cc8f8e2188ad6de7be2fc Mon Sep 17 00:00:00 2001 From: Arief Hidayat Date: Tue, 11 Aug 2026 23:17:57 +0700 Subject: [PATCH 1/2] Add pre-authenticated (trusted reverse-proxy) mode to the REST app The REST app's Spring Security chain only supports HTTP Basic, and its SecurityConfiguration is a plain @Configuration with no way to switch that off. That makes flowable-app-rest impossible to run unchanged behind a reverse proxy that has already authenticated the caller and owns the Authorization header (oauth2-proxy, nginx auth_request, AWS ALB OIDC, Databricks Apps, ...): the proxy's bearer token collides with Flowable's Basic prompt and every request is rejected. Add a third flowable.rest.app.authentication-mode value, 'pre-auth'. In this mode the chain reads the already-authenticated user id from a request header (flowable.rest.app.pre-auth.principal-header, default X-Forwarded-User) via Spring Security's RequestHeaderAuthenticationFilter instead of HTTP Basic. No password is checked; privileges are still loaded from the IDM engine, so the access-rest-api / access-admin authorization rules behave exactly as in 'verify-privilege'. Identity flows to the engine unchanged, because SpringSecurityAuthenticationContext reads the principal name generically. This mirrors the pluggable security the UI apps already have via flowable.common.app.security.type. It is off by default and changes nothing for existing HTTP Basic deployments. Security note documented on the new classes: 'pre-auth' trusts the principal header, so it is only safe when the app is unreachable except through a proxy that strips any client-supplied copy of that header. --- .../app/properties/RestAppProperties.java | 34 ++++ .../rest/conf/SecurityConfiguration.java | 54 +++++- ...eRestApplicationPreAuthenticationTest.java | 163 ++++++++++++++++++ .../PreAuthenticatedUserDetailsService.java | 82 +++++++++ ...reAuthenticatedUserDetailsServiceTest.java | 94 ++++++++++ 5 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java create mode 100644 modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java create mode 100644 modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java index 2a89dfa4299..5ed81fb2097 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java @@ -31,6 +31,10 @@ public class RestAppProperties { * Configures the way user credentials are verified when doing a REST API call: * 'any-user' : the user needs to exist and the password need to match. Any user is allowed to do the call (this is the pre 6.3.0 behavior) * 'verify-privilege' : the user needs to exist, the password needs to match and the user needs to have the 'rest-api' privilege + * 'pre-auth' : the request is trusted to have been authenticated by a reverse proxy in front of the app, and the user id is + * read from a request header (see {@link PreAuth}) instead of HTTP Basic. The password is not checked; privileges + * are still loaded from the IDM engine so authorization behaves as with 'verify-privilege'. Only use this when the + * app cannot be reached except through a trusted proxy that strips any client-supplied copy of the header. * If nothing set, defaults to 'verify-privilege' */ private String authenticationMode = "verify-privilege"; @@ -51,6 +55,9 @@ public class RestAppProperties { @NestedConfigurationProperty private final Admin admin = new Admin(); + @NestedConfigurationProperty + private final PreAuth preAuth = new PreAuth(); + /** * The default role prefix that needs to be used by Spring Security. */ @@ -88,6 +95,10 @@ public Admin getAdmin() { return admin; } + public PreAuth getPreAuth() { + return preAuth; + } + public String getRolePrefix() { return rolePrefix; } @@ -139,6 +150,29 @@ public void setLastName(String lastName) { } } + /** + * Settings for the 'pre-auth' authentication mode, where a trusted reverse proxy has already + * authenticated the caller and passes the user id in a request header. + */ + public static class PreAuth { + + /** + * The request header that carries the already-authenticated user id. Defaults to + * {@code X-Forwarded-User}, which is what most authenticating reverse proxies emit + * (oauth2-proxy, and Databricks Apps also forwards {@code X-Forwarded-Email} / + * {@code X-Forwarded-Preferred-Username}). + */ + private String principalHeader = "X-Forwarded-User"; + + public String getPrincipalHeader() { + return principalHeader; + } + + public void setPrincipalHeader(String principalHeader) { + this.principalHeader = principalHeader; + } + } + public static class Cors { /** * Enable/disable CORS filter. diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java index f1792a9e8d1..e739cfb5b34 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java @@ -13,8 +13,10 @@ package org.flowable.rest.conf; import org.apache.commons.lang3.StringUtils; +import org.flowable.idm.api.IdmIdentityService; import org.flowable.rest.app.properties.RestAppProperties; import org.flowable.rest.security.BasicAuthenticationProvider; +import org.flowable.rest.security.PreAuthenticatedUserDetailsService; import org.flowable.rest.security.SecurityConstants; import org.springframework.boot.actuate.info.InfoEndpoint; import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; @@ -28,12 +30,17 @@ import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; +import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @Configuration(proxyBeanMethods = false) @EnableWebSecurity public class SecurityConfiguration { - + + protected static final String MODE_PRE_AUTH = "pre-auth"; + protected static final String MODE_VERIFY_PRIVILEGE = "verify-privilege"; + protected final RestAppProperties restAppProperties; public SecurityConfiguration(RestAppProperties restAppProperties) { @@ -41,12 +48,23 @@ public SecurityConfiguration(RestAppProperties restAppProperties) { } @Bean - public AuthenticationProvider authenticationProvider() { + public AuthenticationProvider authenticationProvider(IdmIdentityService idmIdentityService) { + if (isPreAuth()) { + // The reverse proxy has already authenticated the caller; this provider only loads + // the user's privileges from IDM. No password is checked. + PreAuthenticatedUserDetailsService userDetailsService = new PreAuthenticatedUserDetailsService(idmIdentityService); + userDetailsService.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege()); + + PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); + provider.setPreAuthenticatedUserDetailsService(userDetailsService); + return provider; + } + BasicAuthenticationProvider basicAuthenticationProvider = new BasicAuthenticationProvider(); basicAuthenticationProvider.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege()); return basicAuthenticationProvider; } - + @Bean public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProvider authenticationProvider) throws Exception { HttpSecurity httpSecurity = http.authenticationProvider(authenticationProvider) @@ -67,7 +85,7 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv httpSecurity .authorizeHttpRequests( authorizeRequests -> authorizeRequests.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/docs/**")).denyAll()); - + } httpSecurity @@ -82,25 +100,43 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv if (isVerifyRestApiPrivilege()) { httpSecurity .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().hasAuthority(SecurityConstants.PRIVILEGE_ACCESS_REST_API)); - + } else { httpSecurity .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated()); } - httpSecurity.httpBasic(Customizer.withDefaults()); + if (isPreAuth()) { + // Identity comes from a header set by a trusted proxy, not HTTP Basic. The filter + // builds a PreAuthenticatedAuthenticationToken from the header, which the + // PreAuthenticatedAuthenticationProvider above resolves against IDM. + RequestHeaderAuthenticationFilter preAuthFilter = new RequestHeaderAuthenticationFilter(); + preAuthFilter.setPrincipalRequestHeader(restAppProperties.getPreAuth().getPrincipalHeader()); + // Missing header simply yields an anonymous request that the authorization rules + // above reject with 401/403, rather than a 500. + preAuthFilter.setExceptionIfHeaderMissing(false); + preAuthFilter.setAuthenticationManager(authentication -> authenticationProvider.authenticate(authentication)); + httpSecurity.addFilterBefore(preAuthFilter, org.springframework.security.web.authentication.AnonymousAuthenticationFilter.class); + } else { + httpSecurity.httpBasic(Customizer.withDefaults()); + } return http.build(); } - + protected boolean isVerifyRestApiPrivilege() { String authMode = restAppProperties.getAuthenticationMode(); if (StringUtils.isNotEmpty(authMode)) { - return "verify-privilege".equals(authMode); + // 'pre-auth' keeps privilege verification on: identity is trusted, authorization is not. + return MODE_VERIFY_PRIVILEGE.equals(authMode) || MODE_PRE_AUTH.equals(authMode); } return true; // checking privilege is the default } - + + protected boolean isPreAuth() { + return MODE_PRE_AUTH.equals(restAppProperties.getAuthenticationMode()); + } + protected boolean isSwaggerDocsEnabled() { return restAppProperties.isSwaggerDocsEnabled(); } diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java new file mode 100644 index 00000000000..ebfd71a66c6 --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java @@ -0,0 +1,163 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.flowable.rest.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.flowable.idm.api.User; +import org.flowable.rest.security.SecurityConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Verifies the {@code pre-auth} authentication mode: the caller's id is taken from a trusted + * request header (as set by a reverse proxy) instead of HTTP Basic, and authorization still + * uses the privileges loaded from the IDM engine. + * + * @author Arief Hidayat + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "flowable.rest.app.authentication-mode=pre-auth", + "flowable.rest.app.pre-auth.principal-header=X-Forwarded-User" + } +) +@AutoConfigureTestRestTemplate +@Import(FlowableRestApplicationPreAuthenticationTest.TestBootstrapConfiguration.class) +public class FlowableRestApplicationPreAuthenticationTest { + + protected static final String PRINCIPAL_HEADER = "X-Forwarded-User"; + + @LocalServerPort + private int serverPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private IdmIdentityService idmIdentityService; + + @Test + public void requestWithoutPrincipalHeaderIsRejected() { + ResponseEntity entity = restTemplate.getForEntity(processDefinitionsUrl(), String.class); + + // In pre-auth mode there is no HTTP Basic challenge to issue, so a request that carries + // no principal header is an anonymous request that the authorization rules deny: 403, + // not a 401 with a WWW-Authenticate prompt. A trusted proxy is expected to always set + // the header, so this is the "proxy misconfigured / bypassed" path. + assertThat(entity.getStatusCode()) + .as("GET process-definitions without a principal header") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void userWithRestApiPrivilegeCanAccessRestApiViaHeader() { + List privileges = idmIdentityService.createPrivilegeQuery().userId("rest-admin").list(); + assertThat(privileges) + .extracting(Privilege::getName) + .as("rest-admin privileges") + .contains(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + + HttpEntity request = new HttpEntity<>(headerFor("rest-admin")); + ResponseEntity entity = restTemplate.exchange(processDefinitionsUrl(), HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET process-definitions as rest-admin") + .isEqualTo(HttpStatus.OK); + } + + @Test + public void userWithoutRestApiPrivilegeIsForbidden() { + User user = idmIdentityService.createUserQuery().userId("test-user").singleResult(); + assertThat(user).as("test-user").isNotNull(); + List privileges = idmIdentityService.createPrivilegeQuery().userId("test-user").list(); + assertThat(privileges) + .extracting(Privilege::getName) + .as("test-user privileges") + .doesNotContain(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + + HttpEntity request = new HttpEntity<>(headerFor("test-user")); + ResponseEntity entity = restTemplate.exchange(processDefinitionsUrl(), HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET process-definitions as test-user (no access-rest-api)") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void adminUserCanAccessActuatorViaHeader() { + HttpEntity request = new HttpEntity<>(headerFor("rest-admin")); + String actuatorUrl = "http://localhost:" + serverPort + "/flowable-rest/actuator"; + ResponseEntity entity = restTemplate.exchange(actuatorUrl, HttpMethod.GET, request, Object.class); + + assertThat(entity.getStatusCode()) + .as("GET actuator as rest-admin (has access-admin)") + .isEqualTo(HttpStatus.OK); + } + + @Test + public void nonAdminUserCannotAccessActuatorViaHeader() { + HttpEntity request = new HttpEntity<>(headerFor("test-user")); + String actuatorUrl = "http://localhost:" + serverPort + "/flowable-rest/actuator"; + ResponseEntity entity = restTemplate.exchange(actuatorUrl, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET actuator as test-user (no access-admin)") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + private String processDefinitionsUrl() { + return "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions"; + } + + protected static HttpHeaders headerFor(String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.set(PRINCIPAL_HEADER, userId); + return headers; + } + + @TestConfiguration + public static class TestBootstrapConfiguration { + + @Bean + public CommandLineRunner initTestUsers(IdmIdentityService idmIdentityService) { + return args -> { + User testUser = idmIdentityService.createUserQuery().userId("test-user").singleResult(); + if (testUser == null) { + User user = idmIdentityService.newUser("test-user"); + user.setPassword("test"); + idmIdentityService.saveUser(user); + } + }; + } + } + +} diff --git a/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java b/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java new file mode 100644 index 00000000000..1587a93b4b8 --- /dev/null +++ b/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java @@ -0,0 +1,82 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.flowable.rest.security; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * Resolves the granted authorities for a user that a trusted reverse proxy has already + * authenticated and whose id is presented in a request header (see the {@code pre-auth} + * authentication mode of the REST app). + * + *

The privileges are loaded from the IDM engine for the header-provided user id, mirroring + * {@link BasicAuthenticationProvider}: with privilege verification on, the user's actual + * privileges are granted (so the {@code access-rest-api} / {@code access-admin} checks behave + * identically to HTTP Basic); with it off, {@code access-rest-api} is granted unconditionally. + * + *

No password is checked here. Authentication has already happened at the proxy, and this + * mode is only safe when the app is not reachable except through that proxy and the proxy + * strips any client-supplied copy of the principal header. + * + * @author Arief Hidayat + */ +public class PreAuthenticatedUserDetailsService + implements AuthenticationUserDetailsService { + + protected final IdmIdentityService idmIdentityService; + protected boolean verifyRestApiPrivilege; + + public PreAuthenticatedUserDetailsService(IdmIdentityService idmIdentityService) { + this.idmIdentityService = idmIdentityService; + } + + @Override + public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) { + String userId = token.getName(); + + Collection grantedAuthorities = new ArrayList<>(); + if (verifyRestApiPrivilege) { + List privileges = idmIdentityService.createPrivilegeQuery().userId(userId).list(); + for (Privilege privilege : privileges) { + grantedAuthorities.add(new SimpleGrantedAuthority(privilege.getName())); + } + } else { + // Matches BasicAuthenticationProvider: when the privilege is not verified, grant it + // so the downstream authorization rule is satisfied for any authenticated user. + grantedAuthorities.add(new SimpleGrantedAuthority(SecurityConstants.PRIVILEGE_ACCESS_REST_API)); + } + + // A UserDetails must carry a (non-empty) password; it is never used, as no credential is + // checked in this flow. + return new User(userId, "", grantedAuthorities); + } + + public boolean isVerifyRestApiPrivilege() { + return verifyRestApiPrivilege; + } + + public void setVerifyRestApiPrivilege(boolean verifyRestApiPrivilege) { + this.verifyRestApiPrivilege = verifyRestApiPrivilege; + } +} diff --git a/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java b/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java new file mode 100644 index 00000000000..c3c8b0b3218 --- /dev/null +++ b/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java @@ -0,0 +1,94 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.flowable.rest.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.flowable.idm.api.PrivilegeQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * @author Arief Hidayat + */ +@ExtendWith(MockitoExtension.class) +class PreAuthenticatedUserDetailsServiceTest { + + @Mock + protected IdmIdentityService idmIdentityService; + + @Mock + protected PrivilegeQuery privilegeQuery; + + @Mock + protected Privilege accessRestApi; + + @Mock + protected Privilege accessAdmin; + + protected PreAuthenticatedUserDetailsService service; + + @BeforeEach + void setUp() { + service = new PreAuthenticatedUserDetailsService(idmIdentityService); + } + + @Test + void withPrivilegeVerificationGrantsTheUsersActualPrivileges() { + service.setVerifyRestApiPrivilege(true); + lenient().when(accessRestApi.getName()).thenReturn(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + lenient().when(accessAdmin.getName()).thenReturn(SecurityConstants.ACCESS_ADMIN); + when(idmIdentityService.createPrivilegeQuery()).thenReturn(privilegeQuery); + when(privilegeQuery.userId("alice")).thenReturn(privilegeQuery); + when(privilegeQuery.list()).thenReturn(List.of(accessRestApi, accessAdmin)); + + UserDetails details = service.loadUserDetails(new PreAuthenticatedAuthenticationToken("alice", "n/a")); + + assertThat(details.getUsername()).isEqualTo("alice"); + assertThat(details.getAuthorities().stream().map(GrantedAuthority::getAuthority)) + .containsExactlyInAnyOrder(SecurityConstants.PRIVILEGE_ACCESS_REST_API, SecurityConstants.ACCESS_ADMIN); + } + + @Test + void withoutPrivilegeVerificationGrantsAccessRestApiUnconditionally() { + service.setVerifyRestApiPrivilege(false); + + UserDetails details = service.loadUserDetails(new PreAuthenticatedAuthenticationToken("bob", "n/a")); + + assertThat(details.getUsername()).isEqualTo("bob"); + assertThat(details.getAuthorities().stream().map(GrantedAuthority::getAuthority)) + .containsExactly(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + } + + @Test + void neverConsultsIdmWhenPrivilegeVerificationIsOff() { + service.setVerifyRestApiPrivilege(false); + + service.loadUserDetails(new PreAuthenticatedAuthenticationToken("carol", "n/a")); + + // No password is checked and, with verification off, no IDM lookup happens either. + org.mockito.Mockito.verifyNoInteractions(idmIdentityService); + } +} From 23113438572680e62eb21d72d0070b3c44f426a9 Mon Sep 17 00:00:00 2001 From: Arief Hidayat Date: Thu, 13 Aug 2026 07:07:35 +0700 Subject: [PATCH 2/2] Add optional trusted-proxy allowlist to pre-auth mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defence in depth for the pre-auth authentication mode, addressing review feedback: the mode's safety otherwise rests entirely on the deployment assumption that the proxy strips client-supplied copies of the principal header, with no backstop in the app. If the app becomes reachable off the proxy, or the proxy forwards an inbound X-Forwarded-* header, any caller could assert an arbitrary identity. New optional property flowable.rest.app.pre-auth.trusted-proxies takes a list of IPs / CIDR ranges. When set, the principal header is only honoured if the request's transport peer address (ServletRequest#getRemoteAddr(), not an X-Forwarded-For value) matches the allowlist; otherwise the header is ignored and the request is denied exactly like a missing header. Empty by default, so existing deployments are unchanged. This binds the trusted identity to the proxy it came from rather than to the header alone, and complements — does not replace — the header-stripping requirement. Implemented by wrapping RequestHeaderAuthenticationFilter with an IpAddressMatcher check in getPreAuthenticatedPrincipal. Tests: FlowableRestApplicationPreAuthUntrustedProxyTest asserts a valid, privileged principal header from a source outside the allowlist is rejected with 403 — the case the earlier header-present/absent suite could not distinguish from a legitimate request. FlowableRestApplicationPreAuthTrustedProxyTest asserts a header from an allowlisted (loopback) source is honoured with 200. --- .../app/properties/RestAppProperties.java | 26 +++++++ .../rest/conf/SecurityConfiguration.java | 40 +++++++++- ...estApplicationPreAuthTrustedProxyTest.java | 68 +++++++++++++++++ ...tApplicationPreAuthUntrustedProxyTest.java | 73 +++++++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java create mode 100644 modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java index 5ed81fb2097..5888965618a 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java @@ -12,7 +12,9 @@ */ package org.flowable.rest.app.properties; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -164,6 +166,22 @@ public static class PreAuth { */ private String principalHeader = "X-Forwarded-User"; + /** + * Optional defence-in-depth allowlist of trusted proxy source addresses, as IPs or CIDR + * ranges (e.g. {@code 10.0.0.0/8}, {@code 192.168.1.5}). Empty by default, which keeps + * the behaviour of trusting the principal header on every request. + * + *

When set, the principal header is only honoured if the request's transport + * peer ({@code ServletRequest#getRemoteAddr()}, not an {@code X-Forwarded-For} + * value) matches one of these entries; otherwise the request is treated as if it carried + * no header and is denied. This binds the trusted identity to the proxy it came from + * rather than to the header alone, so a single misconfiguration (the app becoming + * reachable off the proxy, or a proxy that forwards an inbound {@code X-Forwarded-*} + * header) cannot be exploited from an arbitrary source. It complements, and does not + * replace, the requirement that the proxy strip client-supplied copies of the header. + */ + private List trustedProxies = new ArrayList<>(); + public String getPrincipalHeader() { return principalHeader; } @@ -171,6 +189,14 @@ public String getPrincipalHeader() { public void setPrincipalHeader(String principalHeader) { this.principalHeader = principalHeader; } + + public List getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(List trustedProxies) { + this.trustedProxies = trustedProxies; + } } public static class Cors { diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java index e739cfb5b34..d6f529f3947 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java @@ -12,6 +12,9 @@ */ package org.flowable.rest.conf; +import java.util.ArrayList; +import java.util.List; + import org.apache.commons.lang3.StringUtils; import org.flowable.idm.api.IdmIdentityService; import org.flowable.rest.app.properties.RestAppProperties; @@ -33,6 +36,7 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.IpAddressMatcher; @Configuration(proxyBeanMethods = false) @EnableWebSecurity @@ -110,7 +114,8 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv // Identity comes from a header set by a trusted proxy, not HTTP Basic. The filter // builds a PreAuthenticatedAuthenticationToken from the header, which the // PreAuthenticatedAuthenticationProvider above resolves against IDM. - RequestHeaderAuthenticationFilter preAuthFilter = new RequestHeaderAuthenticationFilter(); + RequestHeaderAuthenticationFilter preAuthFilter = trustedProxyAware( + new RequestHeaderAuthenticationFilter()); preAuthFilter.setPrincipalRequestHeader(restAppProperties.getPreAuth().getPrincipalHeader()); // Missing header simply yields an anonymous request that the authorization rules // above reject with 401/403, rather than a 500. @@ -137,6 +142,39 @@ protected boolean isPreAuth() { return MODE_PRE_AUTH.equals(restAppProperties.getAuthenticationMode()); } + /** + * Wraps the pre-auth filter so that, when a trusted-proxy allowlist is configured, the + * principal header is only read from requests whose transport peer address matches the + * allowlist. A request from any other source is treated as if it carried no header + * (principal resolves to {@code null}) and is denied by the authorization rules, exactly + * like a missing header. With no allowlist configured the plain filter is returned and + * behaviour is unchanged. + */ + protected RequestHeaderAuthenticationFilter trustedProxyAware(RequestHeaderAuthenticationFilter delegate) { + List trustedProxies = restAppProperties.getPreAuth().getTrustedProxies(); + if (trustedProxies == null || trustedProxies.isEmpty()) { + return delegate; + } + List matchers = new ArrayList<>(trustedProxies.size()); + for (String entry : trustedProxies) { + matchers.add(new IpAddressMatcher(entry)); + } + return new RequestHeaderAuthenticationFilter() { + + @Override + protected Object getPreAuthenticatedPrincipal(jakarta.servlet.http.HttpServletRequest request) { + String remoteAddr = request.getRemoteAddr(); + for (IpAddressMatcher matcher : matchers) { + if (matcher.matches(remoteAddr)) { + return super.getPreAuthenticatedPrincipal(request); + } + } + // Untrusted source: ignore the header entirely. + return null; + } + }; + } + protected boolean isSwaggerDocsEnabled() { return restAppProperties.isSwaggerDocsEnabled(); } diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java new file mode 100644 index 00000000000..1c206c5fb2c --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java @@ -0,0 +1,68 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.flowable.rest.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Complements {@link FlowableRestApplicationPreAuthUntrustedProxyTest}: with an allowlist that + * DOES contain the test client's loopback source, a request carrying a privileged principal + * header is honoured and succeeds. Together the two tests pin both sides of the trusted-proxy + * contract — trusted source honoured, untrusted source ignored. + * + * @author Arief Hidayat + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "flowable.rest.app.authentication-mode=pre-auth", + "flowable.rest.app.pre-auth.principal-header=X-Forwarded-User", + // Both IPv4 and IPv6 loopback, since the test client may connect over either. + "flowable.rest.app.pre-auth.trusted-proxies=127.0.0.1/32,::1" + } +) +@AutoConfigureTestRestTemplate +public class FlowableRestApplicationPreAuthTrustedProxyTest { + + @LocalServerPort + private int serverPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void principalHeaderFromTrustedSourceIsHonoured() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-User", "rest-admin"); + HttpEntity request = new HttpEntity<>(headers); + + String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions"; + ResponseEntity entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("principal header from a trusted source address") + .isEqualTo(HttpStatus.OK); + } +} diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java new file mode 100644 index 00000000000..792fe0d58f7 --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java @@ -0,0 +1,73 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.flowable.rest.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Pins the trusted-proxy contract: with an allowlist configured that does NOT contain the test + * client's source address, a request carrying a valid principal header must still be rejected. + * This is the case a plain header-present / header-absent suite cannot distinguish — a spoofed + * header from an untrusted source looks identical to a legitimate one unless the source address + * is checked. + * + *

The allowlist is {@code 10.0.0.0/8}; the {@link TestRestTemplate} connects from loopback + * ({@code 127.0.0.1}), which is outside that range, so the header is ignored and the request is + * denied. + * + * @author Arief Hidayat + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "flowable.rest.app.authentication-mode=pre-auth", + "flowable.rest.app.pre-auth.principal-header=X-Forwarded-User", + "flowable.rest.app.pre-auth.trusted-proxies=10.0.0.0/8" + } +) +@AutoConfigureTestRestTemplate +public class FlowableRestApplicationPreAuthUntrustedProxyTest { + + @LocalServerPort + private int serverPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void principalHeaderFromUntrustedSourceIsIgnored() { + HttpHeaders headers = new HttpHeaders(); + // A valid, privileged user id -- but arriving from a source outside the allowlist. + headers.set("X-Forwarded-User", "rest-admin"); + HttpEntity request = new HttpEntity<>(headers); + + String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions"; + ResponseEntity entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("spoofed principal header from an untrusted source address") + .isEqualTo(HttpStatus.FORBIDDEN); + } +}