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..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;
@@ -31,6 +33,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 +57,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 +97,10 @@ public Admin getAdmin() {
return admin;
}
+ public PreAuth getPreAuth() {
+ return preAuth;
+ }
+
public String getRolePrefix() {
return rolePrefix;
}
@@ -139,6 +152,53 @@ 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";
+
+ /**
+ * 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;
+ }
+
+ 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 {
/**
* 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..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,9 +12,14 @@
*/
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;
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 +33,18 @@
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;
+import org.springframework.security.web.util.matcher.IpAddressMatcher;
@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 +52,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 +89,7 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv
httpSecurity
.authorizeHttpRequests(
authorizeRequests -> authorizeRequests.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/docs/**")).denyAll());
-
+
}
httpSecurity
@@ -82,25 +104,77 @@ 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 = 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.
+ 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());
+ }
+
+ /**
+ * 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);
+ }
+}
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